Refactor and enhance unified frontend and API features
- Updated environment configuration to unify frontend for mail and drive under a single service. - Revised README to reflect changes in frontend setup and routing for the unified application. - Introduced new API documentation endpoints for better accessibility of API specifications. - Enhanced drive and mail services with improved handling of file uploads and metadata enrichment. - Implemented new API token management features, including creation, listing, and revocation of tokens. - Added tests for new functionalities in drive and mail services to ensure reliability and correctness.
This commit is contained in:
parent
556d5f416d
commit
bd7534658a
@ -236,10 +236,8 @@ MAIL_MICROSOFT_OAUTH_CLIENT_SECRET=
|
|||||||
MAIL_MICROSOFT_OAUTH_TENANT=common
|
MAIL_MICROSOFT_OAUTH_TENANT=common
|
||||||
MAIL_OAUTH_REDIRECT_URL=
|
MAIL_OAUTH_REDIRECT_URL=
|
||||||
MAIL_APP_URL=http://localhost/mail
|
MAIL_APP_URL=http://localhost/mail
|
||||||
# Cible nginx → frontend mail (dev: Next sur l'hôte ; prod: ultimail:3000 si container)
|
# Cible nginx → suite frontend unifié mail+drive (dev: Next sur l'hôte ; prod: suite-frontend:3000)
|
||||||
MAIL_FRONTEND_UPSTREAM=host.docker.internal:3000
|
MAIL_FRONTEND_UPSTREAM=host.docker.internal:3000
|
||||||
# Dev: pnpm dev drive-suite sur :3001 | Prod sans dev local: drive-suite:3000
|
|
||||||
DRIVE_FRONTEND_UPSTREAM=host.docker.internal:3001
|
|
||||||
MAIL_WEBHOOK_SHARED_SECRET_ROTATED_AT=2026-01-01T00:00:00Z
|
MAIL_WEBHOOK_SHARED_SECRET_ROTATED_AT=2026-01-01T00:00:00Z
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|||||||
@ -48,10 +48,7 @@ cp .env.example .env
|
|||||||
- Authentik OAuth apps **Ultimail** (`ulti-backend`) and **Nextcloud** via blueprints in `deploy/authentik/blueprints/`
|
- Authentik OAuth apps **Ultimail** (`ulti-backend`) and **Nextcloud** via blueprints in `deploy/authentik/blueprints/`
|
||||||
- OIDC issuer for `ultid` via internal nginx: `ULTID_OIDC_ISSUER=http://nginx/auth/application/o/ulti/`
|
- OIDC issuer for `ultid` via internal nginx: `ULTID_OIDC_ISSUER=http://nginx/auth/application/o/ulti/`
|
||||||
|
|
||||||
**Frontends** (stack + `pnpm dev` sur l’hôte, nginx route tout sur le port 80) :
|
**Frontend suite unifié** (`gmail-interface-clone` — mail + drive + contacts) : `.env.local` avec `NEXT_PUBLIC_APP_URL=http://localhost`, puis `pnpm dev` → http://localhost/mail/ et http://localhost/drive/
|
||||||
|
|
||||||
- **Ultimail** (`gmail-interface-clone`) : `.env.local` avec `NEXT_PUBLIC_APP_URL=http://localhost`, puis `pnpm dev` → http://localhost/mail/
|
|
||||||
- **UltiDrive** (`drive-suite`) : `.env.local` avec `NEXT_PUBLIC_APP_URL=http://localhost/drive` et `NEXT_PUBLIC_BASE_PATH=/drive`, puis `pnpm dev` → http://localhost/drive/
|
|
||||||
|
|
||||||
| Service | URL |
|
| Service | URL |
|
||||||
|---------|-----|
|
|---------|-----|
|
||||||
@ -88,8 +85,7 @@ Un seul **nginx** expose l’entrée HTTP (`:80`) et route :
|
|||||||
| `/auth/*` | Authentik |
|
| `/auth/*` | Authentik |
|
||||||
| `/meet/*` | Jitsi (si `JITSI_ENABLED=true`) |
|
| `/meet/*` | Jitsi (si `JITSI_ENABLED=true`) |
|
||||||
| `/cloud/*` | Nextcloud nginx+FPM (si `NEXTCLOUD_ENABLED=true`) |
|
| `/cloud/*` | Nextcloud nginx+FPM (si `NEXTCLOUD_ENABLED=true`) |
|
||||||
| `/mail/*` | Ultimail (`MAIL_FRONTEND_UPSTREAM`, défaut `host.docker.internal:3000`) |
|
| `/mail/*`, `/drive/*`, `/contacts` | Suite frontend (`MAIL_FRONTEND_UPSTREAM`, défaut `host.docker.internal:3000`) |
|
||||||
| `/drive/*` | UltiDrive (`drive-suite`) |
|
|
||||||
|
|
||||||
Nextcloud : FPM + nginx dédié ; ultid appelle `NEXTCLOUD_URL` en interne (`http://nextcloud:80`).
|
Nextcloud : FPM + nginx dédié ; ultid appelle `NEXTCLOUD_URL` en interne (`http://nextcloud:80`).
|
||||||
Caddy retiré : un seul proxy évite la double couche ; TLS plus tard (certbot, Traefik, ou `listen 443` nginx).
|
Caddy retiré : un seul proxy évite la double couche ; TLS plus tard (certbot, Traefik, ou `listen 443` nginx).
|
||||||
|
|||||||
@ -19,11 +19,13 @@ import (
|
|||||||
"github.com/redis/go-redis/v9"
|
"github.com/redis/go-redis/v9"
|
||||||
|
|
||||||
"github.com/ultisuite/ulti-backend/internal/api/admin"
|
"github.com/ultisuite/ulti-backend/internal/api/admin"
|
||||||
|
"github.com/ultisuite/ulti-backend/internal/api/docs"
|
||||||
"github.com/ultisuite/ulti-backend/internal/api/calendar"
|
"github.com/ultisuite/ulti-backend/internal/api/calendar"
|
||||||
"github.com/ultisuite/ulti-backend/internal/api/contacts"
|
"github.com/ultisuite/ulti-backend/internal/api/contacts"
|
||||||
"github.com/ultisuite/ulti-backend/internal/api/drive"
|
"github.com/ultisuite/ulti-backend/internal/api/drive"
|
||||||
"github.com/ultisuite/ulti-backend/internal/api/office"
|
"github.com/ultisuite/ulti-backend/internal/api/office"
|
||||||
mailapi "github.com/ultisuite/ulti-backend/internal/api/mail"
|
mailapi "github.com/ultisuite/ulti-backend/internal/api/mail"
|
||||||
|
"github.com/ultisuite/ulti-backend/internal/api/mail/drivebridge"
|
||||||
"github.com/ultisuite/ulti-backend/internal/api/mail/sendguard"
|
"github.com/ultisuite/ulti-backend/internal/api/mail/sendguard"
|
||||||
meetapi "github.com/ultisuite/ulti-backend/internal/api/meet"
|
meetapi "github.com/ultisuite/ulti-backend/internal/api/meet"
|
||||||
"github.com/ultisuite/ulti-backend/internal/api/middleware"
|
"github.com/ultisuite/ulti-backend/internal/api/middleware"
|
||||||
@ -207,6 +209,8 @@ func main() {
|
|||||||
r.Use(observability.HTTPMetrics)
|
r.Use(observability.HTTPMetrics)
|
||||||
r.Use(middleware.Logging)
|
r.Use(middleware.Logging)
|
||||||
|
|
||||||
|
r.Mount("/api/docs", docs.NewHandler().Routes())
|
||||||
|
|
||||||
r.Get("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
r.Get("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||||
report := healthChecker.Check(r.Context())
|
report := healthChecker.Check(r.Context())
|
||||||
statusCode := http.StatusOK
|
statusCode := http.StatusOK
|
||||||
@ -225,8 +229,9 @@ func main() {
|
|||||||
var driveHandler *drive.Handler
|
var driveHandler *drive.Handler
|
||||||
var driveSvc *drive.Service
|
var driveSvc *drive.Service
|
||||||
if ncClient != nil {
|
if ncClient != nil {
|
||||||
driveSvc = drive.NewService(ncClient, hub)
|
driveSvc = drive.NewService(ncClient, hub, pool)
|
||||||
driveHandler = drive.NewHandler(ncClient, hub)
|
driveHandler = drive.NewHandlerWithService(driveSvc)
|
||||||
|
mailHandler.SetDriveUploader(&drivebridge.Bridge{Svc: driveSvc})
|
||||||
}
|
}
|
||||||
if ncClient != nil && cfg.OnlyOfficeEnabled && driveSvc != nil {
|
if ncClient != nil && cfg.OnlyOfficeEnabled && driveSvc != nil {
|
||||||
officeSvc := office.NewService(ncClient, office.Config{
|
officeSvc := office.NewService(ncClient, office.Config{
|
||||||
@ -246,6 +251,7 @@ func main() {
|
|||||||
|
|
||||||
r.Group(func(r chi.Router) {
|
r.Group(func(r chi.Router) {
|
||||||
r.Use(middleware.Auth(verifierHolder, pool, auditLogger))
|
r.Use(middleware.Auth(verifierHolder, pool, auditLogger))
|
||||||
|
r.Use(middleware.EnforceApiTokenPolicy())
|
||||||
|
|
||||||
r.Mount("/api/v1/mail", mailHandler.Routes())
|
r.Mount("/api/v1/mail", mailHandler.Routes())
|
||||||
r.Mount("/api/v1/admin", admin.NewHandler(pool, auditLogger).Routes())
|
r.Mount("/api/v1/admin", admin.NewHandler(pool, auditLogger).Routes())
|
||||||
|
|||||||
@ -29,18 +29,10 @@ entries:
|
|||||||
url: http://localhost/api/auth/callback
|
url: http://localhost/api/auth/callback
|
||||||
- matching_mode: strict
|
- matching_mode: strict
|
||||||
url: http://127.0.0.1/api/auth/callback
|
url: http://127.0.0.1/api/auth/callback
|
||||||
- matching_mode: strict
|
|
||||||
url: http://localhost/drive/api/auth/callback
|
|
||||||
- matching_mode: strict
|
|
||||||
url: http://127.0.0.1/drive/api/auth/callback
|
|
||||||
- matching_mode: strict
|
- matching_mode: strict
|
||||||
url: http://localhost:3000/api/auth/callback
|
url: http://localhost:3000/api/auth/callback
|
||||||
- matching_mode: strict
|
- matching_mode: strict
|
||||||
url: http://127.0.0.1:3000/api/auth/callback
|
url: http://127.0.0.1:3000/api/auth/callback
|
||||||
- matching_mode: strict
|
|
||||||
url: http://localhost:3001/api/auth/callback
|
|
||||||
- matching_mode: strict
|
|
||||||
url: http://127.0.0.1:3001/api/auth/callback
|
|
||||||
signing_key: !Find [authentik_crypto.certificatekeypair, [name, authentik Self-signed Certificate]]
|
signing_key: !Find [authentik_crypto.certificatekeypair, [name, authentik Self-signed Certificate]]
|
||||||
|
|
||||||
- model: authentik_core.application
|
- model: authentik_core.application
|
||||||
|
|||||||
@ -9,7 +9,6 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
DOMAIN: ${DOMAIN:-localhost}
|
DOMAIN: ${DOMAIN:-localhost}
|
||||||
MAIL_FRONTEND_UPSTREAM: ${MAIL_FRONTEND_UPSTREAM:-host.docker.internal:3000}
|
MAIL_FRONTEND_UPSTREAM: ${MAIL_FRONTEND_UPSTREAM:-host.docker.internal:3000}
|
||||||
DRIVE_FRONTEND_UPSTREAM: ${DRIVE_FRONTEND_UPSTREAM:-host.docker.internal:3001}
|
|
||||||
env_file: ../.env.resolved
|
env_file: ../.env.resolved
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
- "host.docker.internal:host-gateway"
|
- "host.docker.internal:host-gateway"
|
||||||
@ -202,15 +201,15 @@ services:
|
|||||||
prometheus:
|
prometheus:
|
||||||
condition: service_started
|
condition: service_started
|
||||||
|
|
||||||
drive-suite:
|
suite-frontend:
|
||||||
build:
|
build:
|
||||||
context: ../../drive-suite
|
context: ../../gmail-interface-clone
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
- ULTI_PROXY_ORIGIN=http://nginx
|
- ULTI_PROXY_ORIGIN=http://nginx
|
||||||
- NEXT_PUBLIC_API_URL=/api/v1
|
- NEXT_PUBLIC_API_URL=/api/v1
|
||||||
- NEXT_PUBLIC_APP_URL=http://${DOMAIN:-localhost}/drive
|
- NEXT_PUBLIC_APP_URL=http://${DOMAIN:-localhost}
|
||||||
- OIDC_CLIENT_SECRET=${ULTID_OIDC_CLIENT_SECRET:-changeme}
|
- OIDC_CLIENT_SECRET=${ULTID_OIDC_CLIENT_SECRET:-changeme}
|
||||||
- NEXT_PUBLIC_OIDC_ISSUER=http://${DOMAIN:-localhost}/auth/application/o/ulti/
|
- NEXT_PUBLIC_OIDC_ISSUER=http://${DOMAIN:-localhost}/auth/application/o/ulti/
|
||||||
- NEXT_PUBLIC_OIDC_CLIENT_ID=${ULTID_OIDC_CLIENT_ID:-ulti-backend}
|
- NEXT_PUBLIC_OIDC_CLIENT_ID=${ULTID_OIDC_CLIENT_ID:-ulti-backend}
|
||||||
|
|||||||
@ -157,92 +157,11 @@ server {
|
|||||||
return 301 /office/;
|
return 301 /office/;
|
||||||
}
|
}
|
||||||
|
|
||||||
# UltiDrive — variable proxy_pass must not include a URI path (passes client URI as-is).
|
# UltiDrive — same suite frontend as mail (unified Next.js app)
|
||||||
location ^~ /drive/api/auth/ {
|
|
||||||
resolver 127.0.0.11 valid=10s ipv6=off;
|
|
||||||
set $drive_upstream ${DRIVE_FRONTEND_UPSTREAM};
|
|
||||||
rewrite ^/drive/api/auth/(.*)$ /api/auth/$1 break;
|
|
||||||
proxy_pass http://$drive_upstream;
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
}
|
|
||||||
|
|
||||||
location ^~ /drive/_next/ {
|
|
||||||
resolver 127.0.0.11 valid=10s ipv6=off;
|
|
||||||
set $drive_upstream ${DRIVE_FRONTEND_UPSTREAM};
|
|
||||||
proxy_pass http://$drive_upstream;
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
|
||||||
proxy_set_header Connection $connection_upgrade;
|
|
||||||
}
|
|
||||||
|
|
||||||
# Next.js dev-only assets (no /drive prefix in generated URLs with assetPrefix-only setup)
|
|
||||||
location ^~ /__nextjs_font/ {
|
|
||||||
resolver 127.0.0.11 valid=10s ipv6=off;
|
|
||||||
set $drive_upstream ${DRIVE_FRONTEND_UPSTREAM};
|
|
||||||
proxy_pass http://$drive_upstream;
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
}
|
|
||||||
|
|
||||||
location = /__nextjs_original-stack-frames {
|
|
||||||
resolver 127.0.0.11 valid=10s ipv6=off;
|
|
||||||
set $drive_upstream ${DRIVE_FRONTEND_UPSTREAM};
|
|
||||||
proxy_pass http://$drive_upstream;
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
}
|
|
||||||
|
|
||||||
location ^~ /__nextjs_source-map {
|
|
||||||
resolver 127.0.0.11 valid=10s ipv6=off;
|
|
||||||
set $drive_upstream ${DRIVE_FRONTEND_UPSTREAM};
|
|
||||||
proxy_pass http://$drive_upstream;
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
}
|
|
||||||
|
|
||||||
location ^~ /drive/login {
|
|
||||||
resolver 127.0.0.11 valid=10s ipv6=off;
|
|
||||||
set $drive_upstream ${DRIVE_FRONTEND_UPSTREAM};
|
|
||||||
rewrite ^/drive/login(.*)$ /login$1 break;
|
|
||||||
proxy_pass http://$drive_upstream;
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
|
||||||
proxy_set_header Connection $connection_upgrade;
|
|
||||||
}
|
|
||||||
|
|
||||||
location ^~ /drive/auth/complete {
|
|
||||||
resolver 127.0.0.11 valid=10s ipv6=off;
|
|
||||||
set $drive_upstream ${DRIVE_FRONTEND_UPSTREAM};
|
|
||||||
rewrite ^/drive/auth/complete(.*)$ /auth/complete$1 break;
|
|
||||||
proxy_pass http://$drive_upstream;
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
|
||||||
proxy_set_header Connection $connection_upgrade;
|
|
||||||
}
|
|
||||||
|
|
||||||
location /drive/ {
|
location /drive/ {
|
||||||
resolver 127.0.0.11 valid=10s ipv6=off;
|
resolver 127.0.0.11 valid=10s ipv6=off;
|
||||||
set $drive_upstream ${DRIVE_FRONTEND_UPSTREAM};
|
set $mail_upstream ${MAIL_FRONTEND_UPSTREAM};
|
||||||
proxy_pass http://$drive_upstream;
|
proxy_pass http://$mail_upstream;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
@ -254,8 +173,8 @@ server {
|
|||||||
|
|
||||||
location = /drive {
|
location = /drive {
|
||||||
resolver 127.0.0.11 valid=10s ipv6=off;
|
resolver 127.0.0.11 valid=10s ipv6=off;
|
||||||
set $drive_upstream ${DRIVE_FRONTEND_UPSTREAM};
|
set $mail_upstream ${MAIL_FRONTEND_UPSTREAM};
|
||||||
proxy_pass http://$drive_upstream;
|
proxy_pass http://$mail_upstream;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
@ -265,8 +184,8 @@ server {
|
|||||||
proxy_set_header Connection $connection_upgrade;
|
proxy_set_header Connection $connection_upgrade;
|
||||||
}
|
}
|
||||||
|
|
||||||
# Ultimail frontend — dev: pnpm dev on host (MAIL_FRONTEND_UPSTREAM=host.docker.internal:3000)
|
# Ulti Suite frontend (mail + drive + contacts) — dev: pnpm dev on host (MAIL_FRONTEND_UPSTREAM=host.docker.internal:3000)
|
||||||
# Prod: set MAIL_FRONTEND_UPSTREAM=ultimail:3000 when the container exists.
|
# Prod: set MAIL_FRONTEND_UPSTREAM=suite-frontend:3000
|
||||||
location ^~ /api/auth/ {
|
location ^~ /api/auth/ {
|
||||||
resolver 127.0.0.11 valid=10s ipv6=off;
|
resolver 127.0.0.11 valid=10s ipv6=off;
|
||||||
set $mail_upstream ${MAIL_FRONTEND_UPSTREAM};
|
set $mail_upstream ${MAIL_FRONTEND_UPSTREAM};
|
||||||
|
|||||||
50
internal/api/docs/handler.go
Normal file
50
internal/api/docs/handler.go
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
package docs
|
||||||
|
|
||||||
|
import (
|
||||||
|
_ "embed"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed openapi.yaml
|
||||||
|
var openAPISpec []byte
|
||||||
|
|
||||||
|
func NewHandler() *Handler {
|
||||||
|
return &Handler{}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Handler struct{}
|
||||||
|
|
||||||
|
func (h *Handler) Routes() chi.Router {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Get("/openapi.yaml", h.serveSpec)
|
||||||
|
r.Get("/", h.serveUI)
|
||||||
|
r.Get("/*", h.serveUI)
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) serveSpec(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/yaml; charset=utf-8")
|
||||||
|
w.Header().Set("Cache-Control", "public, max-age=300")
|
||||||
|
_, _ = w.Write(openAPISpec)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) serveUI(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
_, _ = w.Write([]byte(scalarHTML))
|
||||||
|
}
|
||||||
|
|
||||||
|
const scalarHTML = `<!doctype html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Ulti API — Documentation</title>
|
||||||
|
<style>body { margin: 0; }</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<script id="api-reference" data-url="/api/docs/openapi.yaml"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/@scalar/api-reference"></script>
|
||||||
|
</body>
|
||||||
|
</html>`
|
||||||
552
internal/api/docs/openapi.yaml
Normal file
552
internal/api/docs/openapi.yaml
Normal file
@ -0,0 +1,552 @@
|
|||||||
|
openapi: 3.1.0
|
||||||
|
info:
|
||||||
|
title: Ulti Suite API
|
||||||
|
version: 1.0.0
|
||||||
|
description: |
|
||||||
|
API REST Ultimail, UltiDrive et Contacts exposée par **ultid** sous `/api/v1`.
|
||||||
|
|
||||||
|
## Authentification
|
||||||
|
|
||||||
|
Deux modes :
|
||||||
|
|
||||||
|
| Mode | Header | Usage |
|
||||||
|
|------|--------|-------|
|
||||||
|
| **Session utilisateur** | `Authorization: Bearer <jwt_oidc>` | Interface web, apps avec login OIDC (Authentik) |
|
||||||
|
| **Token API** | `Authorization: Bearer ulti_<secret>` | Agents IA, scripts, intégrations programmatiques |
|
||||||
|
|
||||||
|
Les tokens API portent des permissions **fine-grained** (lecture/écriture par ressource) et des **scopes** optionnels (comptes mail, dossiers Drive).
|
||||||
|
|
||||||
|
## Permissions tokens API
|
||||||
|
|
||||||
|
Ressources principales :
|
||||||
|
|
||||||
|
- **Mail** : `mail.mailboxes`, `mail.labels`, `mail.messages`, `mail.search`, `mail.send`, `mail.attachments`, `mail.settings`, `mail.identities`, `mail.automation`
|
||||||
|
- **Drive** : `drive.folders`, `drive.files`, `drive.thumbnails`, `drive.download`, `drive.share`, `drive.upload`, `drive.rename`, `drive.move`, `drive.copy`
|
||||||
|
- **Contacts** : `contacts.read`, `contacts.search`, `contacts.write`, `contacts.delete`, `contacts.labels`
|
||||||
|
- **Automatisations** : `automation.rules`, `automation.webhooks`, `automation.llm`, `automation.search`, `automation.api_tokens` (super admin)
|
||||||
|
|
||||||
|
Chaque ressource accepte `read` et/ou `write` selon le cas.
|
||||||
|
|
||||||
|
## Scopes
|
||||||
|
|
||||||
|
- **mail_scope** : `{ "all_accounts": true }` ou `{ "all_accounts": false, "account_ids": ["uuid", ...] }`
|
||||||
|
- **drive_scope** : `{ "all_folders": true }` ou `{ "all_folders": false, "folder_paths": ["/Projects", ...] }`
|
||||||
|
|
||||||
|
servers:
|
||||||
|
- url: /api/v1
|
||||||
|
description: API ultid (proxifiée par nginx)
|
||||||
|
|
||||||
|
tags:
|
||||||
|
- name: Tokens API
|
||||||
|
description: Gestion des jetons programmatiques
|
||||||
|
- name: Mail
|
||||||
|
description: Messages, boîtes, envoi
|
||||||
|
- name: Drive
|
||||||
|
description: Fichiers et dossiers Nextcloud
|
||||||
|
- name: Contacts
|
||||||
|
description: Carnet d'adresses
|
||||||
|
- name: Automatisations
|
||||||
|
description: Règles, webhooks, fournisseurs
|
||||||
|
|
||||||
|
components:
|
||||||
|
securitySchemes:
|
||||||
|
bearerAuth:
|
||||||
|
type: http
|
||||||
|
scheme: bearer
|
||||||
|
bearerFormat: JWT or ulti_token
|
||||||
|
description: JWT OIDC (session) ou token API `ulti_…`
|
||||||
|
schemas:
|
||||||
|
ApiTokenPermissionGrant:
|
||||||
|
type: object
|
||||||
|
required: [resource, read, write]
|
||||||
|
properties:
|
||||||
|
resource:
|
||||||
|
type: string
|
||||||
|
example: mail.messages
|
||||||
|
read:
|
||||||
|
type: boolean
|
||||||
|
write:
|
||||||
|
type: boolean
|
||||||
|
ApiTokenMailScope:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
all_accounts:
|
||||||
|
type: boolean
|
||||||
|
account_ids:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
ApiTokenDriveScope:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
all_folders:
|
||||||
|
type: boolean
|
||||||
|
folder_paths:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
example: /Projects
|
||||||
|
ApiToken:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
token_prefix:
|
||||||
|
type: string
|
||||||
|
example: ulti_a3f9b2c1
|
||||||
|
permissions:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/ApiTokenPermissionGrant'
|
||||||
|
mail_scope:
|
||||||
|
$ref: '#/components/schemas/ApiTokenMailScope'
|
||||||
|
drive_scope:
|
||||||
|
$ref: '#/components/schemas/ApiTokenDriveScope'
|
||||||
|
created_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
last_used_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
expires_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
ApiTokenCreated:
|
||||||
|
allOf:
|
||||||
|
- $ref: '#/components/schemas/ApiToken'
|
||||||
|
- type: object
|
||||||
|
required: [token]
|
||||||
|
properties:
|
||||||
|
token:
|
||||||
|
type: string
|
||||||
|
description: Secret complet — affiché une seule fois à la création
|
||||||
|
CreateApiTokenRequest:
|
||||||
|
type: object
|
||||||
|
required: [name, permissions, mail_scope, drive_scope]
|
||||||
|
properties:
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
permissions:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/ApiTokenPermissionGrant'
|
||||||
|
mail_scope:
|
||||||
|
$ref: '#/components/schemas/ApiTokenMailScope'
|
||||||
|
drive_scope:
|
||||||
|
$ref: '#/components/schemas/ApiTokenDriveScope'
|
||||||
|
expires_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
MessageSummary:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
account_id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
subject:
|
||||||
|
type: string
|
||||||
|
snippet:
|
||||||
|
type: string
|
||||||
|
date:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
DriveFile:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
path:
|
||||||
|
type: string
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
type:
|
||||||
|
type: string
|
||||||
|
enum: [file, directory]
|
||||||
|
size:
|
||||||
|
type: integer
|
||||||
|
mime_type:
|
||||||
|
type: string
|
||||||
|
last_modified:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
Error:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
error:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
code:
|
||||||
|
type: string
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
|
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
|
||||||
|
paths:
|
||||||
|
/mail/api-tokens:
|
||||||
|
get:
|
||||||
|
tags: [Tokens API]
|
||||||
|
summary: Lister les tokens API
|
||||||
|
description: Nécessite une session OIDC ou un token avec `automation.api_tokens` (écriture).
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Liste des tokens actifs (sans secret)
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
tokens:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/ApiToken'
|
||||||
|
post:
|
||||||
|
tags: [Tokens API]
|
||||||
|
summary: Créer un token API
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/CreateApiTokenRequest'
|
||||||
|
example:
|
||||||
|
name: Agent tri support
|
||||||
|
permissions:
|
||||||
|
- resource: mail.messages
|
||||||
|
read: true
|
||||||
|
write: false
|
||||||
|
- resource: mail.labels
|
||||||
|
read: true
|
||||||
|
write: true
|
||||||
|
mail_scope:
|
||||||
|
all_accounts: false
|
||||||
|
account_ids: ["550e8400-e29b-41d4-a716-446655440000"]
|
||||||
|
drive_scope:
|
||||||
|
all_folders: true
|
||||||
|
folder_paths: []
|
||||||
|
responses:
|
||||||
|
'201':
|
||||||
|
description: Token créé — copier le champ `token` immédiatement
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ApiTokenCreated'
|
||||||
|
|
||||||
|
/mail/api-tokens/{tokenID}:
|
||||||
|
delete:
|
||||||
|
tags: [Tokens API]
|
||||||
|
summary: Révoquer un token API
|
||||||
|
parameters:
|
||||||
|
- name: tokenID
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
responses:
|
||||||
|
'204':
|
||||||
|
description: Token révoqué
|
||||||
|
|
||||||
|
/mail/messages:
|
||||||
|
get:
|
||||||
|
tags: [Mail]
|
||||||
|
summary: Lister les messages
|
||||||
|
description: |
|
||||||
|
Permission requise : `mail.messages` (lecture).
|
||||||
|
Scope mail appliqué automatiquement si le token est restreint à certains comptes.
|
||||||
|
parameters:
|
||||||
|
- name: account_id
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
- name: folder
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
example: inbox
|
||||||
|
- name: page
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
- name: page_size
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Page de messages
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
messages:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/MessageSummary'
|
||||||
|
|
||||||
|
/mail/messages/{messageID}:
|
||||||
|
get:
|
||||||
|
tags: [Mail]
|
||||||
|
summary: Lire un message
|
||||||
|
description: Vérifie que le message appartient à un compte autorisé par le token.
|
||||||
|
parameters:
|
||||||
|
- name: messageID
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Message complet
|
||||||
|
'403':
|
||||||
|
description: Compte hors scope du token
|
||||||
|
|
||||||
|
/mail/search:
|
||||||
|
get:
|
||||||
|
tags: [Mail]
|
||||||
|
summary: Rechercher des messages
|
||||||
|
description: Permission `mail.search` (lecture).
|
||||||
|
parameters:
|
||||||
|
- name: q
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
- name: account_id
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
- name: from
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Résultats de recherche
|
||||||
|
|
||||||
|
/mail/send:
|
||||||
|
post:
|
||||||
|
tags: [Mail]
|
||||||
|
summary: Envoyer un message
|
||||||
|
description: Permission `mail.send` (écriture). `account_id` doit être dans le scope.
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [account_id, to, subject]
|
||||||
|
properties:
|
||||||
|
account_id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
to:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
subject:
|
||||||
|
type: string
|
||||||
|
body_html:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Message envoyé ou mis en file
|
||||||
|
|
||||||
|
/mail/rules:
|
||||||
|
get:
|
||||||
|
tags: [Automatisations]
|
||||||
|
summary: Lister les règles de tri
|
||||||
|
description: Permission `automation.rules` (lecture).
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Règles
|
||||||
|
post:
|
||||||
|
tags: [Automatisations]
|
||||||
|
summary: Créer une règle
|
||||||
|
description: Permission `automation.rules` (écriture).
|
||||||
|
responses:
|
||||||
|
'201':
|
||||||
|
description: Règle créée
|
||||||
|
|
||||||
|
/mail/webhooks:
|
||||||
|
get:
|
||||||
|
tags: [Automatisations]
|
||||||
|
summary: Lister les webhooks
|
||||||
|
description: Permission `automation.webhooks` (lecture).
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Webhooks
|
||||||
|
post:
|
||||||
|
tags: [Automatisations]
|
||||||
|
summary: Créer un webhook
|
||||||
|
responses:
|
||||||
|
'201':
|
||||||
|
description: Webhook créé
|
||||||
|
|
||||||
|
/drive/files/{path}:
|
||||||
|
get:
|
||||||
|
tags: [Drive]
|
||||||
|
summary: Lister un dossier
|
||||||
|
description: Permission `drive.folders` ou `drive.files` (lecture). Path relatif au Drive.
|
||||||
|
parameters:
|
||||||
|
- name: path
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
example: Projects/docs
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Contenu du dossier
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
files:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/DriveFile'
|
||||||
|
post:
|
||||||
|
tags: [Drive]
|
||||||
|
summary: Uploader un fichier
|
||||||
|
description: Permission `drive.upload` (écriture). Path = dossier cible.
|
||||||
|
parameters:
|
||||||
|
- name: path
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
requestBody:
|
||||||
|
content:
|
||||||
|
multipart/form-data:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
file:
|
||||||
|
type: string
|
||||||
|
format: binary
|
||||||
|
responses:
|
||||||
|
'201':
|
||||||
|
description: Fichier uploadé
|
||||||
|
|
||||||
|
/drive/download/{path}:
|
||||||
|
get:
|
||||||
|
tags: [Drive]
|
||||||
|
summary: Télécharger un fichier
|
||||||
|
description: Permission `drive.download` (lecture).
|
||||||
|
parameters:
|
||||||
|
- name: path
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Contenu binaire
|
||||||
|
|
||||||
|
/drive/preview/{path}:
|
||||||
|
get:
|
||||||
|
tags: [Drive]
|
||||||
|
summary: Miniature / aperçu
|
||||||
|
description: Permission `drive.thumbnails` (lecture).
|
||||||
|
parameters:
|
||||||
|
- name: path
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Image ou flux de prévisualisation
|
||||||
|
|
||||||
|
/contacts/search:
|
||||||
|
get:
|
||||||
|
tags: [Contacts]
|
||||||
|
summary: Rechercher des contacts
|
||||||
|
description: Permission `contacts.search` (lecture).
|
||||||
|
parameters:
|
||||||
|
- name: q
|
||||||
|
in: query
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Contacts correspondants
|
||||||
|
|
||||||
|
/contacts/books/{bookID}:
|
||||||
|
get:
|
||||||
|
tags: [Contacts]
|
||||||
|
summary: Lister les contacts d'un carnet
|
||||||
|
description: Permission `contacts.read` (lecture).
|
||||||
|
parameters:
|
||||||
|
- name: bookID
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Contacts du carnet
|
||||||
|
post:
|
||||||
|
tags: [Contacts]
|
||||||
|
summary: Créer un contact
|
||||||
|
description: Permission `contacts.write` (écriture).
|
||||||
|
responses:
|
||||||
|
'201':
|
||||||
|
description: Contact créé
|
||||||
|
|
||||||
|
/contacts/discovery/llm-settings:
|
||||||
|
get:
|
||||||
|
tags: [Automatisations]
|
||||||
|
summary: Lire les fournisseurs LLM
|
||||||
|
description: Permission `automation.llm` (lecture).
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Configuration LLM
|
||||||
|
put:
|
||||||
|
tags: [Automatisations]
|
||||||
|
summary: Mettre à jour les fournisseurs LLM
|
||||||
|
description: Permission `automation.llm` (écriture).
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Configuration mise à jour
|
||||||
|
|
||||||
|
/search:
|
||||||
|
get:
|
||||||
|
tags: [Mail, Drive, Contacts]
|
||||||
|
summary: Recherche cross-suite
|
||||||
|
description: |
|
||||||
|
Vérifie les permissions selon `types` :
|
||||||
|
- `mail` → `mail.search`
|
||||||
|
- `drive` → `drive.files`
|
||||||
|
- `contacts` → `contacts.search`
|
||||||
|
parameters:
|
||||||
|
- name: q
|
||||||
|
in: query
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
- name: types
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
example: mail,contacts,drive
|
||||||
|
- name: account_id
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Résultats agrégés
|
||||||
@ -9,6 +9,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
"github.com/ultisuite/ulti-backend/internal/api/apiresponse"
|
"github.com/ultisuite/ulti-backend/internal/api/apiresponse"
|
||||||
"github.com/ultisuite/ulti-backend/internal/api/apivalidate"
|
"github.com/ultisuite/ulti-backend/internal/api/apivalidate"
|
||||||
@ -26,9 +27,13 @@ type Handler struct {
|
|||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewHandler(nc *nextcloud.Client, hub *realtime.Hub) *Handler {
|
func NewHandler(nc *nextcloud.Client, hub *realtime.Hub, db *pgxpool.Pool) *Handler {
|
||||||
|
return NewHandlerWithService(NewService(nc, hub, db))
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHandlerWithService(svc *Service) *Handler {
|
||||||
return &Handler{
|
return &Handler{
|
||||||
svc: NewService(nc, hub),
|
svc: svc,
|
||||||
logger: slog.Default().With("component", "drive-api"),
|
logger: slog.Default().With("component", "drive-api"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -59,6 +64,8 @@ func (h *Handler) Routes() chi.Router {
|
|||||||
r.With(read).Get("/starred/*", h.ListStarred)
|
r.With(read).Get("/starred/*", h.ListStarred)
|
||||||
r.With(read).Get("/shared", h.ListSharedWithMe)
|
r.With(read).Get("/shared", h.ListSharedWithMe)
|
||||||
r.With(read).Get("/search", h.Search)
|
r.With(read).Get("/search", h.Search)
|
||||||
|
r.With(read).Get("/filter-corpus", h.ListFilterCorpus)
|
||||||
|
r.With(read).Get("/filter-corpus/*", h.ListFilterCorpus)
|
||||||
r.With(read).Get("/shares", h.ListShares)
|
r.With(read).Get("/shares", h.ListShares)
|
||||||
r.With(read).Get("/shares/recipients/lookup", h.LookupShareRecipient)
|
r.With(read).Get("/shares/recipients/lookup", h.LookupShareRecipient)
|
||||||
r.With(read).Get("/download/*", h.Download)
|
r.With(read).Get("/download/*", h.Download)
|
||||||
@ -95,13 +102,32 @@ func (h *Handler) ListFiles(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
path := chi.URLParam(r, "*")
|
path := nextcloud.NormalizeClientPath(chi.URLParam(r, "*"))
|
||||||
result, err := h.svc.ListFiles(r.Context(), ncUser, path, params)
|
result, err := h.svc.ListFiles(r.Context(), ncUser, path, params)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.logger.Error("list files", "error", err)
|
h.logger.Error("list files", "error", err)
|
||||||
apivalidate.WriteInternal(w, r)
|
apivalidate.WriteInternal(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
h.svc.EnrichSources(r.Context(), claims.Sub, result.Files)
|
||||||
|
apiresponse.WriteJSON(w, http.StatusOK, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) ListFilterCorpus(w http.ResponseWriter, r *http.Request) {
|
||||||
|
claims := middleware.ClaimsFromContext(r.Context())
|
||||||
|
ncUser, ok := h.nextcloudUser(w, r, claims)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
path := nextcloud.NormalizeClientPath(chi.URLParam(r, "*"))
|
||||||
|
result, err := h.svc.ListFilterCorpus(r.Context(), ncUser, path)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("list filter corpus", "error", err, "path", path)
|
||||||
|
apivalidate.WriteInternal(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.svc.EnrichSources(r.Context(), claims.Sub, result.Files)
|
||||||
apiresponse.WriteJSON(w, http.StatusOK, result)
|
apiresponse.WriteJSON(w, http.StatusOK, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -258,6 +284,9 @@ func (h *Handler) Move(w http.ResponseWriter, r *http.Request) {
|
|||||||
apivalidate.WriteValidationError(w, r, verr)
|
apivalidate.WriteValidationError(w, r, verr)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if middleware.DenyIfDrivePathOutOfScope(w, r, req.Source, req.Destination) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if err := h.svc.Move(r.Context(), ncUser, req.Source, req.Destination); err != nil {
|
if err := h.svc.Move(r.Context(), ncUser, req.Source, req.Destination); err != nil {
|
||||||
h.logger.Error("move", "error", err)
|
h.logger.Error("move", "error", err)
|
||||||
@ -283,6 +312,9 @@ func (h *Handler) Copy(w http.ResponseWriter, r *http.Request) {
|
|||||||
apivalidate.WriteValidationError(w, r, verr)
|
apivalidate.WriteValidationError(w, r, verr)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if middleware.DenyIfDrivePathOutOfScope(w, r, req.Source, req.Destination) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if err := h.svc.Copy(r.Context(), ncUser, req.Source, req.Destination); err != nil {
|
if err := h.svc.Copy(r.Context(), ncUser, req.Source, req.Destination); err != nil {
|
||||||
h.logger.Error("copy", "error", err)
|
h.logger.Error("copy", "error", err)
|
||||||
@ -307,6 +339,9 @@ func (h *Handler) Rename(w http.ResponseWriter, r *http.Request) {
|
|||||||
apivalidate.WriteValidationError(w, r, verr)
|
apivalidate.WriteValidationError(w, r, verr)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if middleware.DenyIfDrivePathOutOfScope(w, r, req.Path) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if err := h.svc.Rename(r.Context(), ncUser, req.Path, req.NewName); err != nil {
|
if err := h.svc.Rename(r.Context(), ncUser, req.Path, req.NewName); err != nil {
|
||||||
h.logger.Error("rename", "error", err)
|
h.logger.Error("rename", "error", err)
|
||||||
@ -333,6 +368,7 @@ func (h *Handler) ListTrash(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeDriveError(w, r, err)
|
writeDriveError(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
h.svc.EnrichSources(r.Context(), claims.Sub, result.Files)
|
||||||
apiresponse.WriteJSON(w, http.StatusOK, result)
|
apiresponse.WriteJSON(w, http.StatusOK, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -353,6 +389,7 @@ func (h *Handler) ListRecent(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeDriveError(w, r, err)
|
writeDriveError(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
h.svc.EnrichSources(r.Context(), claims.Sub, result.Files)
|
||||||
apiresponse.WriteJSON(w, http.StatusOK, result)
|
apiresponse.WriteJSON(w, http.StatusOK, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -374,6 +411,7 @@ func (h *Handler) ListStarred(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeDriveError(w, r, err)
|
writeDriveError(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
h.svc.EnrichSources(r.Context(), claims.Sub, result.Files)
|
||||||
apiresponse.WriteJSON(w, http.StatusOK, result)
|
apiresponse.WriteJSON(w, http.StatusOK, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -394,6 +432,7 @@ func (h *Handler) ListSharedWithMe(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeDriveError(w, r, err)
|
writeDriveError(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
h.svc.EnrichSources(r.Context(), claims.Sub, result.Files)
|
||||||
apiresponse.WriteJSON(w, http.StatusOK, result)
|
apiresponse.WriteJSON(w, http.StatusOK, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -465,6 +504,7 @@ func (h *Handler) Search(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeDriveError(w, r, err)
|
writeDriveError(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
h.svc.EnrichSources(r.Context(), claims.Sub, result.Files)
|
||||||
apiresponse.WriteJSON(w, http.StatusOK, result)
|
apiresponse.WriteJSON(w, http.StatusOK, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -12,6 +12,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
"github.com/ultisuite/ulti-backend/internal/api/paginate"
|
"github.com/ultisuite/ulti-backend/internal/api/paginate"
|
||||||
"github.com/ultisuite/ulti-backend/internal/api/query"
|
"github.com/ultisuite/ulti-backend/internal/api/query"
|
||||||
"github.com/ultisuite/ulti-backend/internal/auth"
|
"github.com/ultisuite/ulti-backend/internal/auth"
|
||||||
@ -30,14 +32,16 @@ var (
|
|||||||
type Service struct {
|
type Service struct {
|
||||||
nc *nextcloud.Client
|
nc *nextcloud.Client
|
||||||
hub *realtime.Hub
|
hub *realtime.Hub
|
||||||
|
db *pgxpool.Pool
|
||||||
maxUploadBytes int64
|
maxUploadBytes int64
|
||||||
quotaReserveByte int64
|
quotaReserveByte int64
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewService(nc *nextcloud.Client, hub *realtime.Hub) *Service {
|
func NewService(nc *nextcloud.Client, hub *realtime.Hub, db *pgxpool.Pool) *Service {
|
||||||
return &Service{
|
return &Service{
|
||||||
nc: nc,
|
nc: nc,
|
||||||
hub: hub,
|
hub: hub,
|
||||||
|
db: db,
|
||||||
maxUploadBytes: envInt64("ULTID_DRIVE_MAX_UPLOAD_BYTES", 0),
|
maxUploadBytes: envInt64("ULTID_DRIVE_MAX_UPLOAD_BYTES", 0),
|
||||||
quotaReserveByte: envInt64("ULTID_DRIVE_QUOTA_RESERVED_BYTES", 0),
|
quotaReserveByte: envInt64("ULTID_DRIVE_QUOTA_RESERVED_BYTES", 0),
|
||||||
}
|
}
|
||||||
@ -57,6 +61,11 @@ func (s *Service) notifyFileChanged(platformUserID, path string) {
|
|||||||
s.hub.Broadcast(platformUserID, realtime.NewDriveFileChangedEvent(path))
|
s.hub.Broadcast(platformUserID, realtime.NewDriveFileChangedEvent(path))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PublishFileChanged notifies connected clients that a drive path changed.
|
||||||
|
func (s *Service) PublishFileChanged(platformUserID, filePath string) {
|
||||||
|
s.notifyFileChanged(platformUserID, nextcloud.NormalizeClientPath(filePath))
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) notifyShareUpdated(platformUserID, path string) {
|
func (s *Service) notifyShareUpdated(platformUserID, path string) {
|
||||||
if s.hub == nil || platformUserID == "" {
|
if s.hub == nil || platformUserID == "" {
|
||||||
return
|
return
|
||||||
@ -69,6 +78,21 @@ type FilesList struct {
|
|||||||
Pagination query.PaginationMeta `json:"pagination,omitempty"`
|
Pagination query.PaginationMeta `json:"pagination,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) ListFilterCorpus(ctx context.Context, userID, path string) (FilesList, error) {
|
||||||
|
if path == "" {
|
||||||
|
path = "/"
|
||||||
|
}
|
||||||
|
files, err := s.nc.ListFilesRecursive(ctx, userID, path, 0)
|
||||||
|
if err != nil {
|
||||||
|
return FilesList{}, mapDriveError(err)
|
||||||
|
}
|
||||||
|
total := int64(len(files))
|
||||||
|
return FilesList{
|
||||||
|
Files: files,
|
||||||
|
Pagination: query.ListParams{Page: 1, PageSize: len(files)}.Meta(&total),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) ListFiles(ctx context.Context, userID, path string, params query.ListParams) (FilesList, error) {
|
func (s *Service) ListFiles(ctx context.Context, userID, path string, params query.ListParams) (FilesList, error) {
|
||||||
if path == "" {
|
if path == "" {
|
||||||
path = "/"
|
path = "/"
|
||||||
@ -517,16 +541,24 @@ func (s *Service) ensureQuota(ctx context.Context, userID string, incomingBytes
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return mapDriveError(err)
|
return mapDriveError(err)
|
||||||
}
|
}
|
||||||
|
if !quotaAllowsUpload(quota.Free, incomingBytes, s.quotaReserveByte) {
|
||||||
|
return ErrQuotaExceeded
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// quotaAllowsUpload mirrors Nextcloud quota semantics: negative free means unknown or unlimited.
|
||||||
|
func quotaAllowsUpload(free, incomingBytes, reserve int64) bool {
|
||||||
if incomingBytes <= 0 {
|
if incomingBytes <= 0 {
|
||||||
return nil
|
return true
|
||||||
}
|
}
|
||||||
if quota.Free <= 0 {
|
if free < 0 {
|
||||||
return ErrQuotaExceeded
|
return true
|
||||||
}
|
}
|
||||||
if incomingBytes+s.quotaReserveByte > quota.Free {
|
if free == 0 {
|
||||||
return ErrQuotaExceeded
|
return false
|
||||||
}
|
}
|
||||||
return nil
|
return incomingBytes+reserve <= free
|
||||||
}
|
}
|
||||||
|
|
||||||
func mapDriveError(err error) error {
|
func mapDriveError(err error) error {
|
||||||
|
|||||||
24
internal/api/drive/service_quota_test.go
Normal file
24
internal/api/drive/service_quota_test.go
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
package drive
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestQuotaAllowsUploadUnlimited(t *testing.T) {
|
||||||
|
if !quotaAllowsUpload(-3, 1<<30, 0) {
|
||||||
|
t.Fatal("negative free (unlimited) should allow upload")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQuotaAllowsUploadZeroFree(t *testing.T) {
|
||||||
|
if quotaAllowsUpload(0, 1, 0) {
|
||||||
|
t.Fatal("zero free should block upload")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQuotaAllowsUploadInsufficient(t *testing.T) {
|
||||||
|
if quotaAllowsUpload(100, 95, 10) {
|
||||||
|
t.Fatal("upload exceeding free+reserve should be blocked")
|
||||||
|
}
|
||||||
|
if !quotaAllowsUpload(100, 90, 10) {
|
||||||
|
t.Fatal("upload within free+reserve should be allowed")
|
||||||
|
}
|
||||||
|
}
|
||||||
60
internal/api/drive/sources.go
Normal file
60
internal/api/drive/sources.go
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
package drive
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/ultisuite/ulti-backend/internal/nextcloud"
|
||||||
|
)
|
||||||
|
|
||||||
|
func normalizeDriveSourcePath(filePath string) string {
|
||||||
|
return nextcloud.NormalizeClientPath(filePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnrichSources attaches suite source metadata (ultimail, ultimeet, …) from drive_file_sources.
|
||||||
|
func (s *Service) EnrichSources(ctx context.Context, externalUserID string, files []nextcloud.FileInfo) {
|
||||||
|
if s.db == nil || externalUserID == "" || len(files) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var userID string
|
||||||
|
if err := s.db.QueryRow(ctx, `SELECT id FROM users WHERE external_id = $1`, externalUserID).Scan(&userID); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
paths := make([]string, 0, len(files))
|
||||||
|
indexes := make([]int, 0, len(files))
|
||||||
|
for i, f := range files {
|
||||||
|
if f.Type != "file" || f.Path == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
paths = append(paths, normalizeDriveSourcePath(f.Path))
|
||||||
|
indexes = append(indexes, i)
|
||||||
|
}
|
||||||
|
if len(paths) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := s.db.Query(ctx, `
|
||||||
|
SELECT file_path, source FROM drive_file_sources
|
||||||
|
WHERE user_id = $1 AND file_path = ANY($2)
|
||||||
|
`, userID, paths)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
byPath := make(map[string]string, len(paths))
|
||||||
|
for rows.Next() {
|
||||||
|
var filePath, source string
|
||||||
|
if err := rows.Scan(&filePath, &source); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
byPath[filePath] = source
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, filePath := range paths {
|
||||||
|
if source, ok := byPath[filePath]; ok {
|
||||||
|
files[indexes[i]].Source = source
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
26
internal/api/mail/account_scope_sql.go
Normal file
26
internal/api/mail/account_scope_sql.go
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
package mail
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
func appendMessageAccountScope(
|
||||||
|
baseQuery string,
|
||||||
|
args []any,
|
||||||
|
argIdx int,
|
||||||
|
accountID string,
|
||||||
|
scopedAccountIDs []string,
|
||||||
|
) (string, []any, int) {
|
||||||
|
if accountID != "" {
|
||||||
|
baseQuery += fmt.Sprintf(" AND m.account_id = $%d", argIdx)
|
||||||
|
args = append(args, accountID)
|
||||||
|
return baseQuery, args, argIdx + 1
|
||||||
|
}
|
||||||
|
if scopedAccountIDs == nil {
|
||||||
|
return baseQuery, args, argIdx
|
||||||
|
}
|
||||||
|
if len(scopedAccountIDs) == 0 {
|
||||||
|
return baseQuery + " AND FALSE", args, argIdx
|
||||||
|
}
|
||||||
|
baseQuery += fmt.Sprintf(" AND m.account_id = ANY($%d)", argIdx)
|
||||||
|
args = append(args, scopedAccountIDs)
|
||||||
|
return baseQuery, args, argIdx + 1
|
||||||
|
}
|
||||||
19
internal/api/mail/account_scope_sql_test.go
Normal file
19
internal/api/mail/account_scope_sql_test.go
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
package mail
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestAppendMessageAccountScopeRestricted(t *testing.T) {
|
||||||
|
base, args, idx := appendMessageAccountScope(
|
||||||
|
" FROM messages m WHERE 1=1",
|
||||||
|
[]any{"user"},
|
||||||
|
2,
|
||||||
|
"",
|
||||||
|
[]string{"acc-1", "acc-2"},
|
||||||
|
)
|
||||||
|
if idx != 3 || len(args) != 2 {
|
||||||
|
t.Fatalf("idx=%d len(args)=%d", idx, len(args))
|
||||||
|
}
|
||||||
|
if base == "" || args[1] == nil {
|
||||||
|
t.Fatal("expected scoped clause")
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -39,7 +39,7 @@ func (s *Service) ListMessageAttachments(ctx context.Context, externalID, messag
|
|||||||
}
|
}
|
||||||
|
|
||||||
rows, err := s.db.Query(ctx, `
|
rows, err := s.db.Query(ctx, `
|
||||||
SELECT id, filename, content_type, size, content_id, is_inline
|
SELECT id, filename, content_type, size, content_id, is_inline, COALESCE(drive_path, '')
|
||||||
FROM attachments WHERE message_id = $1
|
FROM attachments WHERE message_id = $1
|
||||||
ORDER BY created_at ASC
|
ORDER BY created_at ASC
|
||||||
`, messageID)
|
`, messageID)
|
||||||
@ -50,10 +50,10 @@ func (s *Service) ListMessageAttachments(ctx context.Context, externalID, messag
|
|||||||
|
|
||||||
out := make([]map[string]any, 0)
|
out := make([]map[string]any, 0)
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var id, filename, contentType, contentID string
|
var id, filename, contentType, contentID, drivePath string
|
||||||
var size int64
|
var size int64
|
||||||
var isInline bool
|
var isInline bool
|
||||||
if err := rows.Scan(&id, &filename, &contentType, &size, &contentID, &isInline); err != nil {
|
if err := rows.Scan(&id, &filename, &contentType, &size, &contentID, &isInline, &drivePath); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
entry := map[string]any{
|
entry := map[string]any{
|
||||||
@ -63,6 +63,9 @@ func (s *Service) ListMessageAttachments(ctx context.Context, externalID, messag
|
|||||||
if contentID != "" {
|
if contentID != "" {
|
||||||
entry["content_id"] = contentID
|
entry["content_id"] = contentID
|
||||||
}
|
}
|
||||||
|
if drivePath != "" {
|
||||||
|
entry["drive_path"] = drivePath
|
||||||
|
}
|
||||||
out = append(out, entry)
|
out = append(out, entry)
|
||||||
}
|
}
|
||||||
return out, rows.Err()
|
return out, rows.Err()
|
||||||
|
|||||||
133
internal/api/mail/drive_save.go
Normal file
133
internal/api/mail/drive_save.go
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
package mail
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
|
||||||
|
"github.com/ultisuite/ulti-backend/internal/nextcloud"
|
||||||
|
)
|
||||||
|
|
||||||
|
const driveSourceUltimail = "ultimail"
|
||||||
|
|
||||||
|
func (s *Service) SetDriveUploader(uploader DriveUploader) {
|
||||||
|
s.driveUploader = uploader
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) SaveAttachmentToDrive(
|
||||||
|
ctx context.Context,
|
||||||
|
externalID, email, sub, displayName, messageID, attachmentID, folderPath string,
|
||||||
|
) (string, error) {
|
||||||
|
if s.driveUploader == nil {
|
||||||
|
return "", ErrDriveUnavailable
|
||||||
|
}
|
||||||
|
if s.storage == nil {
|
||||||
|
return "", errors.New("object storage unavailable")
|
||||||
|
}
|
||||||
|
|
||||||
|
folderPath = normalizeDriveFolder(folderPath)
|
||||||
|
|
||||||
|
userID, err := s.ensureMessageOwned(ctx, externalID, messageID)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
filename string
|
||||||
|
contentType string
|
||||||
|
size int64
|
||||||
|
s3Key string
|
||||||
|
existing string
|
||||||
|
)
|
||||||
|
err = s.db.QueryRow(ctx, `
|
||||||
|
SELECT a.filename, a.content_type, a.size, a.s3_key, COALESCE(a.drive_path, '')
|
||||||
|
FROM attachments a
|
||||||
|
WHERE a.id = $1 AND a.message_id = $2
|
||||||
|
`, attachmentID, messageID).Scan(&filename, &contentType, &size, &s3Key, &existing)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return "", ErrAttachmentNotFound
|
||||||
|
}
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if existing != "" {
|
||||||
|
return existing, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if folderPath != "/" {
|
||||||
|
if err := s.driveUploader.EnsureNextcloudFolder(ctx, email, sub, displayName, folderPath); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
destPath, err := uniqueDriveFilePath(ctx, s.driveUploader, email, sub, displayName, folderPath, filename)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
obj, err := s.storage.Get(ctx, s3Key)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer obj.Close()
|
||||||
|
|
||||||
|
destPath = nextcloud.NormalizeClientPath(destPath)
|
||||||
|
if err := s.driveUploader.UploadFile(ctx, email, sub, displayName, destPath, obj, contentType, size); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
s.driveUploader.NotifyFileChanged(externalID, destPath)
|
||||||
|
|
||||||
|
if err := s.recordDriveFileSource(ctx, userID, destPath, driveSourceUltimail); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = s.db.Exec(ctx, `
|
||||||
|
UPDATE attachments SET drive_path = $1 WHERE id = $2 AND message_id = $3
|
||||||
|
`, destPath, attachmentID, messageID)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return destPath, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) SaveMessageAttachmentsToDrive(
|
||||||
|
ctx context.Context,
|
||||||
|
externalID, email, sub, displayName, messageID, folderPath string,
|
||||||
|
) ([]map[string]any, error) {
|
||||||
|
list, err := s.ListMessageAttachments(ctx, externalID, messageID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]map[string]any, 0, len(list))
|
||||||
|
for _, att := range list {
|
||||||
|
if att["is_inline"] == true {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
id, _ := att["id"].(string)
|
||||||
|
if id == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
drivePath, err := s.SaveAttachmentToDrive(ctx, externalID, email, sub, displayName, messageID, id, folderPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
entry := make(map[string]any, len(att)+1)
|
||||||
|
for k, v := range att {
|
||||||
|
entry[k] = v
|
||||||
|
}
|
||||||
|
entry["drive_path"] = drivePath
|
||||||
|
out = append(out, entry)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) recordDriveFileSource(ctx context.Context, userID, filePath, source string) error {
|
||||||
|
filePath = nextcloud.NormalizeClientPath(filePath)
|
||||||
|
_, err := s.db.Exec(ctx, `
|
||||||
|
INSERT INTO drive_file_sources (user_id, file_path, source)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
ON CONFLICT (user_id, file_path) DO UPDATE SET source = EXCLUDED.source
|
||||||
|
`, userID, filePath, source)
|
||||||
|
return err
|
||||||
|
}
|
||||||
65
internal/api/mail/drive_upload.go
Normal file
65
internal/api/mail/drive_upload.go
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
package mail
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"path"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DriveUploader copies mail attachment bytes into a user's Nextcloud drive.
|
||||||
|
type DriveUploader interface {
|
||||||
|
EnsureNextcloudFolder(ctx context.Context, email, sub, displayName, folderPath string) error
|
||||||
|
UploadFile(ctx context.Context, email, sub, displayName, destPath string, body io.Reader, contentType string, size int64) error
|
||||||
|
FileExists(ctx context.Context, email, sub, displayName, filePath string) (bool, error)
|
||||||
|
NotifyFileChanged(platformUserID, filePath string)
|
||||||
|
}
|
||||||
|
|
||||||
|
var ErrDriveUnavailable = errors.New("drive unavailable")
|
||||||
|
|
||||||
|
func normalizeDriveFolder(folderPath string) string {
|
||||||
|
p := strings.TrimSpace(folderPath)
|
||||||
|
if p == "" {
|
||||||
|
return "/"
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(p, "/") {
|
||||||
|
p = "/" + p
|
||||||
|
}
|
||||||
|
p = path.Clean(p)
|
||||||
|
if p == "." {
|
||||||
|
return "/"
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func uniqueDriveFilePath(ctx context.Context, uploader DriveUploader, email, sub, displayName, folderPath, filename string) (string, error) {
|
||||||
|
base := strings.TrimSpace(filename)
|
||||||
|
if base == "" {
|
||||||
|
base = "piece-jointe"
|
||||||
|
}
|
||||||
|
ext := path.Ext(base)
|
||||||
|
stem := strings.TrimSuffix(base, ext)
|
||||||
|
if stem == "" {
|
||||||
|
stem = base
|
||||||
|
ext = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
candidate := path.Join(folderPath, base)
|
||||||
|
for i := 0; i < 100; i++ {
|
||||||
|
exists, err := uploader.FileExists(ctx, email, sub, displayName, candidate)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
return candidate, nil
|
||||||
|
}
|
||||||
|
if i == 0 {
|
||||||
|
candidate = path.Join(folderPath, stem+" (1)"+ext)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
candidate = path.Join(folderPath, stem+" ("+strconv.Itoa(i+1)+")"+ext)
|
||||||
|
}
|
||||||
|
return "", errors.New("could not allocate unique drive path")
|
||||||
|
}
|
||||||
80
internal/api/mail/drivebridge/bridge.go
Normal file
80
internal/api/mail/drivebridge/bridge.go
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
package drivebridge
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"path"
|
||||||
|
|
||||||
|
"github.com/ultisuite/ulti-backend/internal/api/drive"
|
||||||
|
"github.com/ultisuite/ulti-backend/internal/api/query"
|
||||||
|
"github.com/ultisuite/ulti-backend/internal/auth"
|
||||||
|
"github.com/ultisuite/ulti-backend/internal/nextcloud"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Bridge adapts drive.Service for mail attachment exports.
|
||||||
|
type Bridge struct {
|
||||||
|
Svc *drive.Service
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bridge) claims(email, sub, displayName string) *auth.Claims {
|
||||||
|
return &auth.Claims{Email: email, Sub: sub, Name: displayName}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bridge) ncUser(ctx context.Context, email, sub, displayName string) (string, error) {
|
||||||
|
if b.Svc == nil {
|
||||||
|
return "", errors.New("drive unavailable")
|
||||||
|
}
|
||||||
|
return b.Svc.EnsureNextcloudUser(ctx, b.claims(email, sub, displayName))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bridge) EnsureNextcloudFolder(ctx context.Context, email, sub, displayName, folderPath string) error {
|
||||||
|
userID, err := b.ncUser(ctx, email, sub, displayName)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
err = b.Svc.CreateFolder(ctx, userID, folderPath)
|
||||||
|
if err == nil || errors.Is(err, drive.ErrConflict) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var statusErr *nextcloud.HTTPStatusError
|
||||||
|
if errors.As(err, &statusErr) && statusErr.StatusCode == http.StatusMethodNotAllowed {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bridge) UploadFile(ctx context.Context, email, sub, displayName, destPath string, body io.Reader, contentType string, size int64) error {
|
||||||
|
userID, err := b.ncUser(ctx, email, sub, displayName)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return b.Svc.Upload(ctx, userID, destPath, body, contentType, size)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bridge) NotifyFileChanged(platformUserID, filePath string) {
|
||||||
|
if b.Svc == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b.Svc.PublishFileChanged(platformUserID, filePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bridge) FileExists(ctx context.Context, email, sub, displayName, filePath string) (bool, error) {
|
||||||
|
userID, err := b.ncUser(ctx, email, sub, displayName)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
parent := path.Dir(filePath)
|
||||||
|
name := path.Base(filePath)
|
||||||
|
files, err := b.Svc.ListFiles(ctx, userID, parent, query.ListParams{Page: 1, PageSize: 10_000})
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
for _, f := range files.Files {
|
||||||
|
if f.Name == name {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
@ -35,6 +35,13 @@ func (h *Handler) SetAccountSync(trigger AccountSyncTrigger) {
|
|||||||
h.accountSync = trigger
|
h.accountSync = trigger
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetDriveUploader wires Nextcloud export for mail attachments.
|
||||||
|
func (h *Handler) SetDriveUploader(uploader DriveUploader) {
|
||||||
|
if s, ok := h.svc.(*Service); ok {
|
||||||
|
s.SetDriveUploader(uploader)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func NewHandlerWithService(svc ServiceAPI) *Handler {
|
func NewHandlerWithService(svc ServiceAPI) *Handler {
|
||||||
return &Handler{
|
return &Handler{
|
||||||
svc: svc,
|
svc: svc,
|
||||||
@ -116,6 +123,8 @@ func (h *Handler) Routes() chi.Router {
|
|||||||
r.Get("/messages/{messageID}/attachments/cid-map", h.MessageAttachmentCIDMap)
|
r.Get("/messages/{messageID}/attachments/cid-map", h.MessageAttachmentCIDMap)
|
||||||
r.Post("/messages/{messageID}/attachments/reindex", h.ReindexMessageAttachments)
|
r.Post("/messages/{messageID}/attachments/reindex", h.ReindexMessageAttachments)
|
||||||
r.Post("/messages/{messageID}/attachments", h.UploadMessageAttachment)
|
r.Post("/messages/{messageID}/attachments", h.UploadMessageAttachment)
|
||||||
|
r.Post("/messages/{messageID}/attachments/save-to-drive", h.SaveMessageAttachmentsToDrive)
|
||||||
|
r.Post("/messages/{messageID}/attachments/{attachmentID}/save-to-drive", h.SaveAttachmentToDrive)
|
||||||
r.Post("/messages/{messageID}/list-unsubscribe-mailto", h.SendListUnsubscribeMailto)
|
r.Post("/messages/{messageID}/list-unsubscribe-mailto", h.SendListUnsubscribeMailto)
|
||||||
r.Get("/messages/{messageID}", h.GetMessage)
|
r.Get("/messages/{messageID}", h.GetMessage)
|
||||||
r.Put("/messages/{messageID}/labels", h.UpdateLabels)
|
r.Put("/messages/{messageID}/labels", h.UpdateLabels)
|
||||||
@ -145,6 +154,10 @@ func (h *Handler) Routes() chi.Router {
|
|||||||
r.Put("/webhooks/{webhookID}", h.UpdateWebhook)
|
r.Put("/webhooks/{webhookID}", h.UpdateWebhook)
|
||||||
r.Delete("/webhooks/{webhookID}", h.DeleteWebhook)
|
r.Delete("/webhooks/{webhookID}", h.DeleteWebhook)
|
||||||
|
|
||||||
|
r.Get("/api-tokens", h.ListApiTokens)
|
||||||
|
r.Post("/api-tokens", h.CreateApiToken)
|
||||||
|
r.Delete("/api-tokens/{tokenID}", h.RevokeApiToken)
|
||||||
|
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -290,6 +303,7 @@ func (h *Handler) ListMessages(w http.ResponseWriter, r *http.Request) {
|
|||||||
Folder: r.URL.Query().Get("folder"),
|
Folder: r.URL.Query().Get("folder"),
|
||||||
AccountID: r.URL.Query().Get("account_id"),
|
AccountID: r.URL.Query().Get("account_id"),
|
||||||
}
|
}
|
||||||
|
h.applyMailListScope(&filter, r)
|
||||||
|
|
||||||
result, err := h.svc.ListMessages(r.Context(), claims.Sub, filter, params)
|
result, err := h.svc.ListMessages(r.Context(), claims.Sub, filter, params)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -302,7 +316,11 @@ func (h *Handler) ListMessages(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
func (h *Handler) GetMessage(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) GetMessage(w http.ResponseWriter, r *http.Request) {
|
||||||
claims := middleware.ClaimsFromContext(r.Context())
|
claims := middleware.ClaimsFromContext(r.Context())
|
||||||
msg, err := h.svc.GetMessage(r.Context(), claims.Sub, chi.URLParam(r, "messageID"))
|
messageID := chi.URLParam(r, "messageID")
|
||||||
|
if h.denyUnlessMessageInScope(w, r, messageID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
msg, err := h.svc.GetMessage(r.Context(), claims.Sub, messageID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, ErrNotFound) {
|
if errors.Is(err, ErrNotFound) {
|
||||||
apivalidate.WriteNotFound(w, r, "not found")
|
apivalidate.WriteNotFound(w, r, "not found")
|
||||||
@ -326,8 +344,12 @@ func (h *Handler) UpdateLabels(w http.ResponseWriter, r *http.Request) {
|
|||||||
apivalidate.WriteValidationError(w, r, verr)
|
apivalidate.WriteValidationError(w, r, verr)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
messageID := chi.URLParam(r, "messageID")
|
||||||
|
if h.denyUnlessMessageInScope(w, r, messageID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if err := h.svc.UpdateLabels(r.Context(), claims.Sub, chi.URLParam(r, "messageID"), req.Labels); err != nil {
|
if err := h.svc.UpdateLabels(r.Context(), claims.Sub, messageID, req.Labels); err != nil {
|
||||||
if errors.Is(err, ErrNotFound) {
|
if errors.Is(err, ErrNotFound) {
|
||||||
apivalidate.WriteNotFound(w, r, "not found")
|
apivalidate.WriteNotFound(w, r, "not found")
|
||||||
return
|
return
|
||||||
@ -350,8 +372,12 @@ func (h *Handler) UpdateFlags(w http.ResponseWriter, r *http.Request) {
|
|||||||
apivalidate.WriteValidationError(w, r, verr)
|
apivalidate.WriteValidationError(w, r, verr)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
messageID := chi.URLParam(r, "messageID")
|
||||||
|
if h.denyUnlessMessageInScope(w, r, messageID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if err := h.svc.UpdateFlags(r.Context(), claims.Sub, chi.URLParam(r, "messageID"), req.Flags); err != nil {
|
if err := h.svc.UpdateFlags(r.Context(), claims.Sub, messageID, req.Flags); err != nil {
|
||||||
if errors.Is(err, ErrNotFound) {
|
if errors.Is(err, ErrNotFound) {
|
||||||
apivalidate.WriteNotFound(w, r, "not found")
|
apivalidate.WriteNotFound(w, r, "not found")
|
||||||
return
|
return
|
||||||
@ -365,8 +391,12 @@ func (h *Handler) UpdateFlags(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
func (h *Handler) DeleteMessage(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) DeleteMessage(w http.ResponseWriter, r *http.Request) {
|
||||||
claims := middleware.ClaimsFromContext(r.Context())
|
claims := middleware.ClaimsFromContext(r.Context())
|
||||||
|
messageID := chi.URLParam(r, "messageID")
|
||||||
|
if h.denyUnlessMessageInScope(w, r, messageID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if err := h.svc.DeleteMessage(r.Context(), claims.Sub, chi.URLParam(r, "messageID")); err != nil {
|
if err := h.svc.DeleteMessage(r.Context(), claims.Sub, messageID); err != nil {
|
||||||
if errors.Is(err, ErrNotFound) {
|
if errors.Is(err, ErrNotFound) {
|
||||||
apivalidate.WriteNotFound(w, r, "not found")
|
apivalidate.WriteNotFound(w, r, "not found")
|
||||||
return
|
return
|
||||||
@ -381,6 +411,9 @@ func (h *Handler) DeleteMessage(w http.ResponseWriter, r *http.Request) {
|
|||||||
func (h *Handler) SendListUnsubscribeMailto(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) SendListUnsubscribeMailto(w http.ResponseWriter, r *http.Request) {
|
||||||
claims := middleware.ClaimsFromContext(r.Context())
|
claims := middleware.ClaimsFromContext(r.Context())
|
||||||
messageID := chi.URLParam(r, "messageID")
|
messageID := chi.URLParam(r, "messageID")
|
||||||
|
if h.denyUnlessMessageInScope(w, r, messageID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if h.mailSender == nil {
|
if h.mailSender == nil {
|
||||||
apiresponse.WriteError(w, r, http.StatusServiceUnavailable, apiresponse.CodeInternal, "mail send unavailable", nil)
|
apiresponse.WriteError(w, r, http.StatusServiceUnavailable, apiresponse.CodeInternal, "mail send unavailable", nil)
|
||||||
@ -412,7 +445,11 @@ func (h *Handler) SendListUnsubscribeMailto(w http.ResponseWriter, r *http.Reque
|
|||||||
|
|
||||||
func (h *Handler) GetThread(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) GetThread(w http.ResponseWriter, r *http.Request) {
|
||||||
claims := middleware.ClaimsFromContext(r.Context())
|
claims := middleware.ClaimsFromContext(r.Context())
|
||||||
result, err := h.svc.GetThread(r.Context(), claims.Sub, chi.URLParam(r, "threadID"))
|
threadID := chi.URLParam(r, "threadID")
|
||||||
|
if h.denyUnlessThreadInScope(w, r, threadID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := h.svc.GetThread(r.Context(), claims.Sub, threadID, middleware.MailScopeAccountIDs(r.Context()))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.logger.Error("get thread", "error", err)
|
h.logger.Error("get thread", "error", err)
|
||||||
apivalidate.WriteInternal(w, r)
|
apivalidate.WriteInternal(w, r)
|
||||||
@ -453,6 +490,9 @@ func (h *Handler) SendMessage(w http.ResponseWriter, r *http.Request) {
|
|||||||
apivalidate.WriteValidationError(w, r, verr)
|
apivalidate.WriteValidationError(w, r, verr)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if middleware.DenyIfMailAccountOutOfScope(w, r, req.AccountID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
id, status, err := h.svc.SendMessage(r.Context(), userID, &req)
|
id, status, err := h.svc.SendMessage(r.Context(), userID, &req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
137
internal/api/mail/handlers_api_tokens.go
Normal file
137
internal/api/mail/handlers_api_tokens.go
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
package mail
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"github.com/ultisuite/ulti-backend/internal/api/apiresponse"
|
||||||
|
"github.com/ultisuite/ulti-backend/internal/api/apivalidate"
|
||||||
|
"github.com/ultisuite/ulti-backend/internal/api/middleware"
|
||||||
|
"github.com/ultisuite/ulti-backend/internal/apitokens"
|
||||||
|
)
|
||||||
|
|
||||||
|
type createApiTokenRequest struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Permissions []apitokens.PermissionGrant `json:"permissions"`
|
||||||
|
MailScope apitokens.MailScope `json:"mail_scope"`
|
||||||
|
DriveScope apitokens.DriveScope `json:"drive_scope"`
|
||||||
|
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) ListApiTokens(w http.ResponseWriter, r *http.Request) {
|
||||||
|
claims := middleware.ClaimsFromContext(r.Context())
|
||||||
|
db := h.db()
|
||||||
|
if db == nil {
|
||||||
|
apiresponse.WriteError(w, r, http.StatusInternalServerError, apiresponse.CodeInternal, "database unavailable", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tokens, err := apitokens.List(r.Context(), db, claims.Sub)
|
||||||
|
if err != nil {
|
||||||
|
apiresponse.WriteError(w, r, http.StatusInternalServerError, apiresponse.CodeInternal, "failed to list api tokens", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
apiresponse.WriteJSON(w, http.StatusOK, map[string]any{"tokens": tokens})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) CreateApiToken(w http.ResponseWriter, r *http.Request) {
|
||||||
|
claims := middleware.ClaimsFromContext(r.Context())
|
||||||
|
|
||||||
|
var req createApiTokenRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
apiresponse.WriteError(w, r, http.StatusBadRequest, apiresponse.CodeInvalidRequest, "invalid json", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
name := strings.TrimSpace(req.Name)
|
||||||
|
if name == "" {
|
||||||
|
apivalidate.WriteValidationError(w, r, apivalidate.NewValidationError(apivalidate.FieldDetail{
|
||||||
|
Field: "name", Message: "name is required",
|
||||||
|
}))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !hasAnyPermission(req.Permissions) {
|
||||||
|
apivalidate.WriteValidationError(w, r, apivalidate.NewValidationError(apivalidate.FieldDetail{
|
||||||
|
Field: "permissions", Message: "at least one permission is required",
|
||||||
|
}))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
db := h.db()
|
||||||
|
if db == nil {
|
||||||
|
apiresponse.WriteError(w, r, http.StatusInternalServerError, apiresponse.CodeInternal, "database unavailable", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
created, err := apitokens.Create(
|
||||||
|
r.Context(),
|
||||||
|
db,
|
||||||
|
claims.Sub,
|
||||||
|
name,
|
||||||
|
req.Permissions,
|
||||||
|
normalizeMailScope(req.MailScope),
|
||||||
|
normalizeDriveScope(req.DriveScope),
|
||||||
|
req.ExpiresAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
apiresponse.WriteError(w, r, http.StatusInternalServerError, apiresponse.CodeInternal, "failed to create api token", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
apiresponse.WriteJSON(w, http.StatusCreated, created)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) RevokeApiToken(w http.ResponseWriter, r *http.Request) {
|
||||||
|
claims := middleware.ClaimsFromContext(r.Context())
|
||||||
|
tokenID := chi.URLParam(r, "tokenID")
|
||||||
|
|
||||||
|
db := h.db()
|
||||||
|
if db == nil {
|
||||||
|
apiresponse.WriteError(w, r, http.StatusInternalServerError, apiresponse.CodeInternal, "database unavailable", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := apitokens.Revoke(r.Context(), db, claims.Sub, tokenID); err != nil {
|
||||||
|
if err == apitokens.ErrNotFound {
|
||||||
|
apiresponse.WriteError(w, r, http.StatusNotFound, apiresponse.CodeNotFound, "api token not found", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
apiresponse.WriteError(w, r, http.StatusInternalServerError, apiresponse.CodeInternal, "failed to revoke api token", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasAnyPermission(grants []apitokens.PermissionGrant) bool {
|
||||||
|
for _, g := range grants {
|
||||||
|
if g.Read || g.Write {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeMailScope(scope apitokens.MailScope) apitokens.MailScope {
|
||||||
|
if scope.AllAccounts || len(scope.AccountIDs) == 0 {
|
||||||
|
return apitokens.MailScope{AllAccounts: true, AccountIDs: nil}
|
||||||
|
}
|
||||||
|
return scope
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeDriveScope(scope apitokens.DriveScope) apitokens.DriveScope {
|
||||||
|
if scope.AllFolders || len(scope.FolderPaths) == 0 {
|
||||||
|
return apitokens.DriveScope{AllFolders: true, FolderPaths: nil}
|
||||||
|
}
|
||||||
|
return scope
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) db() *pgxpool.Pool {
|
||||||
|
if s, ok := h.svc.(*Service); ok {
|
||||||
|
return s.DB()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@ -13,13 +13,19 @@ import (
|
|||||||
|
|
||||||
"github.com/ultisuite/ulti-backend/internal/api/apiresponse"
|
"github.com/ultisuite/ulti-backend/internal/api/apiresponse"
|
||||||
"github.com/ultisuite/ulti-backend/internal/api/apivalidate"
|
"github.com/ultisuite/ulti-backend/internal/api/apivalidate"
|
||||||
|
driveapi "github.com/ultisuite/ulti-backend/internal/api/drive"
|
||||||
"github.com/ultisuite/ulti-backend/internal/api/middleware"
|
"github.com/ultisuite/ulti-backend/internal/api/middleware"
|
||||||
"github.com/ultisuite/ulti-backend/internal/mail/limits"
|
"github.com/ultisuite/ulti-backend/internal/mail/limits"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const maxJSONRequestBody = 32 << 10
|
||||||
|
|
||||||
func (h *Handler) ListMessageAttachments(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) ListMessageAttachments(w http.ResponseWriter, r *http.Request) {
|
||||||
claims := middleware.ClaimsFromContext(r.Context())
|
claims := middleware.ClaimsFromContext(r.Context())
|
||||||
messageID := chi.URLParam(r, "messageID")
|
messageID := chi.URLParam(r, "messageID")
|
||||||
|
if h.denyUnlessMessageInScope(w, r, messageID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
list, err := h.svc.ListMessageAttachments(r.Context(), claims.Sub, messageID)
|
list, err := h.svc.ListMessageAttachments(r.Context(), claims.Sub, messageID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -37,6 +43,9 @@ func (h *Handler) ListMessageAttachments(w http.ResponseWriter, r *http.Request)
|
|||||||
func (h *Handler) MessageAttachmentCIDMap(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) MessageAttachmentCIDMap(w http.ResponseWriter, r *http.Request) {
|
||||||
claims := middleware.ClaimsFromContext(r.Context())
|
claims := middleware.ClaimsFromContext(r.Context())
|
||||||
messageID := chi.URLParam(r, "messageID")
|
messageID := chi.URLParam(r, "messageID")
|
||||||
|
if h.denyUnlessMessageInScope(w, r, messageID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
mapping, err := h.svc.MessageAttachmentCIDMap(r.Context(), claims.Sub, messageID)
|
mapping, err := h.svc.MessageAttachmentCIDMap(r.Context(), claims.Sub, messageID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -54,6 +63,9 @@ func (h *Handler) MessageAttachmentCIDMap(w http.ResponseWriter, r *http.Request
|
|||||||
func (h *Handler) ReindexMessageAttachments(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) ReindexMessageAttachments(w http.ResponseWriter, r *http.Request) {
|
||||||
claims := middleware.ClaimsFromContext(r.Context())
|
claims := middleware.ClaimsFromContext(r.Context())
|
||||||
messageID := chi.URLParam(r, "messageID")
|
messageID := chi.URLParam(r, "messageID")
|
||||||
|
if h.denyUnlessMessageInScope(w, r, messageID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
if h.accountSync == nil {
|
if h.accountSync == nil {
|
||||||
apiresponse.WriteError(w, r, http.StatusServiceUnavailable, "sync_unavailable", "mail sync is not configured", nil)
|
apiresponse.WriteError(w, r, http.StatusServiceUnavailable, "sync_unavailable", "mail sync is not configured", nil)
|
||||||
return
|
return
|
||||||
@ -82,6 +94,9 @@ func (h *Handler) ReindexMessageAttachments(w http.ResponseWriter, r *http.Reque
|
|||||||
func (h *Handler) UploadMessageAttachment(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) UploadMessageAttachment(w http.ResponseWriter, r *http.Request) {
|
||||||
claims := middleware.ClaimsFromContext(r.Context())
|
claims := middleware.ClaimsFromContext(r.Context())
|
||||||
messageID := chi.URLParam(r, "messageID")
|
messageID := chi.URLParam(r, "messageID")
|
||||||
|
if h.denyUnlessMessageInScope(w, r, messageID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if err := r.ParseMultipartForm(limits.MaxMultipartUploadBytes); err != nil {
|
if err := r.ParseMultipartForm(limits.MaxMultipartUploadBytes); err != nil {
|
||||||
apiresponse.WriteError(w, r, http.StatusBadRequest, apiresponse.CodeInvalidRequest, "invalid multipart form", nil)
|
apiresponse.WriteError(w, r, http.StatusBadRequest, apiresponse.CodeInvalidRequest, "invalid multipart form", nil)
|
||||||
@ -129,6 +144,9 @@ func (h *Handler) DownloadAttachment(w http.ResponseWriter, r *http.Request) {
|
|||||||
claims := middleware.ClaimsFromContext(r.Context())
|
claims := middleware.ClaimsFromContext(r.Context())
|
||||||
inline := strings.HasSuffix(r.URL.Path, "/inline") || r.URL.Query().Get("inline") == "true"
|
inline := strings.HasSuffix(r.URL.Path, "/inline") || r.URL.Query().Get("inline") == "true"
|
||||||
attachmentID := chi.URLParam(r, "attachmentID")
|
attachmentID := chi.URLParam(r, "attachmentID")
|
||||||
|
if h.denyUnlessAttachmentInScope(w, r, attachmentID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
filename, contentType, size, isInline, body, err := h.svc.OpenAttachment(r.Context(), claims.Sub, attachmentID)
|
filename, contentType, size, isInline, body, err := h.svc.OpenAttachment(r.Context(), claims.Sub, attachmentID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -227,6 +245,95 @@ func (h *Handler) DownloadDraftAttachment(w http.ResponseWriter, r *http.Request
|
|||||||
_, _ = io.Copy(w, body)
|
_, _ = io.Copy(w, body)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type saveToDriveRequest struct {
|
||||||
|
FolderPath string `json:"folder_path"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) SaveAttachmentToDrive(w http.ResponseWriter, r *http.Request) {
|
||||||
|
claims := middleware.ClaimsFromContext(r.Context())
|
||||||
|
messageID := chi.URLParam(r, "messageID")
|
||||||
|
attachmentID := chi.URLParam(r, "attachmentID")
|
||||||
|
if h.denyUnlessMessageInScope(w, r, messageID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req saveToDriveRequest
|
||||||
|
if err := apivalidate.DecodeJSON(w, r, maxJSONRequestBody, &req); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
drivePath, err := h.svc.SaveAttachmentToDrive(
|
||||||
|
r.Context(),
|
||||||
|
claims.Sub,
|
||||||
|
claims.Email,
|
||||||
|
claims.Sub,
|
||||||
|
claims.Name,
|
||||||
|
messageID,
|
||||||
|
attachmentID,
|
||||||
|
req.FolderPath,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
if writeSaveToDriveError(w, r, h, err) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.logger.Error("save attachment to drive", "error", err)
|
||||||
|
apivalidate.WriteInternal(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
apiresponse.WriteJSON(w, http.StatusOK, map[string]string{"drive_path": drivePath})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) SaveMessageAttachmentsToDrive(w http.ResponseWriter, r *http.Request) {
|
||||||
|
claims := middleware.ClaimsFromContext(r.Context())
|
||||||
|
messageID := chi.URLParam(r, "messageID")
|
||||||
|
if h.denyUnlessMessageInScope(w, r, messageID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req saveToDriveRequest
|
||||||
|
if err := apivalidate.DecodeJSON(w, r, maxJSONRequestBody, &req); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
list, err := h.svc.SaveMessageAttachmentsToDrive(
|
||||||
|
r.Context(),
|
||||||
|
claims.Sub,
|
||||||
|
claims.Email,
|
||||||
|
claims.Sub,
|
||||||
|
claims.Name,
|
||||||
|
messageID,
|
||||||
|
req.FolderPath,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
if writeSaveToDriveError(w, r, h, err) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.logger.Error("save message attachments to drive", "error", err)
|
||||||
|
apivalidate.WriteInternal(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
apiresponse.WriteJSON(w, http.StatusOK, map[string]any{"attachments": list})
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeSaveToDriveError(w http.ResponseWriter, r *http.Request, h *Handler, err error) bool {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, ErrNotFound), errors.Is(err, ErrAttachmentNotFound):
|
||||||
|
apivalidate.WriteNotFound(w, r, "not found")
|
||||||
|
return true
|
||||||
|
case errors.Is(err, ErrDriveUnavailable):
|
||||||
|
apiresponse.WriteError(w, r, http.StatusServiceUnavailable, "drive_unavailable", "drive is not available", nil)
|
||||||
|
return true
|
||||||
|
case errors.Is(err, driveapi.ErrQuotaExceeded):
|
||||||
|
apiresponse.WriteError(w, r, http.StatusInsufficientStorage, "drive.quota_exceeded", "drive quota exceeded", nil)
|
||||||
|
return true
|
||||||
|
case errors.Is(err, driveapi.ErrForbidden):
|
||||||
|
apiresponse.WriteError(w, r, http.StatusForbidden, apiresponse.CodeAuthForbidden, "drive access denied", nil)
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func writeAttachmentUploadError(w http.ResponseWriter, r *http.Request, err error) bool {
|
func writeAttachmentUploadError(w http.ResponseWriter, r *http.Request, err error) bool {
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, limits.ErrAttachmentTooLarge), errors.Is(err, limits.ErrAttachmentsTotalTooLarge):
|
case errors.Is(err, limits.ErrAttachmentTooLarge), errors.Is(err, limits.ErrAttachmentsTotalTooLarge):
|
||||||
|
|||||||
79
internal/api/mail/handlers_mail_scope.go
Normal file
79
internal/api/mail/handlers_mail_scope.go
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
package mail
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/ultisuite/ulti-backend/internal/api/apiresponse"
|
||||||
|
"github.com/ultisuite/ulti-backend/internal/api/apivalidate"
|
||||||
|
"github.com/ultisuite/ulti-backend/internal/api/middleware"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (h *Handler) applyMailListScope(filter *MessageListFilter, r *http.Request) {
|
||||||
|
filter.ScopedAccountIDs = middleware.MailScopeAccountIDs(r.Context())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) applyMailSearchScope(filter *MessageSearchFilter, r *http.Request) {
|
||||||
|
filter.ScopedAccountIDs = middleware.MailScopeAccountIDs(r.Context())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) denyUnlessMessageInScope(w http.ResponseWriter, r *http.Request, messageID string) bool {
|
||||||
|
if middleware.MailScopeAccountIDs(r.Context()) == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
claims := middleware.ClaimsFromContext(r.Context())
|
||||||
|
accountID, err := h.svc.MessageAccountID(r.Context(), claims.Sub, messageID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, ErrNotFound) {
|
||||||
|
apivalidate.WriteNotFound(w, r, "not found")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
h.logger.Error("resolve message account", "message_id", messageID, "error", err)
|
||||||
|
apivalidate.WriteInternal(w, r)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if middleware.DenyIfMailAccountOutOfScope(w, r, accountID) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) denyUnlessAttachmentInScope(w http.ResponseWriter, r *http.Request, attachmentID string) bool {
|
||||||
|
if middleware.MailScopeAccountIDs(r.Context()) == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
claims := middleware.ClaimsFromContext(r.Context())
|
||||||
|
accountID, err := h.svc.AttachmentAccountID(r.Context(), claims.Sub, attachmentID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, ErrAttachmentNotFound) || errors.Is(err, ErrNotFound) {
|
||||||
|
apivalidate.WriteNotFound(w, r, "not found")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
h.logger.Error("resolve attachment account", "attachment_id", attachmentID, "error", err)
|
||||||
|
apivalidate.WriteInternal(w, r)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if middleware.DenyIfMailAccountOutOfScope(w, r, accountID) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) denyUnlessThreadInScope(w http.ResponseWriter, r *http.Request, threadID string) bool {
|
||||||
|
scoped := middleware.MailScopeAccountIDs(r.Context())
|
||||||
|
if scoped == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
claims := middleware.ClaimsFromContext(r.Context())
|
||||||
|
ok, err := h.svc.ThreadAccessible(r.Context(), claims.Sub, threadID, scoped)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("resolve thread scope", "thread_id", threadID, "error", err)
|
||||||
|
apivalidate.WriteInternal(w, r)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
apiresponse.WriteError(w, r, http.StatusForbidden, apiresponse.CodeAuthForbidden, "mail account out of token scope", nil)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@ -25,6 +25,7 @@ func (h *Handler) SearchMessages(w http.ResponseWriter, r *http.Request) {
|
|||||||
apivalidate.WriteValidationError(w, r, verr)
|
apivalidate.WriteValidationError(w, r, verr)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
h.applyMailSearchScope(&filter, r)
|
||||||
|
|
||||||
result, err := h.svc.SearchMessages(r.Context(), claims.Sub, filter, params)
|
result, err := h.svc.SearchMessages(r.Context(), claims.Sub, filter, params)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@ -308,7 +308,22 @@ func (f *fakeMailService) DeleteAccount(context.Context, string, string) error {
|
|||||||
func (f *fakeMailService) ResanitizeAccountBodies(context.Context, string, string) (ResanitizeBodiesResult, error) {
|
func (f *fakeMailService) ResanitizeAccountBodies(context.Context, string, string) (ResanitizeBodiesResult, error) {
|
||||||
return ResanitizeBodiesResult{}, nil
|
return ResanitizeBodiesResult{}, nil
|
||||||
}
|
}
|
||||||
func (f *fakeMailService) GetThread(context.Context, string, string) (map[string]any, error) {
|
func (f *fakeMailService) MessageAccountID(_ context.Context, _, messageID string) (string, error) {
|
||||||
|
if f.deleted[messageID] {
|
||||||
|
return "", ErrNotFound
|
||||||
|
}
|
||||||
|
if _, ok := f.messages[messageID]; !ok {
|
||||||
|
return "", ErrNotFound
|
||||||
|
}
|
||||||
|
return testMailAccountID, nil
|
||||||
|
}
|
||||||
|
func (f *fakeMailService) AttachmentAccountID(context.Context, string, string) (string, error) {
|
||||||
|
return testMailAccountID, nil
|
||||||
|
}
|
||||||
|
func (f *fakeMailService) ThreadAccessible(context.Context, string, string, []string) (bool, error) {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
func (f *fakeMailService) GetThread(context.Context, string, string, []string) (map[string]any, error) {
|
||||||
return map[string]any{"messages": []any{}}, nil
|
return map[string]any{"messages": []any{}}, nil
|
||||||
}
|
}
|
||||||
func (f *fakeMailService) ListRules(context.Context, string, query.ListParams) (RulesList, error) {
|
func (f *fakeMailService) ListRules(context.Context, string, query.ListParams) (RulesList, error) {
|
||||||
@ -608,6 +623,14 @@ func (f *fakeMailService) OpenDraftAttachment(context.Context, string, string, s
|
|||||||
return "", "", nil, ErrAttachmentNotFound
|
return "", "", nil, ErrAttachmentNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (f *fakeMailService) SaveAttachmentToDrive(context.Context, string, string, string, string, string, string, string) (string, error) {
|
||||||
|
return "/Ultimail/test.pdf", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeMailService) SaveMessageAttachmentsToDrive(context.Context, string, string, string, string, string, string) ([]map[string]any, error) {
|
||||||
|
return []map[string]any{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func newTestMailRouter(svc ServiceAPI) http.Handler {
|
func newTestMailRouter(svc ServiceAPI) http.Handler {
|
||||||
return newTestMailRouterWithClaims(svc, &auth.Claims{
|
return newTestMailRouterWithClaims(svc, &auth.Claims{
|
||||||
Sub: testExternalID,
|
Sub: testExternalID,
|
||||||
|
|||||||
@ -19,6 +19,7 @@ type MessageSearchFilter struct {
|
|||||||
HasAttachments *bool
|
HasAttachments *bool
|
||||||
Label string
|
Label string
|
||||||
AccountID string
|
AccountID string
|
||||||
|
ScopedAccountIDs []string
|
||||||
}
|
}
|
||||||
|
|
||||||
type MessageSearchResult struct {
|
type MessageSearchResult struct {
|
||||||
@ -35,11 +36,7 @@ func (s *Service) SearchMessages(ctx context.Context, externalID string, filter
|
|||||||
args := []any{externalID}
|
args := []any{externalID}
|
||||||
argIdx := 2
|
argIdx := 2
|
||||||
|
|
||||||
if filter.AccountID != "" {
|
base, args, argIdx = appendMessageAccountScope(base, args, argIdx, filter.AccountID, filter.ScopedAccountIDs)
|
||||||
base += fmt.Sprintf(" AND m.account_id = $%d", argIdx)
|
|
||||||
args = append(args, filter.AccountID)
|
|
||||||
argIdx++
|
|
||||||
}
|
|
||||||
if filter.Sender != "" {
|
if filter.Sender != "" {
|
||||||
base += fmt.Sprintf(" AND m.from_addr::text ILIKE '%%' || $%d || '%%'", argIdx)
|
base += fmt.Sprintf(" AND m.from_addr::text ILIKE '%%' || $%d || '%%'", argIdx)
|
||||||
args = append(args, filter.Sender)
|
args = append(args, filter.Sender)
|
||||||
|
|||||||
@ -31,11 +31,12 @@ var (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
db *pgxpool.Pool
|
db *pgxpool.Pool // exported via DB() for api token handlers
|
||||||
credentials *credentials.Manager
|
credentials *credentials.Manager
|
||||||
audit *securityaudit.Logger
|
audit *securityaudit.Logger
|
||||||
storage *storage.Client
|
storage *storage.Client
|
||||||
attachmentsBucket string
|
attachmentsBucket string
|
||||||
|
driveUploader DriveUploader
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -50,6 +51,10 @@ func NewService(db *pgxpool.Pool, audit *securityaudit.Logger, credentialManager
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) DB() *pgxpool.Pool {
|
||||||
|
return s.db
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) ResolveUserID(ctx context.Context, externalID string) (string, error) {
|
func (s *Service) ResolveUserID(ctx context.Context, externalID string) (string, error) {
|
||||||
var userID string
|
var userID string
|
||||||
err := s.db.QueryRow(ctx, `SELECT id FROM users WHERE external_id = $1`, externalID).Scan(&userID)
|
err := s.db.QueryRow(ctx, `SELECT id FROM users WHERE external_id = $1`, externalID).Scan(&userID)
|
||||||
@ -168,6 +173,7 @@ func (s *Service) DeleteAccount(ctx context.Context, externalID, accountID strin
|
|||||||
type MessageListFilter struct {
|
type MessageListFilter struct {
|
||||||
Folder string
|
Folder string
|
||||||
AccountID string
|
AccountID string
|
||||||
|
ScopedAccountIDs []string
|
||||||
}
|
}
|
||||||
|
|
||||||
type MessagesList struct {
|
type MessagesList struct {
|
||||||
@ -185,11 +191,7 @@ func (s *Service) ListMessages(ctx context.Context, externalID string, filter Me
|
|||||||
args := []any{externalID}
|
args := []any{externalID}
|
||||||
argIdx := 2
|
argIdx := 2
|
||||||
|
|
||||||
if filter.AccountID != "" {
|
baseQuery, args, argIdx = appendMessageAccountScope(baseQuery, args, argIdx, filter.AccountID, filter.ScopedAccountIDs)
|
||||||
baseQuery += fmt.Sprintf(" AND m.account_id = $%d", argIdx)
|
|
||||||
args = append(args, filter.AccountID)
|
|
||||||
argIdx++
|
|
||||||
}
|
|
||||||
if clause, arg, ok := folderFilterClause(filter.Folder, argIdx); ok {
|
if clause, arg, ok := folderFilterClause(filter.Folder, argIdx); ok {
|
||||||
baseQuery += clause
|
baseQuery += clause
|
||||||
args = append(args, arg)
|
args = append(args, arg)
|
||||||
@ -256,9 +258,63 @@ func (s *Service) ListMessages(ctx context.Context, externalID string, filter Me
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) MessageAccountID(ctx context.Context, externalID, messageID string) (string, error) {
|
||||||
|
var accountID string
|
||||||
|
err := s.db.QueryRow(ctx, `
|
||||||
|
SELECT m.account_id::text
|
||||||
|
FROM messages m
|
||||||
|
JOIN mail_accounts ma ON m.account_id = ma.id
|
||||||
|
WHERE m.id = $1 AND ma.user_id = (SELECT id FROM users WHERE external_id = $2)
|
||||||
|
`, messageID, externalID).Scan(&accountID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return "", ErrNotFound
|
||||||
|
}
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return accountID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) AttachmentAccountID(ctx context.Context, externalID, attachmentID string) (string, error) {
|
||||||
|
var accountID string
|
||||||
|
err := s.db.QueryRow(ctx, `
|
||||||
|
SELECT m.account_id::text
|
||||||
|
FROM attachments a
|
||||||
|
JOIN messages m ON a.message_id = m.id
|
||||||
|
JOIN mail_accounts ma ON m.account_id = ma.id
|
||||||
|
WHERE a.id = $1 AND ma.user_id = (SELECT id FROM users WHERE external_id = $2)
|
||||||
|
`, attachmentID, externalID).Scan(&accountID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return "", ErrAttachmentNotFound
|
||||||
|
}
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return accountID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ThreadAccessible(ctx context.Context, externalID, threadID string, scopedAccountIDs []string) (bool, error) {
|
||||||
|
if len(scopedAccountIDs) == 0 {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
var ok bool
|
||||||
|
err := s.db.QueryRow(ctx, `
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM messages m
|
||||||
|
JOIN mail_accounts ma ON m.account_id = ma.id
|
||||||
|
WHERE m.thread_id = $1
|
||||||
|
AND ma.user_id = (SELECT id FROM users WHERE external_id = $2)
|
||||||
|
AND m.account_id = ANY($3)
|
||||||
|
)
|
||||||
|
`, threadID, externalID, scopedAccountIDs).Scan(&ok)
|
||||||
|
return ok, err
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) GetMessage(ctx context.Context, externalID, messageID string) (map[string]any, error) {
|
func (s *Service) GetMessage(ctx context.Context, externalID, messageID string) (map[string]any, error) {
|
||||||
var msg struct {
|
var msg struct {
|
||||||
ID string
|
ID string
|
||||||
|
AccountID string
|
||||||
MessageID string
|
MessageID string
|
||||||
ThreadID *string
|
ThreadID *string
|
||||||
InReplyTo string
|
InReplyTo string
|
||||||
@ -276,13 +332,13 @@ func (s *Service) GetMessage(ctx context.Context, externalID, messageID string)
|
|||||||
Labels []string
|
Labels []string
|
||||||
}
|
}
|
||||||
err := s.db.QueryRow(ctx, `
|
err := s.db.QueryRow(ctx, `
|
||||||
SELECT m.id, m.message_id, m.thread_id, m.in_reply_to, m.references_header,
|
SELECT m.id, m.account_id::text, m.message_id, m.thread_id, m.in_reply_to, m.references_header,
|
||||||
m.subject, m.from_addr, m.to_addrs, m.cc_addrs, m.reply_to, m.auth_info, m.date,
|
m.subject, m.from_addr, m.to_addrs, m.cc_addrs, m.reply_to, m.auth_info, m.date,
|
||||||
m.body_text, m.body_html, m.flags, m.labels
|
m.body_text, m.body_html, m.flags, m.labels
|
||||||
FROM messages m JOIN mail_accounts ma ON m.account_id = ma.id
|
FROM messages m JOIN mail_accounts ma ON m.account_id = ma.id
|
||||||
WHERE m.id = $1 AND ma.user_id = (SELECT id FROM users WHERE external_id = $2)
|
WHERE m.id = $1 AND ma.user_id = (SELECT id FROM users WHERE external_id = $2)
|
||||||
`, messageID, externalID).Scan(
|
`, messageID, externalID).Scan(
|
||||||
&msg.ID, &msg.MessageID, &msg.ThreadID, &msg.InReplyTo, &msg.References,
|
&msg.ID, &msg.AccountID, &msg.MessageID, &msg.ThreadID, &msg.InReplyTo, &msg.References,
|
||||||
&msg.Subject, &msg.From, &msg.To, &msg.Cc, &msg.ReplyTo, &msg.AuthInfo, &msg.Date,
|
&msg.Subject, &msg.From, &msg.To, &msg.Cc, &msg.ReplyTo, &msg.AuthInfo, &msg.Date,
|
||||||
&msg.Text, &msg.HTML, &msg.Flags, &msg.Labels,
|
&msg.Text, &msg.HTML, &msg.Flags, &msg.Labels,
|
||||||
)
|
)
|
||||||
@ -302,7 +358,7 @@ func (s *Service) GetMessage(ctx context.Context, externalID, messageID string)
|
|||||||
`, bodyText, bodyHTML, repairedSnippet, subject, msg.ID)
|
`, bodyText, bodyHTML, repairedSnippet, subject, msg.ID)
|
||||||
}
|
}
|
||||||
out := map[string]any{
|
out := map[string]any{
|
||||||
"id": msg.ID, "message_id": msg.MessageID, "subject": subject,
|
"id": msg.ID, "account_id": msg.AccountID, "message_id": msg.MessageID, "subject": subject,
|
||||||
"from": json.RawMessage(msg.From), "to": json.RawMessage(msg.To), "cc": json.RawMessage(msg.Cc),
|
"from": json.RawMessage(msg.From), "to": json.RawMessage(msg.To), "cc": json.RawMessage(msg.Cc),
|
||||||
"reply_to": json.RawMessage(msg.ReplyTo), "auth_info": json.RawMessage(msg.AuthInfo),
|
"reply_to": json.RawMessage(msg.ReplyTo), "auth_info": json.RawMessage(msg.AuthInfo),
|
||||||
"date": msg.Date, "body_text": bodyText, "body_html": sanitize.SanitizeHTML(bodyHTML),
|
"date": msg.Date, "body_text": bodyText, "body_html": sanitize.SanitizeHTML(bodyHTML),
|
||||||
@ -373,13 +429,22 @@ func (s *Service) DeleteMessage(ctx context.Context, externalID, messageID strin
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) GetThread(ctx context.Context, externalID, threadID string) (map[string]any, error) {
|
func (s *Service) GetThread(ctx context.Context, externalID, threadID string, scopedAccountIDs []string) (map[string]any, error) {
|
||||||
rows, err := s.db.Query(ctx, `
|
base := `
|
||||||
SELECT m.id, m.subject, m.from_addr, m.to_addrs, m.cc_addrs, m.date, m.snippet, m.flags, m.labels
|
SELECT m.id, m.subject, m.from_addr, m.to_addrs, m.cc_addrs, m.date, m.snippet, m.flags, m.labels, m.has_attachments
|
||||||
FROM messages m JOIN mail_accounts ma ON m.account_id = ma.id
|
FROM messages m JOIN mail_accounts ma ON m.account_id = ma.id
|
||||||
WHERE m.thread_id = $1 AND ma.user_id = (SELECT id FROM users WHERE external_id = $2)
|
WHERE m.thread_id = $1 AND ma.user_id = (SELECT id FROM users WHERE external_id = $2)
|
||||||
ORDER BY m.date ASC
|
`
|
||||||
`, threadID, externalID)
|
args := []any{threadID, externalID}
|
||||||
|
if scopedAccountIDs != nil {
|
||||||
|
if len(scopedAccountIDs) == 0 {
|
||||||
|
return map[string]any{"thread_id": threadID, "messages": []map[string]any{}}, nil
|
||||||
|
}
|
||||||
|
base += " AND m.account_id = ANY($3)"
|
||||||
|
args = append(args, scopedAccountIDs)
|
||||||
|
}
|
||||||
|
base += " ORDER BY m.date ASC"
|
||||||
|
rows, err := s.db.Query(ctx, base, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@ -391,13 +456,15 @@ func (s *Service) GetThread(ctx context.Context, externalID, threadID string) (m
|
|||||||
var from, toAddrs, ccAddrs []byte
|
var from, toAddrs, ccAddrs []byte
|
||||||
var date any
|
var date any
|
||||||
var flags, labels []string
|
var flags, labels []string
|
||||||
if err := rows.Scan(&id, &subject, &from, &toAddrs, &ccAddrs, &date, &snippet, &flags, &labels); err != nil {
|
var hasAttachments bool
|
||||||
|
if err := rows.Scan(&id, &subject, &from, &toAddrs, &ccAddrs, &date, &snippet, &flags, &labels, &hasAttachments); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
messages = append(messages, map[string]any{
|
messages = append(messages, map[string]any{
|
||||||
"id": id, "subject": subject, "from": json.RawMessage(from),
|
"id": id, "subject": subject, "from": json.RawMessage(from),
|
||||||
"to": json.RawMessage(toAddrs), "cc": json.RawMessage(ccAddrs),
|
"to": json.RawMessage(toAddrs), "cc": json.RawMessage(ccAddrs),
|
||||||
"date": date, "snippet": snippet, "flags": flags, "labels": labels,
|
"date": date, "snippet": snippet, "flags": flags, "labels": labels,
|
||||||
|
"has_attachments": hasAttachments,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if err := rows.Err(); err != nil {
|
if err := rows.Err(); err != nil {
|
||||||
|
|||||||
@ -28,12 +28,15 @@ type ServiceAPI interface {
|
|||||||
DeleteAccount(ctx context.Context, externalID, accountID string) error
|
DeleteAccount(ctx context.Context, externalID, accountID string) error
|
||||||
ResanitizeAccountBodies(ctx context.Context, externalID, accountID string) (ResanitizeBodiesResult, error)
|
ResanitizeAccountBodies(ctx context.Context, externalID, accountID string) (ResanitizeBodiesResult, error)
|
||||||
ListMessages(ctx context.Context, externalID string, filter MessageListFilter, params query.ListParams) (MessagesList, error)
|
ListMessages(ctx context.Context, externalID string, filter MessageListFilter, params query.ListParams) (MessagesList, error)
|
||||||
|
MessageAccountID(ctx context.Context, externalID, messageID string) (string, error)
|
||||||
|
AttachmentAccountID(ctx context.Context, externalID, attachmentID string) (string, error)
|
||||||
|
ThreadAccessible(ctx context.Context, externalID, threadID string, scopedAccountIDs []string) (bool, error)
|
||||||
GetMessage(ctx context.Context, externalID, messageID string) (map[string]any, error)
|
GetMessage(ctx context.Context, externalID, messageID string) (map[string]any, error)
|
||||||
SendMailtoListUnsubscribe(ctx context.Context, externalID, messageID string, sender MailSender) (*listunsubscribe.Mailto, error)
|
SendMailtoListUnsubscribe(ctx context.Context, externalID, messageID string, sender MailSender) (*listunsubscribe.Mailto, error)
|
||||||
UpdateLabels(ctx context.Context, externalID, messageID string, labels []string) error
|
UpdateLabels(ctx context.Context, externalID, messageID string, labels []string) error
|
||||||
UpdateFlags(ctx context.Context, externalID, messageID string, flags []string) error
|
UpdateFlags(ctx context.Context, externalID, messageID string, flags []string) error
|
||||||
DeleteMessage(ctx context.Context, externalID, messageID string) error
|
DeleteMessage(ctx context.Context, externalID, messageID string) error
|
||||||
GetThread(ctx context.Context, externalID, threadID string) (map[string]any, error)
|
GetThread(ctx context.Context, externalID, threadID string, scopedAccountIDs []string) (map[string]any, error)
|
||||||
SendMessage(ctx context.Context, userID string, req *sendMessageRequest) (id, status string, err error)
|
SendMessage(ctx context.Context, userID string, req *sendMessageRequest) (id, status string, err error)
|
||||||
SendOutboxNow(ctx context.Context, userID, outboxID string) (status string, err error)
|
SendOutboxNow(ctx context.Context, userID, outboxID string) (status string, err error)
|
||||||
RescheduleOutbox(ctx context.Context, userID, outboxID string, scheduledAt time.Time) (status string, err error)
|
RescheduleOutbox(ctx context.Context, userID, outboxID string, scheduledAt time.Time) (status string, err error)
|
||||||
@ -81,6 +84,8 @@ type ServiceAPI interface {
|
|||||||
OpenAttachment(ctx context.Context, externalID, attachmentID string) (filename, contentType string, size int64, isInline bool, body io.ReadCloser, err error)
|
OpenAttachment(ctx context.Context, externalID, attachmentID string) (filename, contentType string, size int64, isInline bool, body io.ReadCloser, err error)
|
||||||
UploadDraftAttachment(ctx context.Context, externalID, draftID, filename, contentType, contentID string, isInline bool, reader io.Reader, size int64) (string, error)
|
UploadDraftAttachment(ctx context.Context, externalID, draftID, filename, contentType, contentID string, isInline bool, reader io.Reader, size int64) (string, error)
|
||||||
OpenDraftAttachment(ctx context.Context, externalID, draftID, attachmentID string) (filename, contentType string, body io.ReadCloser, err error)
|
OpenDraftAttachment(ctx context.Context, externalID, draftID, attachmentID string) (filename, contentType string, body io.ReadCloser, err error)
|
||||||
|
SaveAttachmentToDrive(ctx context.Context, externalID, email, sub, displayName, messageID, attachmentID, folderPath string) (string, error)
|
||||||
|
SaveMessageAttachmentsToDrive(ctx context.Context, externalID, email, sub, displayName, messageID, folderPath string) ([]map[string]any, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
var _ ServiceAPI = (*Service)(nil)
|
var _ ServiceAPI = (*Service)(nil)
|
||||||
|
|||||||
65
internal/api/middleware/apitoken_policy.go
Normal file
65
internal/api/middleware/apitoken_policy.go
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/ultisuite/ulti-backend/internal/api/apiresponse"
|
||||||
|
"github.com/ultisuite/ulti-backend/internal/apitokens"
|
||||||
|
)
|
||||||
|
|
||||||
|
func EnforceApiTokenPolicy() func(http.Handler) http.Handler {
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
auth := ApiTokenFromContext(r.Context())
|
||||||
|
if auth == nil {
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(r.URL.Path, "/api/v1/search") {
|
||||||
|
reqs := apitokens.SearchRequirements(r.URL.Query().Get("types"))
|
||||||
|
for _, req := range reqs {
|
||||||
|
if !apitokens.AllowsRequirement(auth, req) {
|
||||||
|
apiresponse.WriteError(w, r, http.StatusForbidden, apiresponse.CodeAuthForbidden, "insufficient api token permission", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
accountID := apitokens.ExtractMailAccountID(r.URL.Path, r.URL.Query().Get("account_id"))
|
||||||
|
if accountID != "" && !apitokens.AllowsMailAccount(auth, accountID) {
|
||||||
|
apiresponse.WriteError(w, r, http.StatusForbidden, apiresponse.CodeAuthForbidden, "mail account out of token scope", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
req, ok := apitokens.RequirementForRequest(r.Method, r.URL.Path, r.URL.Query().Get("types"))
|
||||||
|
if !ok {
|
||||||
|
apiresponse.WriteError(w, r, http.StatusForbidden, apiresponse.CodeAuthForbidden, "route not allowed for api token", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !apitokens.AllowsRequirement(auth, req) {
|
||||||
|
apiresponse.WriteError(w, r, http.StatusForbidden, apiresponse.CodeAuthForbidden, "insufficient api token permission", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch req.ScopeHint {
|
||||||
|
case apitokens.ScopeMailAccountQuery, apitokens.ScopeMailAccountPath:
|
||||||
|
accountID := apitokens.ExtractMailAccountID(r.URL.Path, r.URL.Query().Get("account_id"))
|
||||||
|
if accountID != "" && !apitokens.AllowsMailAccount(auth, accountID) {
|
||||||
|
apiresponse.WriteError(w, r, http.StatusForbidden, apiresponse.CodeAuthForbidden, "mail account out of token scope", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case apitokens.ScopeDrivePathFromURL:
|
||||||
|
drivePath := apitokens.ExtractDrivePathFromURL(r.URL.Path)
|
||||||
|
if drivePath != "" && !apitokens.AllowsDrivePath(auth, drivePath) {
|
||||||
|
apiresponse.WriteError(w, r, http.StatusForbidden, apiresponse.CodeAuthForbidden, "drive path out of token scope", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
61
internal/api/middleware/apitoken_policy_test.go
Normal file
61
internal/api/middleware/apitoken_policy_test.go
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/ultisuite/ulti-backend/internal/apitokens"
|
||||||
|
"github.com/ultisuite/ulti-backend/internal/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestEnforceApiTokenPolicyAllowsMailRead(t *testing.T) {
|
||||||
|
authCtx := &apitokens.AuthContext{
|
||||||
|
ExternalID: "user-1",
|
||||||
|
Permissions: []apitokens.PermissionGrant{
|
||||||
|
{Resource: "mail.messages", Read: true},
|
||||||
|
},
|
||||||
|
MailScope: apitokens.MailScope{AllAccounts: true},
|
||||||
|
DriveScope: apitokens.DriveScope{AllFolders: true},
|
||||||
|
}
|
||||||
|
called := false
|
||||||
|
handler := EnforceApiTokenPolicy()(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
called = true
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/mail/messages", nil)
|
||||||
|
ctx := context.WithValue(context.Background(), claimsKey, &auth.Claims{Sub: "user-1"})
|
||||||
|
ctx = context.WithValue(ctx, apiTokenKey, authCtx)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rec, req.WithContext(ctx))
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK || !called {
|
||||||
|
t.Fatalf("status=%d called=%v", rec.Code, called)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnforceApiTokenPolicyDeniesMissingPermission(t *testing.T) {
|
||||||
|
authCtx := &apitokens.AuthContext{
|
||||||
|
ExternalID: "user-1",
|
||||||
|
Permissions: []apitokens.PermissionGrant{
|
||||||
|
{Resource: "mail.messages", Read: true},
|
||||||
|
},
|
||||||
|
MailScope: apitokens.MailScope{AllAccounts: true},
|
||||||
|
DriveScope: apitokens.DriveScope{AllFolders: true},
|
||||||
|
}
|
||||||
|
handler := EnforceApiTokenPolicy()(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
t.Fatal("handler should not run")
|
||||||
|
}))
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/mail/send", nil)
|
||||||
|
ctx := context.WithValue(context.Background(), claimsKey, &auth.Claims{Sub: "user-1"})
|
||||||
|
ctx = context.WithValue(ctx, apiTokenKey, authCtx)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rec, req.WithContext(ctx))
|
||||||
|
|
||||||
|
if rec.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("status=%d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -9,6 +9,7 @@ import (
|
|||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
"github.com/ultisuite/ulti-backend/internal/api/apiresponse"
|
"github.com/ultisuite/ulti-backend/internal/api/apiresponse"
|
||||||
|
"github.com/ultisuite/ulti-backend/internal/apitokens"
|
||||||
"github.com/ultisuite/ulti-backend/internal/auth"
|
"github.com/ultisuite/ulti-backend/internal/auth"
|
||||||
"github.com/ultisuite/ulti-backend/internal/permission"
|
"github.com/ultisuite/ulti-backend/internal/permission"
|
||||||
"github.com/ultisuite/ulti-backend/internal/securityaudit"
|
"github.com/ultisuite/ulti-backend/internal/securityaudit"
|
||||||
@ -17,23 +18,14 @@ import (
|
|||||||
|
|
||||||
type ctxKey string
|
type ctxKey string
|
||||||
|
|
||||||
const claimsKey ctxKey = "claims"
|
const (
|
||||||
|
claimsKey ctxKey = "claims"
|
||||||
|
apiTokenKey ctxKey = "api_token"
|
||||||
|
)
|
||||||
|
|
||||||
func Auth(verifier *auth.Holder, db *pgxpool.Pool, audit *securityaudit.Logger) func(http.Handler) http.Handler {
|
func Auth(verifier *auth.Holder, db *pgxpool.Pool, audit *securityaudit.Logger) func(http.Handler) http.Handler {
|
||||||
return func(next http.Handler) http.Handler {
|
return func(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
if verifier == nil || !verifier.Ready() {
|
|
||||||
apiresponse.WriteError(w, r, http.StatusServiceUnavailable, apiresponse.CodeAuthUnavailable, "authentication unavailable", nil)
|
|
||||||
if audit != nil {
|
|
||||||
audit.Log(r.Context(), "system", securityaudit.ActionTokenRejected, map[string]any{
|
|
||||||
"reason": "verifier_unavailable",
|
|
||||||
"path": r.URL.Path,
|
|
||||||
"method": r.Method,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
header := r.Header.Get("Authorization")
|
header := r.Header.Get("Authorization")
|
||||||
if header == "" {
|
if header == "" {
|
||||||
apiresponse.WriteError(w, r, http.StatusUnauthorized, apiresponse.CodeAuthMissingAuthorization, "missing authorization header", nil)
|
apiresponse.WriteError(w, r, http.StatusUnauthorized, apiresponse.CodeAuthMissingAuthorization, "missing authorization header", nil)
|
||||||
@ -59,6 +51,60 @@ func Auth(verifier *auth.Holder, db *pgxpool.Pool, audit *securityaudit.Logger)
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
token = strings.TrimSpace(token)
|
||||||
|
|
||||||
|
if strings.HasPrefix(token, apitokens.TokenPrefix()) {
|
||||||
|
if db == nil {
|
||||||
|
apiresponse.WriteError(w, r, http.StatusServiceUnavailable, apiresponse.CodeAuthUnavailable, "authentication unavailable", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
apiAuth, err := apitokens.Authenticate(r.Context(), db, token)
|
||||||
|
if err != nil {
|
||||||
|
apiresponse.WriteError(w, r, http.StatusUnauthorized, apiresponse.CodeAuthInvalidToken, "invalid api token", nil)
|
||||||
|
if audit != nil {
|
||||||
|
audit.Log(r.Context(), "anonymous", securityaudit.ActionTokenRejected, map[string]any{
|
||||||
|
"reason": "api_token_verification_failed",
|
||||||
|
"path": r.URL.Path,
|
||||||
|
"method": r.Method,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if isApiTokenManagementRoute(r.URL.Path) && !apitokens.HasPermission(apiAuth, "automation.api_tokens", true) {
|
||||||
|
apiresponse.WriteError(w, r, http.StatusForbidden, apiresponse.CodeAuthForbidden, "api token management requires super admin permission", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
claims := &auth.Claims{
|
||||||
|
Sub: apiAuth.ExternalID,
|
||||||
|
Email: apiAuth.Email,
|
||||||
|
Name: apiAuth.Name,
|
||||||
|
}
|
||||||
|
if audit != nil {
|
||||||
|
audit.Log(r.Context(), claims.Sub, securityaudit.ActionLogin, map[string]any{
|
||||||
|
"email": claims.Email,
|
||||||
|
"path": r.URL.Path,
|
||||||
|
"method": r.Method,
|
||||||
|
"api_token": apiAuth.TokenID,
|
||||||
|
"auth_mode": "api_token",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
ctx := context.WithValue(r.Context(), claimsKey, claims)
|
||||||
|
ctx = context.WithValue(ctx, apiTokenKey, apiAuth)
|
||||||
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if verifier == nil || !verifier.Ready() {
|
||||||
|
apiresponse.WriteError(w, r, http.StatusServiceUnavailable, apiresponse.CodeAuthUnavailable, "authentication unavailable", nil)
|
||||||
|
if audit != nil {
|
||||||
|
audit.Log(r.Context(), "system", securityaudit.ActionTokenRejected, map[string]any{
|
||||||
|
"reason": "verifier_unavailable",
|
||||||
|
"path": r.URL.Path,
|
||||||
|
"method": r.Method,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
claims, err := verifier.Verify(r.Context(), token)
|
claims, err := verifier.Verify(r.Context(), token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -119,3 +165,12 @@ func ClaimsFromContext(ctx context.Context) *auth.Claims {
|
|||||||
claims, _ := ctx.Value(claimsKey).(*auth.Claims)
|
claims, _ := ctx.Value(claimsKey).(*auth.Claims)
|
||||||
return claims
|
return claims
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ApiTokenFromContext(ctx context.Context) *apitokens.AuthContext {
|
||||||
|
authCtx, _ := ctx.Value(apiTokenKey).(*apitokens.AuthContext)
|
||||||
|
return authCtx
|
||||||
|
}
|
||||||
|
|
||||||
|
func isApiTokenManagementRoute(path string) bool {
|
||||||
|
return strings.Contains(path, "/api-tokens")
|
||||||
|
}
|
||||||
|
|||||||
@ -27,6 +27,10 @@ func RequireRole(roles ...permission.Role) func(http.Handler) http.Handler {
|
|||||||
func RequirePermission(resource permission.Resource, level permission.Level) func(http.Handler) http.Handler {
|
func RequirePermission(resource permission.Resource, level permission.Level) func(http.Handler) http.Handler {
|
||||||
return func(next http.Handler) http.Handler {
|
return func(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if ApiTokenFromContext(r.Context()) != nil {
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
claims := ClaimsFromContext(r.Context())
|
claims := ClaimsFromContext(r.Context())
|
||||||
if claims == nil {
|
if claims == nil {
|
||||||
apiresponse.WriteError(w, r, http.StatusUnauthorized, apiresponse.CodeAuthUnauthorized, "unauthorized", nil)
|
apiresponse.WriteError(w, r, http.StatusUnauthorized, apiresponse.CodeAuthUnauthorized, "unauthorized", nil)
|
||||||
|
|||||||
48
internal/api/middleware/scope.go
Normal file
48
internal/api/middleware/scope.go
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/ultisuite/ulti-backend/internal/api/apiresponse"
|
||||||
|
"github.com/ultisuite/ulti-backend/internal/apitokens"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MailScopeAccountIDs returns nil when all mail accounts are allowed (session or token),
|
||||||
|
// otherwise the explicit account IDs authorized by the API token.
|
||||||
|
func MailScopeAccountIDs(ctx context.Context) []string {
|
||||||
|
auth := ApiTokenFromContext(ctx)
|
||||||
|
if auth == nil || auth.MailScope.AllAccounts {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return auth.MailScope.AccountIDs
|
||||||
|
}
|
||||||
|
|
||||||
|
func DenyIfMailAccountOutOfScope(w http.ResponseWriter, r *http.Request, accountID string) bool {
|
||||||
|
auth := ApiTokenFromContext(r.Context())
|
||||||
|
if auth == nil || accountID == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if apitokens.AllowsMailAccount(auth, accountID) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
apiresponse.WriteError(w, r, http.StatusForbidden, apiresponse.CodeAuthForbidden, "mail account out of token scope", nil)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func DenyIfDrivePathOutOfScope(w http.ResponseWriter, r *http.Request, paths ...string) bool {
|
||||||
|
auth := ApiTokenFromContext(r.Context())
|
||||||
|
if auth == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, p := range paths {
|
||||||
|
if p == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !apitokens.AllowsDrivePath(auth, p) {
|
||||||
|
apiresponse.WriteError(w, r, http.StatusForbidden, apiresponse.CodeAuthForbidden, "drive path out of token scope", nil)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
283
internal/apitokens/policy.go
Normal file
283
internal/apitokens/policy.go
Normal file
@ -0,0 +1,283 @@
|
|||||||
|
package apitokens
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ScopeHint int
|
||||||
|
|
||||||
|
const (
|
||||||
|
ScopeNone ScopeHint = iota
|
||||||
|
ScopeMailAccountQuery
|
||||||
|
ScopeMailAccountPath
|
||||||
|
ScopeDrivePathFromURL
|
||||||
|
)
|
||||||
|
|
||||||
|
type Requirement struct {
|
||||||
|
Resource string
|
||||||
|
Alternatives []string
|
||||||
|
Write bool
|
||||||
|
ScopeHint ScopeHint
|
||||||
|
}
|
||||||
|
|
||||||
|
func RequirementForRequest(method, fullPath, typesQuery string) (Requirement, bool) {
|
||||||
|
method = strings.ToUpper(strings.TrimSpace(method))
|
||||||
|
path := strings.TrimSuffix(strings.TrimSpace(fullPath), "/")
|
||||||
|
if path == "" {
|
||||||
|
path = "/"
|
||||||
|
}
|
||||||
|
|
||||||
|
write := method != http.MethodGet && method != http.MethodHead
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(path, "/api/v1/mail/api-tokens"):
|
||||||
|
return Requirement{Resource: "automation.api_tokens", Write: write || method == http.MethodDelete}, true
|
||||||
|
case strings.HasPrefix(path, "/api/v1/mail/webhooks"):
|
||||||
|
return Requirement{Resource: "automation.webhooks", Write: write}, true
|
||||||
|
case strings.HasPrefix(path, "/api/v1/mail/rules"):
|
||||||
|
return Requirement{Resource: "automation.rules", Write: write}, true
|
||||||
|
|
||||||
|
case strings.HasPrefix(path, "/api/v1/contacts/discovery/llm-settings"),
|
||||||
|
strings.HasPrefix(path, "/api/v1/contacts/discovery/llm-models/"):
|
||||||
|
return Requirement{Resource: "automation.llm", Write: write}, true
|
||||||
|
case strings.HasPrefix(path, "/api/v1/contacts/discovery/search-settings"):
|
||||||
|
return Requirement{Resource: "automation.search", Write: write}, true
|
||||||
|
case strings.HasPrefix(path, "/api/v1/contacts/discovery/"):
|
||||||
|
return Requirement{Resource: "contacts.write", Write: write}, true
|
||||||
|
|
||||||
|
case strings.HasPrefix(path, "/api/v1/contacts/search"):
|
||||||
|
return Requirement{Resource: "contacts.search", Write: false}, true
|
||||||
|
case strings.HasPrefix(path, "/api/v1/contacts/"):
|
||||||
|
switch method {
|
||||||
|
case http.MethodPost, http.MethodPut, http.MethodPatch:
|
||||||
|
if strings.Contains(path, "/merge-duplicates") || strings.Contains(path, "/improve") {
|
||||||
|
return Requirement{Resource: "contacts.write", Write: true}, true
|
||||||
|
}
|
||||||
|
if strings.Contains(path, "/books/") {
|
||||||
|
return Requirement{Resource: "contacts.write", Write: true}, true
|
||||||
|
}
|
||||||
|
return Requirement{Resource: "contacts.write", Write: true}, true
|
||||||
|
case http.MethodDelete:
|
||||||
|
return Requirement{Resource: "contacts.delete", Write: true}, true
|
||||||
|
default:
|
||||||
|
return Requirement{Resource: "contacts.read", Write: false}, true
|
||||||
|
}
|
||||||
|
|
||||||
|
case strings.HasPrefix(path, "/api/v1/drive/"):
|
||||||
|
return driveRequirement(method, path)
|
||||||
|
|
||||||
|
case strings.HasPrefix(path, "/api/v1/search"):
|
||||||
|
return searchRequirement(typesQuery)
|
||||||
|
|
||||||
|
case strings.HasPrefix(path, "/api/v1/mail/"):
|
||||||
|
return mailRequirement(method, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Requirement{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func mailRequirement(method, path string) (Requirement, bool) {
|
||||||
|
write := method != http.MethodGet && method != http.MethodHead
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(path, "/api/v1/mail/settings"):
|
||||||
|
return Requirement{Resource: "mail.settings", Write: write}, true
|
||||||
|
case strings.HasPrefix(path, "/api/v1/mail/search"):
|
||||||
|
return Requirement{Resource: "mail.search", Write: false, ScopeHint: ScopeMailAccountQuery}, true
|
||||||
|
case strings.HasPrefix(path, "/api/v1/mail/send"),
|
||||||
|
strings.HasPrefix(path, "/api/v1/mail/outbox/"):
|
||||||
|
return Requirement{Resource: "mail.send", Write: true}, true
|
||||||
|
case strings.HasPrefix(path, "/api/v1/mail/signatures"):
|
||||||
|
return Requirement{Resource: "mail.settings", Write: write}, true
|
||||||
|
case strings.HasPrefix(path, "/api/v1/mail/identities/"):
|
||||||
|
return Requirement{Resource: "mail.identities", Write: write}, true
|
||||||
|
case strings.Contains(path, "/accounts/") && strings.Contains(path, "/identities"):
|
||||||
|
return Requirement{Resource: "mail.identities", Write: write, ScopeHint: ScopeMailAccountPath}, true
|
||||||
|
case strings.HasPrefix(path, "/api/v1/mail/accounts"):
|
||||||
|
if write {
|
||||||
|
return Requirement{Resource: "mail.settings", Write: true, ScopeHint: ScopeMailAccountPath}, true
|
||||||
|
}
|
||||||
|
return Requirement{Resource: "mail.mailboxes", Write: false, ScopeHint: ScopeMailAccountPath}, true
|
||||||
|
case strings.HasPrefix(path, "/api/v1/mail/unified-folders"),
|
||||||
|
strings.HasPrefix(path, "/api/v1/mail/folders"):
|
||||||
|
return Requirement{Resource: "mail.mailboxes", Write: write, ScopeHint: ScopeMailAccountQuery}, true
|
||||||
|
case strings.HasPrefix(path, "/api/v1/mail/labels"):
|
||||||
|
return Requirement{Resource: "mail.labels", Write: write}, true
|
||||||
|
case strings.HasPrefix(path, "/api/v1/mail/attachments/"),
|
||||||
|
strings.Contains(path, "/attachments"):
|
||||||
|
if write {
|
||||||
|
return Requirement{Resource: "mail.attachments", Write: true}, true
|
||||||
|
}
|
||||||
|
return Requirement{Resource: "mail.attachments", Write: false}, true
|
||||||
|
case strings.HasPrefix(path, "/api/v1/mail/messages"):
|
||||||
|
if strings.HasSuffix(path, "/labels") || strings.HasSuffix(path, "/flags") {
|
||||||
|
return Requirement{Resource: "mail.labels", Write: true}, true
|
||||||
|
}
|
||||||
|
if write {
|
||||||
|
return Requirement{Resource: "mail.labels", Write: true}, true
|
||||||
|
}
|
||||||
|
return Requirement{Resource: "mail.messages", Write: false, ScopeHint: ScopeMailAccountQuery}, true
|
||||||
|
case strings.HasPrefix(path, "/api/v1/mail/threads"):
|
||||||
|
return Requirement{Resource: "mail.messages", Write: false}, true
|
||||||
|
case strings.HasPrefix(path, "/api/v1/mail/drafts"):
|
||||||
|
if write {
|
||||||
|
return Requirement{Resource: "mail.send", Write: true}, true
|
||||||
|
}
|
||||||
|
return Requirement{Resource: "mail.messages", Write: false}, true
|
||||||
|
default:
|
||||||
|
return Requirement{}, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func driveRequirement(method, path string) (Requirement, bool) {
|
||||||
|
write := method != http.MethodGet && method != http.MethodHead
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case strings.Contains(path, "/preview/"):
|
||||||
|
return Requirement{Resource: "drive.thumbnails", Write: false, ScopeHint: ScopeDrivePathFromURL}, true
|
||||||
|
case strings.Contains(path, "/download/"):
|
||||||
|
return Requirement{Resource: "drive.download", Write: false, ScopeHint: ScopeDrivePathFromURL}, true
|
||||||
|
case strings.Contains(path, "/shares"):
|
||||||
|
return Requirement{Resource: "drive.share", Write: write}, true
|
||||||
|
case strings.Contains(path, "/move"):
|
||||||
|
return Requirement{Resource: "drive.move", Write: true}, true
|
||||||
|
case strings.Contains(path, "/copy"):
|
||||||
|
return Requirement{Resource: "drive.copy", Write: true}, true
|
||||||
|
case strings.Contains(path, "/rename"):
|
||||||
|
return Requirement{Resource: "drive.rename", Write: true}, true
|
||||||
|
case strings.Contains(path, "/files/") || strings.Contains(path, "/folders/"):
|
||||||
|
if write {
|
||||||
|
return Requirement{Resource: "drive.upload", Write: true, ScopeHint: ScopeDrivePathFromURL}, true
|
||||||
|
}
|
||||||
|
return Requirement{
|
||||||
|
Resource: "drive.folders",
|
||||||
|
Alternatives: []string{"drive.files"},
|
||||||
|
Write: false,
|
||||||
|
ScopeHint: ScopeDrivePathFromURL,
|
||||||
|
}, true
|
||||||
|
case strings.Contains(path, "/search"),
|
||||||
|
strings.Contains(path, "/recent"),
|
||||||
|
strings.Contains(path, "/starred"),
|
||||||
|
strings.Contains(path, "/shared"),
|
||||||
|
strings.Contains(path, "/filter-corpus"),
|
||||||
|
strings.Contains(path, "/quota"),
|
||||||
|
strings.Contains(path, "/trash"):
|
||||||
|
if write {
|
||||||
|
return Requirement{Resource: "drive.upload", Write: true}, true
|
||||||
|
}
|
||||||
|
return Requirement{Resource: "drive.files", Write: false}, true
|
||||||
|
default:
|
||||||
|
return Requirement{}, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func searchRequirement(typesQuery string) (Requirement, bool) {
|
||||||
|
types := parseSearchTypes(typesQuery)
|
||||||
|
if len(types) == 0 {
|
||||||
|
return Requirement{Resource: "mail.search", Write: false, ScopeHint: ScopeMailAccountQuery}, true
|
||||||
|
}
|
||||||
|
req := Requirement{Write: false, ScopeHint: ScopeMailAccountQuery}
|
||||||
|
for _, t := range types {
|
||||||
|
switch t {
|
||||||
|
case "mail":
|
||||||
|
req.Resource = "mail.search"
|
||||||
|
case "contacts":
|
||||||
|
req.Resource = "contacts.search"
|
||||||
|
case "drive":
|
||||||
|
req.Resource = "drive.files"
|
||||||
|
default:
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return req, true
|
||||||
|
}
|
||||||
|
return Requirement{Resource: "mail.search", Write: false, ScopeHint: ScopeMailAccountQuery}, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseSearchTypes(raw string) []string {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if raw == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
parts := strings.Split(raw, ",")
|
||||||
|
out := make([]string, 0, len(parts))
|
||||||
|
for _, part := range parts {
|
||||||
|
part = strings.TrimSpace(strings.ToLower(part))
|
||||||
|
if part != "" {
|
||||||
|
out = append(out, part)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func AllowsRequirement(auth *AuthContext, req Requirement) bool {
|
||||||
|
if auth == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if req.Resource == "automation.api_tokens" && !req.Write {
|
||||||
|
return HasPermission(auth, req.Resource, true)
|
||||||
|
}
|
||||||
|
if HasPermission(auth, req.Resource, req.Write) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, alt := range req.Alternatives {
|
||||||
|
if HasPermission(auth, alt, req.Write) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func SearchRequirements(typesQuery string) []Requirement {
|
||||||
|
types := parseSearchTypes(typesQuery)
|
||||||
|
if len(types) == 0 {
|
||||||
|
return []Requirement{{Resource: "mail.search", Write: false, ScopeHint: ScopeMailAccountQuery}}
|
||||||
|
}
|
||||||
|
reqs := make([]Requirement, 0, len(types))
|
||||||
|
for _, t := range types {
|
||||||
|
switch t {
|
||||||
|
case "mail":
|
||||||
|
reqs = append(reqs, Requirement{Resource: "mail.search", Write: false, ScopeHint: ScopeMailAccountQuery})
|
||||||
|
case "contacts":
|
||||||
|
reqs = append(reqs, Requirement{Resource: "contacts.search", Write: false})
|
||||||
|
case "drive":
|
||||||
|
reqs = append(reqs, Requirement{Resource: "drive.files", Write: false})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(reqs) == 0 {
|
||||||
|
return []Requirement{{Resource: "mail.search", Write: false, ScopeHint: ScopeMailAccountQuery}}
|
||||||
|
}
|
||||||
|
return reqs
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExtractMailAccountID(path, queryAccountID string) string {
|
||||||
|
if id := strings.TrimSpace(queryAccountID); id != "" {
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
parts := strings.Split(strings.Trim(path, "/"), "/")
|
||||||
|
for i := 0; i < len(parts)-1; i++ {
|
||||||
|
if parts[i] == "accounts" && i+1 < len(parts) {
|
||||||
|
return parts[i+1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExtractDrivePathFromURL(fullPath string) string {
|
||||||
|
markers := []string{
|
||||||
|
"/api/v1/drive/files/",
|
||||||
|
"/api/v1/drive/download/",
|
||||||
|
"/api/v1/drive/preview/",
|
||||||
|
"/api/v1/drive/folders/",
|
||||||
|
}
|
||||||
|
for _, marker := range markers {
|
||||||
|
if idx := strings.Index(fullPath, marker); idx >= 0 {
|
||||||
|
rest := fullPath[idx+len(marker):]
|
||||||
|
if rest == "" {
|
||||||
|
return "/"
|
||||||
|
}
|
||||||
|
return NormalizeDriveScopePath("/" + rest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
65
internal/apitokens/policy_test.go
Normal file
65
internal/apitokens/policy_test.go
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
package apitokens
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestRequirementForMailMessages(t *testing.T) {
|
||||||
|
req, ok := RequirementForRequest("GET", "/api/v1/mail/messages", "")
|
||||||
|
if !ok || req.Resource != "mail.messages" || req.Write {
|
||||||
|
t.Fatalf("got %+v ok=%v", req, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequirementForMailSend(t *testing.T) {
|
||||||
|
req, ok := RequirementForRequest("POST", "/api/v1/mail/send", "")
|
||||||
|
if !ok || req.Resource != "mail.send" || !req.Write {
|
||||||
|
t.Fatalf("got %+v ok=%v", req, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequirementForDriveUpload(t *testing.T) {
|
||||||
|
req, ok := RequirementForRequest("POST", "/api/v1/drive/files/Projects", "")
|
||||||
|
if !ok || req.Resource != "drive.upload" || !req.Write || req.ScopeHint != ScopeDrivePathFromURL {
|
||||||
|
t.Fatalf("got %+v ok=%v", req, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequirementForAutomationWebhooks(t *testing.T) {
|
||||||
|
req, ok := RequirementForRequest("DELETE", "/api/v1/mail/webhooks/abc", "")
|
||||||
|
if !ok || req.Resource != "automation.webhooks" || !req.Write {
|
||||||
|
t.Fatalf("got %+v ok=%v", req, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearchRequirementsMultipleTypes(t *testing.T) {
|
||||||
|
reqs := SearchRequirements("mail,contacts,drive")
|
||||||
|
if len(reqs) != 3 {
|
||||||
|
t.Fatalf("len = %d", len(reqs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAllowsRequirementAlternatives(t *testing.T) {
|
||||||
|
auth := &AuthContext{
|
||||||
|
Permissions: []PermissionGrant{
|
||||||
|
{Resource: "drive.files", Read: true},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
req := Requirement{Resource: "drive.folders", Alternatives: []string{"drive.files"}, Write: false}
|
||||||
|
if !AllowsRequirement(auth, req) {
|
||||||
|
t.Fatal("expected drive.files alternative to satisfy folders read")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractMailAccountIDFromPath(t *testing.T) {
|
||||||
|
got := ExtractMailAccountID("/api/v1/mail/accounts/550e8400-e29b-41d4-a716-446655440000/sync", "")
|
||||||
|
if got != "550e8400-e29b-41d4-a716-446655440000" {
|
||||||
|
t.Fatalf("got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractDrivePathFromURL(t *testing.T) {
|
||||||
|
got := ExtractDrivePathFromURL("/api/v1/drive/files/Projects/docs")
|
||||||
|
want := "/Projects/docs"
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("got %q want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
69
internal/apitokens/scope.go
Normal file
69
internal/apitokens/scope.go
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
package apitokens
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/ultisuite/ulti-backend/internal/nextcloud"
|
||||||
|
)
|
||||||
|
|
||||||
|
func AllowsMailAccount(auth *AuthContext, accountID string) bool {
|
||||||
|
if auth == nil || accountID == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if auth.MailScope.AllAccounts {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, id := range auth.MailScope.AccountIDs {
|
||||||
|
if id == accountID {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func AllowsDrivePath(auth *AuthContext, rawPath string) bool {
|
||||||
|
if auth == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if auth.DriveScope.AllFolders {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
target := NormalizeDriveScopePath(rawPath)
|
||||||
|
if target == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, allowed := range auth.DriveScope.FolderPaths {
|
||||||
|
if drivePathWithinScope(target, allowed) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func NormalizeDriveScopePath(rawPath string) string {
|
||||||
|
rawPath = strings.TrimSpace(rawPath)
|
||||||
|
if rawPath == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
normalized := nextcloud.NormalizeClientPath(rawPath)
|
||||||
|
if normalized == "" {
|
||||||
|
return "/"
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(normalized, "/") {
|
||||||
|
normalized = "/" + normalized
|
||||||
|
}
|
||||||
|
return path.Clean(normalized)
|
||||||
|
}
|
||||||
|
|
||||||
|
func drivePathWithinScope(target, allowed string) bool {
|
||||||
|
target = NormalizeDriveScopePath(target)
|
||||||
|
allowed = NormalizeDriveScopePath(allowed)
|
||||||
|
if allowed == "/" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if target == allowed {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return strings.HasPrefix(target, allowed+"/")
|
||||||
|
}
|
||||||
33
internal/apitokens/scope_test.go
Normal file
33
internal/apitokens/scope_test.go
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
package apitokens
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestDrivePathWithinScope(t *testing.T) {
|
||||||
|
auth := &AuthContext{
|
||||||
|
DriveScope: DriveScope{
|
||||||
|
AllFolders: false,
|
||||||
|
FolderPaths: []string{"/Projects"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if !AllowsDrivePath(auth, "/Projects/docs/report.pdf") {
|
||||||
|
t.Fatal("expected nested path within /Projects")
|
||||||
|
}
|
||||||
|
if AllowsDrivePath(auth, "/Personal/notes.txt") {
|
||||||
|
t.Fatal("did not expect /Personal to be allowed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAllowsMailAccountScoped(t *testing.T) {
|
||||||
|
auth := &AuthContext{
|
||||||
|
MailScope: MailScope{
|
||||||
|
AllAccounts: false,
|
||||||
|
AccountIDs: []string{"acc-1"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if !AllowsMailAccount(auth, "acc-1") {
|
||||||
|
t.Fatal("expected acc-1")
|
||||||
|
}
|
||||||
|
if AllowsMailAccount(auth, "acc-2") {
|
||||||
|
t.Fatal("did not expect acc-2")
|
||||||
|
}
|
||||||
|
}
|
||||||
300
internal/apitokens/tokens.go
Normal file
300
internal/apitokens/tokens.go
Normal file
@ -0,0 +1,300 @@
|
|||||||
|
package apitokens
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/subtle"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
const tokenPrefix = "ulti_"
|
||||||
|
|
||||||
|
func TokenPrefix() string {
|
||||||
|
return tokenPrefix
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrNotFound = errors.New("api token not found")
|
||||||
|
ErrRevoked = errors.New("api token revoked")
|
||||||
|
ErrExpired = errors.New("api token expired")
|
||||||
|
)
|
||||||
|
|
||||||
|
type PermissionGrant struct {
|
||||||
|
Resource string `json:"resource"`
|
||||||
|
Read bool `json:"read"`
|
||||||
|
Write bool `json:"write"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type MailScope struct {
|
||||||
|
AllAccounts bool `json:"all_accounts"`
|
||||||
|
AccountIDs []string `json:"account_ids"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DriveScope struct {
|
||||||
|
AllFolders bool `json:"all_folders"`
|
||||||
|
FolderPaths []string `json:"folder_paths"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Token struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
TokenPrefix string `json:"token_prefix"`
|
||||||
|
Permissions []PermissionGrant `json:"permissions"`
|
||||||
|
MailScope MailScope `json:"mail_scope"`
|
||||||
|
DriveScope DriveScope `json:"drive_scope"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
|
||||||
|
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreatedToken struct {
|
||||||
|
Token
|
||||||
|
TokenSecret string `json:"token"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AuthContext struct {
|
||||||
|
TokenID string
|
||||||
|
UserID string
|
||||||
|
ExternalID string
|
||||||
|
Email string
|
||||||
|
Name string
|
||||||
|
Permissions []PermissionGrant
|
||||||
|
MailScope MailScope
|
||||||
|
DriveScope DriveScope
|
||||||
|
}
|
||||||
|
|
||||||
|
func HashSecret(secret string) []byte {
|
||||||
|
sum := sha256.Sum256([]byte(secret))
|
||||||
|
return sum[:]
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateSecret() (string, string, error) {
|
||||||
|
raw := make([]byte, 32)
|
||||||
|
if _, err := rand.Read(raw); err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
encoded := base64.RawURLEncoding.EncodeToString(raw)
|
||||||
|
full := tokenPrefix + encoded
|
||||||
|
visible := tokenPrefix + encoded[:8]
|
||||||
|
return full, visible, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func List(ctx context.Context, db *pgxpool.Pool, externalID string) ([]Token, error) {
|
||||||
|
rows, err := db.Query(ctx, `
|
||||||
|
SELECT t.id, t.name, t.token_prefix, t.permissions, t.mail_scope, t.drive_scope,
|
||||||
|
t.created_at, t.last_used_at, t.expires_at
|
||||||
|
FROM api_tokens t
|
||||||
|
JOIN users u ON u.id = t.user_id
|
||||||
|
WHERE u.external_id = $1 AND t.revoked_at IS NULL
|
||||||
|
ORDER BY t.created_at DESC
|
||||||
|
`, externalID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
out := make([]Token, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
item, err := scanToken(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, item)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func Create(ctx context.Context, db *pgxpool.Pool, externalID string, name string, permissions []PermissionGrant, mailScope MailScope, driveScope DriveScope, expiresAt *time.Time) (CreatedToken, error) {
|
||||||
|
secret, prefix, err := generateSecret()
|
||||||
|
if err != nil {
|
||||||
|
return CreatedToken{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
permJSON, err := json.Marshal(permissions)
|
||||||
|
if err != nil {
|
||||||
|
return CreatedToken{}, err
|
||||||
|
}
|
||||||
|
mailJSON, err := json.Marshal(mailScope)
|
||||||
|
if err != nil {
|
||||||
|
return CreatedToken{}, err
|
||||||
|
}
|
||||||
|
driveJSON, err := json.Marshal(driveScope)
|
||||||
|
if err != nil {
|
||||||
|
return CreatedToken{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var item Token
|
||||||
|
err = db.QueryRow(ctx, `
|
||||||
|
INSERT INTO api_tokens (
|
||||||
|
user_id, name, token_prefix, secret_hash, permissions, mail_scope, drive_scope, expires_at
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
(SELECT id FROM users WHERE external_id = $1),
|
||||||
|
$2, $3, $4, $5, $6, $7, $8
|
||||||
|
)
|
||||||
|
RETURNING id, name, token_prefix, permissions, mail_scope, drive_scope, created_at, last_used_at, expires_at
|
||||||
|
`, externalID, name, prefix, HashSecret(secret), permJSON, mailJSON, driveJSON, expiresAt).Scan(
|
||||||
|
&item.ID,
|
||||||
|
&item.Name,
|
||||||
|
&item.TokenPrefix,
|
||||||
|
&permJSON,
|
||||||
|
&mailJSON,
|
||||||
|
&driveJSON,
|
||||||
|
&item.CreatedAt,
|
||||||
|
&item.LastUsedAt,
|
||||||
|
&item.ExpiresAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return CreatedToken{}, err
|
||||||
|
}
|
||||||
|
if err := decodeTokenJSON(permJSON, mailJSON, driveJSON, &item); err != nil {
|
||||||
|
return CreatedToken{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return CreatedToken{Token: item, TokenSecret: secret}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func Revoke(ctx context.Context, db *pgxpool.Pool, externalID, tokenID string) error {
|
||||||
|
result, err := db.Exec(ctx, `
|
||||||
|
UPDATE api_tokens
|
||||||
|
SET revoked_at = now(), updated_at = now()
|
||||||
|
WHERE id = $1
|
||||||
|
AND user_id = (SELECT id FROM users WHERE external_id = $2)
|
||||||
|
AND revoked_at IS NULL
|
||||||
|
`, tokenID, externalID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if result.RowsAffected() == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func Authenticate(ctx context.Context, db *pgxpool.Pool, secret string) (*AuthContext, error) {
|
||||||
|
secret = strings.TrimSpace(secret)
|
||||||
|
if !strings.HasPrefix(secret, tokenPrefix) {
|
||||||
|
return nil, fmt.Errorf("not an api token")
|
||||||
|
}
|
||||||
|
|
||||||
|
hash := HashSecret(secret)
|
||||||
|
row := db.QueryRow(ctx, `
|
||||||
|
SELECT t.id, u.id::text, u.external_id, u.email, COALESCE(u.name, ''),
|
||||||
|
t.permissions, t.mail_scope, t.drive_scope, t.expires_at, t.revoked_at
|
||||||
|
FROM api_tokens t
|
||||||
|
JOIN users u ON u.id = t.user_id
|
||||||
|
WHERE t.secret_hash = $1
|
||||||
|
LIMIT 1
|
||||||
|
`, hash)
|
||||||
|
|
||||||
|
var auth AuthContext
|
||||||
|
var permJSON, mailJSON, driveJSON []byte
|
||||||
|
var expiresAt *time.Time
|
||||||
|
var revokedAt *time.Time
|
||||||
|
if err := row.Scan(
|
||||||
|
&auth.TokenID,
|
||||||
|
&auth.UserID,
|
||||||
|
&auth.ExternalID,
|
||||||
|
&auth.Email,
|
||||||
|
&auth.Name,
|
||||||
|
&permJSON,
|
||||||
|
&mailJSON,
|
||||||
|
&driveJSON,
|
||||||
|
&expiresAt,
|
||||||
|
&revokedAt,
|
||||||
|
); err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if revokedAt != nil {
|
||||||
|
return nil, ErrRevoked
|
||||||
|
}
|
||||||
|
if expiresAt != nil && time.Now().After(*expiresAt) {
|
||||||
|
return nil, ErrExpired
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(permJSON, &auth.Permissions); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(mailJSON, &auth.MailScope); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(driveJSON, &auth.DriveScope); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, _ = db.Exec(ctx, `
|
||||||
|
UPDATE api_tokens SET last_used_at = now(), updated_at = now() WHERE id = $1
|
||||||
|
`, auth.TokenID)
|
||||||
|
|
||||||
|
return &auth, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func HasPermission(auth *AuthContext, resource string, write bool) bool {
|
||||||
|
if auth == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, grant := range auth.Permissions {
|
||||||
|
if grant.Resource != resource {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if write {
|
||||||
|
return grant.Write
|
||||||
|
}
|
||||||
|
return grant.Read || grant.Write
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func ConstantTimeEqual(a, b string) bool {
|
||||||
|
return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
|
||||||
|
}
|
||||||
|
|
||||||
|
type rowScanner interface {
|
||||||
|
Scan(dest ...any) error
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanToken(rows rowScanner) (Token, error) {
|
||||||
|
var item Token
|
||||||
|
var permJSON, mailJSON, driveJSON []byte
|
||||||
|
if err := rows.Scan(
|
||||||
|
&item.ID,
|
||||||
|
&item.Name,
|
||||||
|
&item.TokenPrefix,
|
||||||
|
&permJSON,
|
||||||
|
&mailJSON,
|
||||||
|
&driveJSON,
|
||||||
|
&item.CreatedAt,
|
||||||
|
&item.LastUsedAt,
|
||||||
|
&item.ExpiresAt,
|
||||||
|
); err != nil {
|
||||||
|
return Token{}, err
|
||||||
|
}
|
||||||
|
if err := decodeTokenJSON(permJSON, mailJSON, driveJSON, &item); err != nil {
|
||||||
|
return Token{}, err
|
||||||
|
}
|
||||||
|
return item, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeTokenJSON(permJSON, mailJSON, driveJSON []byte, item *Token) error {
|
||||||
|
if err := json.Unmarshal(permJSON, &item.Permissions); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(mailJSON, &item.MailScope); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(driveJSON, &item.DriveScope); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
37
internal/apitokens/tokens_test.go
Normal file
37
internal/apitokens/tokens_test.go
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
package apitokens
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestHashSecretDeterministic(t *testing.T) {
|
||||||
|
a := HashSecret("ulti_test_secret")
|
||||||
|
b := HashSecret("ulti_test_secret")
|
||||||
|
if len(a) != 32 {
|
||||||
|
t.Fatalf("hash length = %d, want 32", len(a))
|
||||||
|
}
|
||||||
|
for i := range a {
|
||||||
|
if a[i] != b[i] {
|
||||||
|
t.Fatal("hash not deterministic")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHasPermission(t *testing.T) {
|
||||||
|
auth := &AuthContext{
|
||||||
|
Permissions: []PermissionGrant{
|
||||||
|
{Resource: "mail.messages", Read: true, Write: false},
|
||||||
|
{Resource: "mail.send", Read: false, Write: true},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if !HasPermission(auth, "mail.messages", false) {
|
||||||
|
t.Fatal("expected read on mail.messages")
|
||||||
|
}
|
||||||
|
if HasPermission(auth, "mail.messages", true) {
|
||||||
|
t.Fatal("did not expect write on mail.messages")
|
||||||
|
}
|
||||||
|
if !HasPermission(auth, "mail.send", true) {
|
||||||
|
t.Fatal("expected write on mail.send")
|
||||||
|
}
|
||||||
|
if HasPermission(auth, "drive.files", false) {
|
||||||
|
t.Fatal("did not expect drive.files")
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -101,14 +101,10 @@ func Catalog(cfg *config.Config) []AppSpec {
|
|||||||
func ultimailRedirectURIs(cfg *config.Config) []string {
|
func ultimailRedirectURIs(cfg *config.Config) []string {
|
||||||
base := baseURL(cfg)
|
base := baseURL(cfg)
|
||||||
mail := strings.TrimRight(cfg.MailAppURL, "/")
|
mail := strings.TrimRight(cfg.MailAppURL, "/")
|
||||||
drive := strings.TrimRight(base+"/drive", "/")
|
|
||||||
return uniqueURIs(
|
return uniqueURIs(
|
||||||
mail+"/api/auth/callback",
|
mail+"/api/auth/callback",
|
||||||
"http://localhost:3000/api/auth/callback",
|
"http://localhost:3000/api/auth/callback",
|
||||||
"http://127.0.0.1:3000/api/auth/callback",
|
"http://127.0.0.1:3000/api/auth/callback",
|
||||||
drive+"/api/auth/callback",
|
|
||||||
"http://localhost:3001/api/auth/callback",
|
|
||||||
"http://127.0.0.1:3001/api/auth/callback",
|
|
||||||
base+"/api/auth/callback",
|
base+"/api/auth/callback",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -145,11 +141,10 @@ func immichRedirectURIs(cfg *config.Config) []string {
|
|||||||
|
|
||||||
func driveRedirectURIs(cfg *config.Config) []string {
|
func driveRedirectURIs(cfg *config.Config) []string {
|
||||||
base := baseURL(cfg)
|
base := baseURL(cfg)
|
||||||
drive := strings.TrimRight(base+"/drive", "/")
|
|
||||||
return uniqueURIs(
|
return uniqueURIs(
|
||||||
drive+"/api/auth/callback",
|
base+"/api/auth/callback",
|
||||||
"http://localhost:3001/api/auth/callback",
|
"http://localhost:3000/api/auth/callback",
|
||||||
"http://127.0.0.1:3001/api/auth/callback",
|
"http://127.0.0.1:3000/api/auth/callback",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -141,6 +141,19 @@ func NormalizeClientFilePath(userID, path string) string {
|
|||||||
return path
|
return path
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FileNameFromClientPath returns the storage basename for a logical client path.
|
||||||
|
func FileNameFromClientPath(path string) string {
|
||||||
|
return pathBaseName(NormalizeClientPath(path))
|
||||||
|
}
|
||||||
|
|
||||||
|
// SyncFileDisplayName aligns display name with the storage path basename when present.
|
||||||
|
func SyncFileDisplayName(path, name string) string {
|
||||||
|
if bn := FileNameFromClientPath(path); bn != "" {
|
||||||
|
return bn
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(name)
|
||||||
|
}
|
||||||
|
|
||||||
// EnsureClientFilePath joins name when path is a parent directory (Nextcloud recent API).
|
// EnsureClientFilePath joins name when path is a parent directory (Nextcloud recent API).
|
||||||
func EnsureClientFilePath(path, name string) string {
|
func EnsureClientFilePath(path, name string) string {
|
||||||
path = NormalizeClientPath(path)
|
path = NormalizeClientPath(path)
|
||||||
|
|||||||
@ -88,6 +88,14 @@ func TestNormalizeClientFilePathStripsOCSPrefix(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSyncFileDisplayNamePrefersPathBasename(t *testing.T) {
|
||||||
|
got := SyncFileDisplayName("/Documents/actual.jpg", "Display Name.jpg")
|
||||||
|
want := "actual.jpg"
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("SyncFileDisplayName() = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestEnsureClientFilePathJoinsName(t *testing.T) {
|
func TestEnsureClientFilePathJoinsName(t *testing.T) {
|
||||||
got := EnsureClientFilePath("/Documents", "report.pdf")
|
got := EnsureClientFilePath("/Documents", "report.pdf")
|
||||||
want := "/Documents/report.pdf"
|
want := "/Documents/report.pdf"
|
||||||
|
|||||||
@ -26,6 +26,7 @@ type FileInfo struct {
|
|||||||
FileID int64 `json:"file_id,omitempty"`
|
FileID int64 `json:"file_id,omitempty"`
|
||||||
IsFavorite bool `json:"is_favorite"`
|
IsFavorite bool `json:"is_favorite"`
|
||||||
IsShared bool `json:"is_shared"`
|
IsShared bool `json:"is_shared"`
|
||||||
|
Source string `json:"source,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ShareInfo struct {
|
type ShareInfo struct {
|
||||||
@ -357,9 +358,10 @@ func (c *Client) listRecentOCS(ctx context.Context, userID string, limit int) ([
|
|||||||
NormalizeClientFilePath(userID, item.Path),
|
NormalizeClientFilePath(userID, item.Path),
|
||||||
item.Name,
|
item.Name,
|
||||||
)
|
)
|
||||||
|
name := SyncFileDisplayName(logicalPath, item.Name)
|
||||||
files = append(files, FileInfo{
|
files = append(files, FileInfo{
|
||||||
Path: logicalPath,
|
Path: logicalPath,
|
||||||
Name: item.Name,
|
Name: name,
|
||||||
Type: fileType,
|
Type: fileType,
|
||||||
Size: item.Size,
|
Size: item.Size,
|
||||||
MimeType: item.MimeType,
|
MimeType: item.MimeType,
|
||||||
@ -443,6 +445,7 @@ func (c *Client) ListSharedWithMe(ctx context.Context, userID string) ([]FileInf
|
|||||||
NormalizeClientFilePath(userID, item.Path),
|
NormalizeClientFilePath(userID, item.Path),
|
||||||
name,
|
name,
|
||||||
)
|
)
|
||||||
|
name = SyncFileDisplayName(logicalPath, name)
|
||||||
files = append(files, FileInfo{
|
files = append(files, FileInfo{
|
||||||
Path: logicalPath,
|
Path: logicalPath,
|
||||||
Name: name,
|
Name: name,
|
||||||
@ -602,6 +605,8 @@ func (c *Client) EmptyTrash(ctx context.Context, userID string) error {
|
|||||||
const (
|
const (
|
||||||
favoritesMaxDirs = 2000
|
favoritesMaxDirs = 2000
|
||||||
favoritesMaxCollect = 500
|
favoritesMaxCollect = 500
|
||||||
|
filterCorpusMaxDirs = 2000
|
||||||
|
filterCorpusMaxFiles = 10000
|
||||||
)
|
)
|
||||||
|
|
||||||
func (c *Client) ListFavorites(ctx context.Context, userID, basePath string, maxCollect int) ([]FileInfo, error) {
|
func (c *Client) ListFavorites(ctx context.Context, userID, basePath string, maxCollect int) ([]FileInfo, error) {
|
||||||
@ -651,6 +656,52 @@ func (c *Client) ListFavorites(ctx context.Context, userID, basePath string, max
|
|||||||
return results, nil
|
return results, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListFilesRecursive collects file entries (not directories) under basePath for client-side filtering.
|
||||||
|
func (c *Client) ListFilesRecursive(ctx context.Context, userID, basePath string, maxFiles int) ([]FileInfo, error) {
|
||||||
|
if maxFiles <= 0 {
|
||||||
|
maxFiles = filterCorpusMaxFiles
|
||||||
|
}
|
||||||
|
basePath = normalizeSearchPath(basePath)
|
||||||
|
if basePath == "" {
|
||||||
|
basePath = "/"
|
||||||
|
}
|
||||||
|
|
||||||
|
queue := []string{basePath}
|
||||||
|
seen := map[string]struct{}{basePath: {}}
|
||||||
|
results := make([]FileInfo, 0, min(maxFiles, 256))
|
||||||
|
visited := 0
|
||||||
|
|
||||||
|
for len(queue) > 0 && visited < filterCorpusMaxDirs && len(results) < maxFiles {
|
||||||
|
dir := queue[0]
|
||||||
|
queue = queue[1:]
|
||||||
|
visited++
|
||||||
|
|
||||||
|
files, err := c.ListFiles(ctx, userID, dir)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, f := range files {
|
||||||
|
if isDirectoryEntry(f) {
|
||||||
|
child := normalizeSearchPath(f.Path)
|
||||||
|
if child == "" || child == "/" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := seen[child]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[child] = struct{}{}
|
||||||
|
queue = append(queue, child)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
results = append(results, f)
|
||||||
|
if len(results) >= maxFiles {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (c *Client) SetFavorite(ctx context.Context, userID, filePath string, favorite bool) error {
|
func (c *Client) SetFavorite(ctx context.Context, userID, filePath string, favorite bool) error {
|
||||||
filePath = normalizeOperationPath(userID, filePath)
|
filePath = normalizeOperationPath(userID, filePath)
|
||||||
davPath := c.WebDAVPath(userID, filePath)
|
davPath := c.WebDAVPath(userID, filePath)
|
||||||
@ -971,6 +1022,7 @@ func parsePropfindResponse(body io.Reader, listDir string) ([]FileInfo, error) {
|
|||||||
|
|
||||||
name := fileNameFromDAVProp(r.Propstat.Prop.DisplayName, r.Href)
|
name := fileNameFromDAVProp(r.Propstat.Prop.DisplayName, r.Href)
|
||||||
clientPath := ResolvePropfindClientPath(listDir, r.Href, name)
|
clientPath := ResolvePropfindClientPath(listDir, r.Href, name)
|
||||||
|
name = SyncFileDisplayName(clientPath, name)
|
||||||
|
|
||||||
fileType := "file"
|
fileType := "file"
|
||||||
if r.Propstat.Prop.ResourceType.Collection != nil {
|
if r.Propstat.Prop.ResourceType.Collection != nil {
|
||||||
|
|||||||
4
migrations/000027_attachment_drive_path.down.sql
Normal file
4
migrations/000027_attachment_drive_path.down.sql
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
DROP INDEX IF EXISTS idx_attachments_drive_path;
|
||||||
|
|
||||||
|
ALTER TABLE attachments
|
||||||
|
DROP COLUMN IF EXISTS drive_path;
|
||||||
6
migrations/000027_attachment_drive_path.up.sql
Normal file
6
migrations/000027_attachment_drive_path.up.sql
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
ALTER TABLE attachments
|
||||||
|
ADD COLUMN IF NOT EXISTS drive_path TEXT NOT NULL DEFAULT '';
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_attachments_drive_path
|
||||||
|
ON attachments (drive_path)
|
||||||
|
WHERE drive_path <> '';
|
||||||
1
migrations/000028_drive_file_sources.down.sql
Normal file
1
migrations/000028_drive_file_sources.down.sql
Normal file
@ -0,0 +1 @@
|
|||||||
|
DROP TABLE IF EXISTS drive_file_sources;
|
||||||
11
migrations/000028_drive_file_sources.up.sql
Normal file
11
migrations/000028_drive_file_sources.up.sql
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
CREATE TABLE drive_file_sources (
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
file_path TEXT NOT NULL,
|
||||||
|
source TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
PRIMARY KEY (user_id, file_path),
|
||||||
|
CONSTRAINT drive_file_sources_source_chk CHECK (source IN ('ultimail', 'ultimeet'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_drive_file_sources_user_source
|
||||||
|
ON drive_file_sources (user_id, source);
|
||||||
1
migrations/000029_api_tokens.down.sql
Normal file
1
migrations/000029_api_tokens.down.sql
Normal file
@ -0,0 +1 @@
|
|||||||
|
DROP TABLE IF EXISTS api_tokens;
|
||||||
19
migrations/000029_api_tokens.up.sql
Normal file
19
migrations/000029_api_tokens.up.sql
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
CREATE TABLE api_tokens (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
token_prefix TEXT NOT NULL,
|
||||||
|
secret_hash BYTEA NOT NULL,
|
||||||
|
permissions JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
mail_scope JSONB NOT NULL DEFAULT '{"all_accounts": true, "account_ids": []}'::jsonb,
|
||||||
|
drive_scope JSONB NOT NULL DEFAULT '{"all_folders": true, "folder_paths": []}'::jsonb,
|
||||||
|
expires_at TIMESTAMPTZ,
|
||||||
|
last_used_at TIMESTAMPTZ,
|
||||||
|
revoked_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX idx_api_tokens_prefix ON api_tokens(token_prefix) WHERE revoked_at IS NULL;
|
||||||
|
CREATE INDEX idx_api_tokens_user ON api_tokens(user_id);
|
||||||
|
CREATE INDEX idx_api_tokens_secret_hash ON api_tokens(secret_hash) WHERE revoked_at IS NULL;
|
||||||
Loading…
Reference in New Issue
Block a user