diff --git a/.env.example b/.env.example index 8887bd7..61f3a29 100644 --- a/.env.example +++ b/.env.example @@ -236,10 +236,8 @@ MAIL_MICROSOFT_OAUTH_CLIENT_SECRET= MAIL_MICROSOFT_OAUTH_TENANT=common MAIL_OAUTH_REDIRECT_URL= 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 -# 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 # ----------------------------------------------------------------------------- diff --git a/README.md b/README.md index 134a1e8..5571fc5 100644 --- a/README.md +++ b/README.md @@ -48,10 +48,7 @@ cp .env.example .env - 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/` -**Frontends** (stack + `pnpm dev` sur l’hôte, nginx route tout sur le port 80) : - -- **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/ +**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/ | Service | URL | |---------|-----| @@ -88,8 +85,7 @@ Un seul **nginx** expose l’entrée HTTP (`:80`) et route : | `/auth/*` | Authentik | | `/meet/*` | Jitsi (si `JITSI_ENABLED=true`) | | `/cloud/*` | Nextcloud nginx+FPM (si `NEXTCLOUD_ENABLED=true`) | -| `/mail/*` | Ultimail (`MAIL_FRONTEND_UPSTREAM`, défaut `host.docker.internal:3000`) | -| `/drive/*` | UltiDrive (`drive-suite`) | +| `/mail/*`, `/drive/*`, `/contacts` | Suite frontend (`MAIL_FRONTEND_UPSTREAM`, défaut `host.docker.internal:3000`) | 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). diff --git a/cmd/ultid/main.go b/cmd/ultid/main.go index 794ef81..967afca 100644 --- a/cmd/ultid/main.go +++ b/cmd/ultid/main.go @@ -19,11 +19,13 @@ import ( "github.com/redis/go-redis/v9" "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/contacts" "github.com/ultisuite/ulti-backend/internal/api/drive" "github.com/ultisuite/ulti-backend/internal/api/office" 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" meetapi "github.com/ultisuite/ulti-backend/internal/api/meet" "github.com/ultisuite/ulti-backend/internal/api/middleware" @@ -207,6 +209,8 @@ func main() { r.Use(observability.HTTPMetrics) r.Use(middleware.Logging) + r.Mount("/api/docs", docs.NewHandler().Routes()) + r.Get("/healthz", func(w http.ResponseWriter, r *http.Request) { report := healthChecker.Check(r.Context()) statusCode := http.StatusOK @@ -225,8 +229,9 @@ func main() { var driveHandler *drive.Handler var driveSvc *drive.Service if ncClient != nil { - driveSvc = drive.NewService(ncClient, hub) - driveHandler = drive.NewHandler(ncClient, hub) + driveSvc = drive.NewService(ncClient, hub, pool) + driveHandler = drive.NewHandlerWithService(driveSvc) + mailHandler.SetDriveUploader(&drivebridge.Bridge{Svc: driveSvc}) } if ncClient != nil && cfg.OnlyOfficeEnabled && driveSvc != nil { officeSvc := office.NewService(ncClient, office.Config{ @@ -246,6 +251,7 @@ func main() { r.Group(func(r chi.Router) { r.Use(middleware.Auth(verifierHolder, pool, auditLogger)) + r.Use(middleware.EnforceApiTokenPolicy()) r.Mount("/api/v1/mail", mailHandler.Routes()) r.Mount("/api/v1/admin", admin.NewHandler(pool, auditLogger).Routes()) diff --git a/deploy/authentik/blueprints/ulti-oidc.yaml b/deploy/authentik/blueprints/ulti-oidc.yaml index ad62de5..a5ac725 100644 --- a/deploy/authentik/blueprints/ulti-oidc.yaml +++ b/deploy/authentik/blueprints/ulti-oidc.yaml @@ -29,18 +29,10 @@ entries: url: http://localhost/api/auth/callback - matching_mode: strict 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 url: http://localhost:3000/api/auth/callback - matching_mode: strict 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]] - model: authentik_core.application diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 785f888..ec2395b 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -9,7 +9,6 @@ services: environment: DOMAIN: ${DOMAIN:-localhost} MAIL_FRONTEND_UPSTREAM: ${MAIL_FRONTEND_UPSTREAM:-host.docker.internal:3000} - DRIVE_FRONTEND_UPSTREAM: ${DRIVE_FRONTEND_UPSTREAM:-host.docker.internal:3001} env_file: ../.env.resolved extra_hosts: - "host.docker.internal:host-gateway" @@ -202,15 +201,15 @@ services: prometheus: condition: service_started - drive-suite: + suite-frontend: build: - context: ../../drive-suite + context: ../../gmail-interface-clone dockerfile: Dockerfile restart: unless-stopped environment: - ULTI_PROXY_ORIGIN=http://nginx - 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} - NEXT_PUBLIC_OIDC_ISSUER=http://${DOMAIN:-localhost}/auth/application/o/ulti/ - NEXT_PUBLIC_OIDC_CLIENT_ID=${ULTID_OIDC_CLIENT_ID:-ulti-backend} diff --git a/deploy/nginx/default.conf.template b/deploy/nginx/default.conf.template index 155b4c8..579cffa 100644 --- a/deploy/nginx/default.conf.template +++ b/deploy/nginx/default.conf.template @@ -157,92 +157,11 @@ server { return 301 /office/; } - # UltiDrive — variable proxy_pass must not include a URI path (passes client URI as-is). - 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; - } - + # UltiDrive — same suite frontend as mail (unified Next.js app) location /drive/ { resolver 127.0.0.11 valid=10s ipv6=off; - set $drive_upstream ${DRIVE_FRONTEND_UPSTREAM}; - proxy_pass http://$drive_upstream; + set $mail_upstream ${MAIL_FRONTEND_UPSTREAM}; + proxy_pass http://$mail_upstream; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; @@ -254,8 +173,8 @@ server { location = /drive { resolver 127.0.0.11 valid=10s ipv6=off; - set $drive_upstream ${DRIVE_FRONTEND_UPSTREAM}; - proxy_pass http://$drive_upstream; + set $mail_upstream ${MAIL_FRONTEND_UPSTREAM}; + proxy_pass http://$mail_upstream; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; @@ -265,8 +184,8 @@ server { proxy_set_header Connection $connection_upgrade; } - # Ultimail frontend — dev: pnpm dev on host (MAIL_FRONTEND_UPSTREAM=host.docker.internal:3000) - # Prod: set MAIL_FRONTEND_UPSTREAM=ultimail:3000 when the container exists. + # Ulti Suite frontend (mail + drive + contacts) — dev: pnpm dev on host (MAIL_FRONTEND_UPSTREAM=host.docker.internal:3000) + # Prod: set MAIL_FRONTEND_UPSTREAM=suite-frontend:3000 location ^~ /api/auth/ { resolver 127.0.0.11 valid=10s ipv6=off; set $mail_upstream ${MAIL_FRONTEND_UPSTREAM}; diff --git a/internal/api/docs/handler.go b/internal/api/docs/handler.go new file mode 100644 index 0000000..30eecc8 --- /dev/null +++ b/internal/api/docs/handler.go @@ -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 = ` + + + + + Ulti API — Documentation + + + + + + +` diff --git a/internal/api/docs/openapi.yaml b/internal/api/docs/openapi.yaml new file mode 100644 index 0000000..d1ec223 --- /dev/null +++ b/internal/api/docs/openapi.yaml @@ -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 ` | Interface web, apps avec login OIDC (Authentik) | + | **Token API** | `Authorization: Bearer ulti_` | 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 diff --git a/internal/api/drive/handlers.go b/internal/api/drive/handlers.go index 07fc2d9..cdcc66e 100644 --- a/internal/api/drive/handlers.go +++ b/internal/api/drive/handlers.go @@ -9,6 +9,7 @@ import ( "strings" "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" @@ -26,9 +27,13 @@ type Handler struct { 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{ - svc: NewService(nc, hub), + svc: svc, 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("/shared", h.ListSharedWithMe) 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/recipients/lookup", h.LookupShareRecipient) r.With(read).Get("/download/*", h.Download) @@ -95,13 +102,32 @@ func (h *Handler) ListFiles(w http.ResponseWriter, r *http.Request) { return } - path := chi.URLParam(r, "*") + path := nextcloud.NormalizeClientPath(chi.URLParam(r, "*")) result, err := h.svc.ListFiles(r.Context(), ncUser, path, params) if err != nil { h.logger.Error("list files", "error", err) apivalidate.WriteInternal(w, r) 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) } @@ -258,6 +284,9 @@ func (h *Handler) Move(w http.ResponseWriter, r *http.Request) { apivalidate.WriteValidationError(w, r, verr) 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 { 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) 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 { 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) return } + if middleware.DenyIfDrivePathOutOfScope(w, r, req.Path) { + return + } if err := h.svc.Rename(r.Context(), ncUser, req.Path, req.NewName); err != nil { h.logger.Error("rename", "error", err) @@ -333,6 +368,7 @@ func (h *Handler) ListTrash(w http.ResponseWriter, r *http.Request) { writeDriveError(w, r, err) return } + h.svc.EnrichSources(r.Context(), claims.Sub, result.Files) apiresponse.WriteJSON(w, http.StatusOK, result) } @@ -353,6 +389,7 @@ func (h *Handler) ListRecent(w http.ResponseWriter, r *http.Request) { writeDriveError(w, r, err) return } + h.svc.EnrichSources(r.Context(), claims.Sub, result.Files) apiresponse.WriteJSON(w, http.StatusOK, result) } @@ -374,6 +411,7 @@ func (h *Handler) ListStarred(w http.ResponseWriter, r *http.Request) { writeDriveError(w, r, err) return } + h.svc.EnrichSources(r.Context(), claims.Sub, result.Files) apiresponse.WriteJSON(w, http.StatusOK, result) } @@ -394,6 +432,7 @@ func (h *Handler) ListSharedWithMe(w http.ResponseWriter, r *http.Request) { writeDriveError(w, r, err) return } + h.svc.EnrichSources(r.Context(), claims.Sub, result.Files) apiresponse.WriteJSON(w, http.StatusOK, result) } @@ -465,6 +504,7 @@ func (h *Handler) Search(w http.ResponseWriter, r *http.Request) { writeDriveError(w, r, err) return } + h.svc.EnrichSources(r.Context(), claims.Sub, result.Files) apiresponse.WriteJSON(w, http.StatusOK, result) } diff --git a/internal/api/drive/service.go b/internal/api/drive/service.go index 8c791c1..12ed1f6 100644 --- a/internal/api/drive/service.go +++ b/internal/api/drive/service.go @@ -12,6 +12,8 @@ import ( "strings" "time" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/ultisuite/ulti-backend/internal/api/paginate" "github.com/ultisuite/ulti-backend/internal/api/query" "github.com/ultisuite/ulti-backend/internal/auth" @@ -30,14 +32,16 @@ var ( type Service struct { nc *nextcloud.Client hub *realtime.Hub + db *pgxpool.Pool maxUploadBytes 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{ nc: nc, hub: hub, + db: db, maxUploadBytes: envInt64("ULTID_DRIVE_MAX_UPLOAD_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)) } +// 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) { if s.hub == nil || platformUserID == "" { return @@ -69,6 +78,21 @@ type FilesList struct { 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) { if path == "" { path = "/" @@ -517,18 +541,26 @@ func (s *Service) ensureQuota(ctx context.Context, userID string, incomingBytes if err != nil { return mapDriveError(err) } - if incomingBytes <= 0 { - return nil - } - if quota.Free <= 0 { - return ErrQuotaExceeded - } - if incomingBytes+s.quotaReserveByte > quota.Free { + 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 { + return true + } + if free < 0 { + return true + } + if free == 0 { + return false + } + return incomingBytes+reserve <= free +} + func mapDriveError(err error) error { if err == nil { return nil diff --git a/internal/api/drive/service_quota_test.go b/internal/api/drive/service_quota_test.go new file mode 100644 index 0000000..8c787bd --- /dev/null +++ b/internal/api/drive/service_quota_test.go @@ -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") + } +} diff --git a/internal/api/drive/sources.go b/internal/api/drive/sources.go new file mode 100644 index 0000000..bc8c593 --- /dev/null +++ b/internal/api/drive/sources.go @@ -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 + } + } +} diff --git a/internal/api/mail/account_scope_sql.go b/internal/api/mail/account_scope_sql.go new file mode 100644 index 0000000..db98a64 --- /dev/null +++ b/internal/api/mail/account_scope_sql.go @@ -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 +} diff --git a/internal/api/mail/account_scope_sql_test.go b/internal/api/mail/account_scope_sql_test.go new file mode 100644 index 0000000..9fe503f --- /dev/null +++ b/internal/api/mail/account_scope_sql_test.go @@ -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") + } +} diff --git a/internal/api/mail/attachments.go b/internal/api/mail/attachments.go index e24e386..fb42f26 100644 --- a/internal/api/mail/attachments.go +++ b/internal/api/mail/attachments.go @@ -39,7 +39,7 @@ func (s *Service) ListMessageAttachments(ctx context.Context, externalID, messag } 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 ORDER BY created_at ASC `, messageID) @@ -50,10 +50,10 @@ func (s *Service) ListMessageAttachments(ctx context.Context, externalID, messag out := make([]map[string]any, 0) for rows.Next() { - var id, filename, contentType, contentID string + var id, filename, contentType, contentID, drivePath string var size int64 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 } entry := map[string]any{ @@ -63,6 +63,9 @@ func (s *Service) ListMessageAttachments(ctx context.Context, externalID, messag if contentID != "" { entry["content_id"] = contentID } + if drivePath != "" { + entry["drive_path"] = drivePath + } out = append(out, entry) } return out, rows.Err() diff --git a/internal/api/mail/drive_save.go b/internal/api/mail/drive_save.go new file mode 100644 index 0000000..4118962 --- /dev/null +++ b/internal/api/mail/drive_save.go @@ -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 +} diff --git a/internal/api/mail/drive_upload.go b/internal/api/mail/drive_upload.go new file mode 100644 index 0000000..ffb80ab --- /dev/null +++ b/internal/api/mail/drive_upload.go @@ -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") +} diff --git a/internal/api/mail/drivebridge/bridge.go b/internal/api/mail/drivebridge/bridge.go new file mode 100644 index 0000000..8925a7d --- /dev/null +++ b/internal/api/mail/drivebridge/bridge.go @@ -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 +} diff --git a/internal/api/mail/handlers.go b/internal/api/mail/handlers.go index 6cbec9e..a325d77 100644 --- a/internal/api/mail/handlers.go +++ b/internal/api/mail/handlers.go @@ -35,6 +35,13 @@ func (h *Handler) SetAccountSync(trigger AccountSyncTrigger) { 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 { return &Handler{ svc: svc, @@ -116,6 +123,8 @@ func (h *Handler) Routes() chi.Router { r.Get("/messages/{messageID}/attachments/cid-map", h.MessageAttachmentCIDMap) r.Post("/messages/{messageID}/attachments/reindex", h.ReindexMessageAttachments) 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.Get("/messages/{messageID}", h.GetMessage) r.Put("/messages/{messageID}/labels", h.UpdateLabels) @@ -145,6 +154,10 @@ func (h *Handler) Routes() chi.Router { r.Put("/webhooks/{webhookID}", h.UpdateWebhook) 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 } @@ -290,6 +303,7 @@ func (h *Handler) ListMessages(w http.ResponseWriter, r *http.Request) { Folder: r.URL.Query().Get("folder"), AccountID: r.URL.Query().Get("account_id"), } + h.applyMailListScope(&filter, r) result, err := h.svc.ListMessages(r.Context(), claims.Sub, filter, params) 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) { 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 errors.Is(err, ErrNotFound) { 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) 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) { apivalidate.WriteNotFound(w, r, "not found") return @@ -350,8 +372,12 @@ func (h *Handler) UpdateFlags(w http.ResponseWriter, r *http.Request) { apivalidate.WriteValidationError(w, r, verr) 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) { apivalidate.WriteNotFound(w, r, "not found") 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) { 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) { apivalidate.WriteNotFound(w, r, "not found") 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) { claims := middleware.ClaimsFromContext(r.Context()) messageID := chi.URLParam(r, "messageID") + if h.denyUnlessMessageInScope(w, r, messageID) { + return + } if h.mailSender == 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) { 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 { h.logger.Error("get thread", "error", err) apivalidate.WriteInternal(w, r) @@ -453,6 +490,9 @@ func (h *Handler) SendMessage(w http.ResponseWriter, r *http.Request) { apivalidate.WriteValidationError(w, r, verr) return } + if middleware.DenyIfMailAccountOutOfScope(w, r, req.AccountID) { + return + } id, status, err := h.svc.SendMessage(r.Context(), userID, &req) if err != nil { diff --git a/internal/api/mail/handlers_api_tokens.go b/internal/api/mail/handlers_api_tokens.go new file mode 100644 index 0000000..383c66a --- /dev/null +++ b/internal/api/mail/handlers_api_tokens.go @@ -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 +} diff --git a/internal/api/mail/handlers_attachments.go b/internal/api/mail/handlers_attachments.go index cd26ea8..0b0645b 100644 --- a/internal/api/mail/handlers_attachments.go +++ b/internal/api/mail/handlers_attachments.go @@ -13,13 +13,19 @@ import ( "github.com/ultisuite/ulti-backend/internal/api/apiresponse" "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/mail/limits" ) +const maxJSONRequestBody = 32 << 10 + func (h *Handler) ListMessageAttachments(w http.ResponseWriter, r *http.Request) { claims := middleware.ClaimsFromContext(r.Context()) messageID := chi.URLParam(r, "messageID") + if h.denyUnlessMessageInScope(w, r, messageID) { + return + } list, err := h.svc.ListMessageAttachments(r.Context(), claims.Sub, messageID) 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) { claims := middleware.ClaimsFromContext(r.Context()) messageID := chi.URLParam(r, "messageID") + if h.denyUnlessMessageInScope(w, r, messageID) { + return + } mapping, err := h.svc.MessageAttachmentCIDMap(r.Context(), claims.Sub, messageID) 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) { claims := middleware.ClaimsFromContext(r.Context()) messageID := chi.URLParam(r, "messageID") + if h.denyUnlessMessageInScope(w, r, messageID) { + return + } if h.accountSync == nil { apiresponse.WriteError(w, r, http.StatusServiceUnavailable, "sync_unavailable", "mail sync is not configured", nil) 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) { claims := middleware.ClaimsFromContext(r.Context()) messageID := chi.URLParam(r, "messageID") + if h.denyUnlessMessageInScope(w, r, messageID) { + return + } if err := r.ParseMultipartForm(limits.MaxMultipartUploadBytes); err != 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()) inline := strings.HasSuffix(r.URL.Path, "/inline") || r.URL.Query().Get("inline") == "true" 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) if err != nil { @@ -227,6 +245,95 @@ func (h *Handler) DownloadDraftAttachment(w http.ResponseWriter, r *http.Request _, _ = 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 { switch { case errors.Is(err, limits.ErrAttachmentTooLarge), errors.Is(err, limits.ErrAttachmentsTotalTooLarge): diff --git a/internal/api/mail/handlers_mail_scope.go b/internal/api/mail/handlers_mail_scope.go new file mode 100644 index 0000000..ddb95b7 --- /dev/null +++ b/internal/api/mail/handlers_mail_scope.go @@ -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 +} diff --git a/internal/api/mail/handlers_search.go b/internal/api/mail/handlers_search.go index 7eef19f..137c7f6 100644 --- a/internal/api/mail/handlers_search.go +++ b/internal/api/mail/handlers_search.go @@ -25,6 +25,7 @@ func (h *Handler) SearchMessages(w http.ResponseWriter, r *http.Request) { apivalidate.WriteValidationError(w, r, verr) return } + h.applyMailSearchScope(&filter, r) result, err := h.svc.SearchMessages(r.Context(), claims.Sub, filter, params) if err != nil { diff --git a/internal/api/mail/handlers_test.go b/internal/api/mail/handlers_test.go index e92c7d3..e672ffd 100644 --- a/internal/api/mail/handlers_test.go +++ b/internal/api/mail/handlers_test.go @@ -308,7 +308,22 @@ func (f *fakeMailService) DeleteAccount(context.Context, string, string) error { func (f *fakeMailService) ResanitizeAccountBodies(context.Context, string, string) (ResanitizeBodiesResult, error) { 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 } 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 } +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 { return newTestMailRouterWithClaims(svc, &auth.Claims{ Sub: testExternalID, diff --git a/internal/api/mail/search_advanced.go b/internal/api/mail/search_advanced.go index 5d82565..ccf585d 100644 --- a/internal/api/mail/search_advanced.go +++ b/internal/api/mail/search_advanced.go @@ -12,13 +12,14 @@ import ( ) type MessageSearchFilter struct { - Query string - Sender string - DateFrom *time.Time - DateTo *time.Time - HasAttachments *bool - Label string - AccountID string + Query string + Sender string + DateFrom *time.Time + DateTo *time.Time + HasAttachments *bool + Label string + AccountID string + ScopedAccountIDs []string } type MessageSearchResult struct { @@ -35,11 +36,7 @@ func (s *Service) SearchMessages(ctx context.Context, externalID string, filter args := []any{externalID} argIdx := 2 - if filter.AccountID != "" { - base += fmt.Sprintf(" AND m.account_id = $%d", argIdx) - args = append(args, filter.AccountID) - argIdx++ - } + base, args, argIdx = appendMessageAccountScope(base, args, argIdx, filter.AccountID, filter.ScopedAccountIDs) if filter.Sender != "" { base += fmt.Sprintf(" AND m.from_addr::text ILIKE '%%' || $%d || '%%'", argIdx) args = append(args, filter.Sender) diff --git a/internal/api/mail/service.go b/internal/api/mail/service.go index 89966f7..77b3eff 100644 --- a/internal/api/mail/service.go +++ b/internal/api/mail/service.go @@ -31,11 +31,12 @@ var ( ) type Service struct { - db *pgxpool.Pool + db *pgxpool.Pool // exported via DB() for api token handlers credentials *credentials.Manager audit *securityaudit.Logger storage *storage.Client attachmentsBucket string + driveUploader DriveUploader 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) { var userID string err := s.db.QueryRow(ctx, `SELECT id FROM users WHERE external_id = $1`, externalID).Scan(&userID) @@ -166,8 +171,9 @@ func (s *Service) DeleteAccount(ctx context.Context, externalID, accountID strin } type MessageListFilter struct { - Folder string - AccountID string + Folder string + AccountID string + ScopedAccountIDs []string } type MessagesList struct { @@ -185,11 +191,7 @@ func (s *Service) ListMessages(ctx context.Context, externalID string, filter Me args := []any{externalID} argIdx := 2 - if filter.AccountID != "" { - baseQuery += fmt.Sprintf(" AND m.account_id = $%d", argIdx) - args = append(args, filter.AccountID) - argIdx++ - } + baseQuery, args, argIdx = appendMessageAccountScope(baseQuery, args, argIdx, filter.AccountID, filter.ScopedAccountIDs) if clause, arg, ok := folderFilterClause(filter.Folder, argIdx); ok { baseQuery += clause args = append(args, arg) @@ -256,9 +258,63 @@ func (s *Service) ListMessages(ctx context.Context, externalID string, filter Me }, 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) { var msg struct { ID string + AccountID string MessageID string ThreadID *string InReplyTo string @@ -276,13 +332,13 @@ func (s *Service) GetMessage(ctx context.Context, externalID, messageID string) Labels []string } 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.body_text, m.body_html, m.flags, m.labels 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( - &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.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) } 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), "reply_to": json.RawMessage(msg.ReplyTo), "auth_info": json.RawMessage(msg.AuthInfo), "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 } -func (s *Service) GetThread(ctx context.Context, externalID, threadID string) (map[string]any, error) { - rows, err := s.db.Query(ctx, ` - SELECT m.id, m.subject, m.from_addr, m.to_addrs, m.cc_addrs, m.date, m.snippet, m.flags, m.labels +func (s *Service) GetThread(ctx context.Context, externalID, threadID string, scopedAccountIDs []string) (map[string]any, error) { + base := ` + 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 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 { return nil, err } @@ -391,13 +456,15 @@ func (s *Service) GetThread(ctx context.Context, externalID, threadID string) (m var from, toAddrs, ccAddrs []byte var date any 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 } messages = append(messages, map[string]any{ "id": id, "subject": subject, "from": json.RawMessage(from), "to": json.RawMessage(toAddrs), "cc": json.RawMessage(ccAddrs), "date": date, "snippet": snippet, "flags": flags, "labels": labels, + "has_attachments": hasAttachments, }) } if err := rows.Err(); err != nil { diff --git a/internal/api/mail/service_iface.go b/internal/api/mail/service_iface.go index f631415..efea9c0 100644 --- a/internal/api/mail/service_iface.go +++ b/internal/api/mail/service_iface.go @@ -28,12 +28,15 @@ type ServiceAPI interface { DeleteAccount(ctx context.Context, externalID, accountID string) error ResanitizeAccountBodies(ctx context.Context, externalID, accountID string) (ResanitizeBodiesResult, 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) SendMailtoListUnsubscribe(ctx context.Context, externalID, messageID string, sender MailSender) (*listunsubscribe.Mailto, error) UpdateLabels(ctx context.Context, externalID, messageID string, labels []string) error UpdateFlags(ctx context.Context, externalID, messageID string, flags []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) 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) @@ -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) 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) + 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) diff --git a/internal/api/middleware/apitoken_policy.go b/internal/api/middleware/apitoken_policy.go new file mode 100644 index 0000000..ba2e8a6 --- /dev/null +++ b/internal/api/middleware/apitoken_policy.go @@ -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) + }) + } +} diff --git a/internal/api/middleware/apitoken_policy_test.go b/internal/api/middleware/apitoken_policy_test.go new file mode 100644 index 0000000..d46b0b5 --- /dev/null +++ b/internal/api/middleware/apitoken_policy_test.go @@ -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) + } +} diff --git a/internal/api/middleware/auth.go b/internal/api/middleware/auth.go index 67a9bbc..94c44cf 100644 --- a/internal/api/middleware/auth.go +++ b/internal/api/middleware/auth.go @@ -9,6 +9,7 @@ import ( "github.com/jackc/pgx/v5/pgxpool" "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/permission" "github.com/ultisuite/ulti-backend/internal/securityaudit" @@ -17,23 +18,14 @@ import ( 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 { return func(next http.Handler) http.Handler { 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") if header == "" { 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 } + 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) if err != nil { @@ -119,3 +165,12 @@ func ClaimsFromContext(ctx context.Context) *auth.Claims { claims, _ := ctx.Value(claimsKey).(*auth.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") +} diff --git a/internal/api/middleware/rbac.go b/internal/api/middleware/rbac.go index 8567ca0..4f3d6af 100644 --- a/internal/api/middleware/rbac.go +++ b/internal/api/middleware/rbac.go @@ -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 { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if ApiTokenFromContext(r.Context()) != nil { + next.ServeHTTP(w, r) + return + } claims := ClaimsFromContext(r.Context()) if claims == nil { apiresponse.WriteError(w, r, http.StatusUnauthorized, apiresponse.CodeAuthUnauthorized, "unauthorized", nil) diff --git a/internal/api/middleware/scope.go b/internal/api/middleware/scope.go new file mode 100644 index 0000000..423080a --- /dev/null +++ b/internal/api/middleware/scope.go @@ -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 +} diff --git a/internal/apitokens/policy.go b/internal/apitokens/policy.go new file mode 100644 index 0000000..74e0cfc --- /dev/null +++ b/internal/apitokens/policy.go @@ -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 "" +} diff --git a/internal/apitokens/policy_test.go b/internal/apitokens/policy_test.go new file mode 100644 index 0000000..4e3f27d --- /dev/null +++ b/internal/apitokens/policy_test.go @@ -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) + } +} diff --git a/internal/apitokens/scope.go b/internal/apitokens/scope.go new file mode 100644 index 0000000..7cdfeec --- /dev/null +++ b/internal/apitokens/scope.go @@ -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+"/") +} diff --git a/internal/apitokens/scope_test.go b/internal/apitokens/scope_test.go new file mode 100644 index 0000000..8431b22 --- /dev/null +++ b/internal/apitokens/scope_test.go @@ -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") + } +} diff --git a/internal/apitokens/tokens.go b/internal/apitokens/tokens.go new file mode 100644 index 0000000..ffbf42b --- /dev/null +++ b/internal/apitokens/tokens.go @@ -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 +} diff --git a/internal/apitokens/tokens_test.go b/internal/apitokens/tokens_test.go new file mode 100644 index 0000000..4edb1c3 --- /dev/null +++ b/internal/apitokens/tokens_test.go @@ -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") + } +} diff --git a/internal/authentik/catalog.go b/internal/authentik/catalog.go index 6e57430..c6e7651 100644 --- a/internal/authentik/catalog.go +++ b/internal/authentik/catalog.go @@ -101,14 +101,10 @@ func Catalog(cfg *config.Config) []AppSpec { func ultimailRedirectURIs(cfg *config.Config) []string { base := baseURL(cfg) mail := strings.TrimRight(cfg.MailAppURL, "/") - drive := strings.TrimRight(base+"/drive", "/") return uniqueURIs( mail+"/api/auth/callback", "http://localhost: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", ) } @@ -145,11 +141,10 @@ func immichRedirectURIs(cfg *config.Config) []string { func driveRedirectURIs(cfg *config.Config) []string { base := baseURL(cfg) - drive := strings.TrimRight(base+"/drive", "/") return uniqueURIs( - drive+"/api/auth/callback", - "http://localhost:3001/api/auth/callback", - "http://127.0.0.1:3001/api/auth/callback", + base+"/api/auth/callback", + "http://localhost:3000/api/auth/callback", + "http://127.0.0.1:3000/api/auth/callback", ) } diff --git a/internal/nextcloud/dav_path.go b/internal/nextcloud/dav_path.go index 97e0811..c95800f 100644 --- a/internal/nextcloud/dav_path.go +++ b/internal/nextcloud/dav_path.go @@ -141,6 +141,19 @@ func NormalizeClientFilePath(userID, path string) string { 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). func EnsureClientFilePath(path, name string) string { path = NormalizeClientPath(path) diff --git a/internal/nextcloud/dav_path_test.go b/internal/nextcloud/dav_path_test.go index d1d5b81..b43b882 100644 --- a/internal/nextcloud/dav_path_test.go +++ b/internal/nextcloud/dav_path_test.go @@ -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) { got := EnsureClientFilePath("/Documents", "report.pdf") want := "/Documents/report.pdf" diff --git a/internal/nextcloud/drive.go b/internal/nextcloud/drive.go index c859804..2a71c75 100644 --- a/internal/nextcloud/drive.go +++ b/internal/nextcloud/drive.go @@ -26,6 +26,7 @@ type FileInfo struct { FileID int64 `json:"file_id,omitempty"` IsFavorite bool `json:"is_favorite"` IsShared bool `json:"is_shared"` + Source string `json:"source,omitempty"` } type ShareInfo struct { @@ -357,9 +358,10 @@ func (c *Client) listRecentOCS(ctx context.Context, userID string, limit int) ([ NormalizeClientFilePath(userID, item.Path), item.Name, ) + name := SyncFileDisplayName(logicalPath, item.Name) files = append(files, FileInfo{ Path: logicalPath, - Name: item.Name, + Name: name, Type: fileType, Size: item.Size, MimeType: item.MimeType, @@ -443,6 +445,7 @@ func (c *Client) ListSharedWithMe(ctx context.Context, userID string) ([]FileInf NormalizeClientFilePath(userID, item.Path), name, ) + name = SyncFileDisplayName(logicalPath, name) files = append(files, FileInfo{ Path: logicalPath, Name: name, @@ -600,8 +603,10 @@ func (c *Client) EmptyTrash(ctx context.Context, userID string) error { } const ( - favoritesMaxDirs = 2000 - favoritesMaxCollect = 500 + favoritesMaxDirs = 2000 + favoritesMaxCollect = 500 + filterCorpusMaxDirs = 2000 + filterCorpusMaxFiles = 10000 ) 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 } +// 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 { filePath = normalizeOperationPath(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) clientPath := ResolvePropfindClientPath(listDir, r.Href, name) + name = SyncFileDisplayName(clientPath, name) fileType := "file" if r.Propstat.Prop.ResourceType.Collection != nil { diff --git a/migrations/000027_attachment_drive_path.down.sql b/migrations/000027_attachment_drive_path.down.sql new file mode 100644 index 0000000..51acb8a --- /dev/null +++ b/migrations/000027_attachment_drive_path.down.sql @@ -0,0 +1,4 @@ +DROP INDEX IF EXISTS idx_attachments_drive_path; + +ALTER TABLE attachments + DROP COLUMN IF EXISTS drive_path; diff --git a/migrations/000027_attachment_drive_path.up.sql b/migrations/000027_attachment_drive_path.up.sql new file mode 100644 index 0000000..634653d --- /dev/null +++ b/migrations/000027_attachment_drive_path.up.sql @@ -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 <> ''; diff --git a/migrations/000028_drive_file_sources.down.sql b/migrations/000028_drive_file_sources.down.sql new file mode 100644 index 0000000..97c1e6b --- /dev/null +++ b/migrations/000028_drive_file_sources.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS drive_file_sources; diff --git a/migrations/000028_drive_file_sources.up.sql b/migrations/000028_drive_file_sources.up.sql new file mode 100644 index 0000000..fd6b152 --- /dev/null +++ b/migrations/000028_drive_file_sources.up.sql @@ -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); diff --git a/migrations/000029_api_tokens.down.sql b/migrations/000029_api_tokens.down.sql new file mode 100644 index 0000000..a844841 --- /dev/null +++ b/migrations/000029_api_tokens.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS api_tokens; diff --git a/migrations/000029_api_tokens.up.sql b/migrations/000029_api_tokens.up.sql new file mode 100644 index 0000000..b19e254 --- /dev/null +++ b/migrations/000029_api_tokens.up.sql @@ -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;