From bda75aeb0d6cc2623e85b918bc7c6183f665af0a Mon Sep 17 00:00:00 2001 From: R3D347HR4Y Date: Sat, 13 Jun 2026 20:38:26 +0200 Subject: [PATCH] feat(config): enhance AI gateway and model management features - Updated .env.example to include new configuration options for AI gateway and WebUI secret key. - Modified Nginx configuration to support additional API routes for model management and migration. - Implemented new API endpoints for discovering organization-level LLM models and managing hosted mail services. - Enhanced AI gateway logic to support organization-specific model access and permissions. - Improved error handling and response structures in the AI and mail APIs. - Added integration tests for new features and updated existing tests for model access control. --- .env.example | 4 + CLAUDE.md | 64 +++ deploy/nginx/default.conf.template | 535 +++++++++++++++++- deploy/openwebui/docker-compose.openwebui.yml | 4 +- internal/ai/gateway.go | 20 +- internal/ai/models.go | 70 +++ internal/ai/models_test.go | 55 ++ internal/ai/providers.go | 47 ++ internal/ai/types.go | 23 +- internal/api/admin/handlers.go | 25 + internal/api/admin/org_llm.go | 92 +++ internal/api/admin/org_settings.go | 4 + internal/api/ai/handlers.go | 152 ++++- internal/api/mail/handlers.go | 17 +- internal/api/mail/handlers_hosted.go | 154 +++++ internal/config/config.go | 2 + internal/mail/hosted/service.go | 68 +++ internal/server/bootstrap.go | 1 + 18 files changed, 1301 insertions(+), 36 deletions(-) create mode 100644 CLAUDE.md create mode 100644 internal/ai/models.go create mode 100644 internal/ai/models_test.go create mode 100644 internal/api/admin/org_llm.go diff --git a/.env.example b/.env.example index 3c0dde4..28751cd 100644 --- a/.env.example +++ b/.env.example @@ -174,6 +174,10 @@ RICHTEXT_STORAGE_MODE=sidecar # ----------------------------------------------------------------------------- AI_ASSISTANT_ENABLED=false OPENWEBUI_URL=http://openwebui:8080 +WEBUI_SECRET_KEY=changeme-openwebui-dev-secret +AI_GATEWAY_API_KEY=ulti-gateway +# OpenWebUI embed : modèles ultid visibles sans entrée DB par modèle +BYPASS_MODEL_ACCESS_CONTROL=true AI_ASSISTANT_PUBLIC_PATH=/ai ULTIMAIL_MCP_URL=http://ultimail-mcp:3100 # OpenWebUI utilise POSTGRES_USER/POSTGRES_PASSWORD (base openwebui créée dans init-db.sh) diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..583d49c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,64 @@ +# ulti-backend (ultid) + +API monolithique Go — mail, drive, contacts, admin, UltiAI gateway, etc. + +--- + +## Environnement local + +- Config : `.env` (test local, gitignored) → `./deploy/compose-up.sh` génère `.env.resolved` pour Compose +- Entrée HTTP dev : nginx `:80` → `/api/v1/*` → `ultid` +- Frontend dev : repo `gmail-interface-clone`, proxy `ULTI_PROXY_ORIGIN=http://127.0.0.1:80` + +--- + +## Agents — redémarrage obligatoire + +**Ne jamais demander au développeur de redémarrer.** L'agent relance le service quand c'est nécessaire. + +### Quand redémarrer `ultid` + +- Modification code Go (`internal/`, `cmd/`, routes, handlers, services) +- Nouvelle route ou changement de comportement API +- Changement `.env` / `.env.resolved` consommé au boot +- Après implémentation feature backend à tester côté frontend + +### Commandes (depuis la racine de ce repo) + +```bash +# Rebuild + restart (après changement code Go) +./deploy/compose-up.sh up -d --build ultid + +# Restart sans rebuild (rare) +./deploy/compose-up.sh restart ultid + +# Logs +./deploy/compose-up.sh logs ultid --tail 50 +``` + +### Vérification + +```bash +curl -s http://127.0.0.1:80/api/v1/ai/config +``` + +### Autres services Compose + +Modules optionnels (OpenWebUI, Nextcloud, …) : `./deploy/compose-up.sh up -d` relit `.env` et active les overlays. + +--- + +## Dev hors Docker + +```bash +go run ./cmd/ultid # nécessite PG, KeyDB, RustFS, Authentik déjà up +``` + +En pratique l'environnement dev utilise Docker pour `ultid`. + +--- + +## Docs + +- `README.md` — architecture, compose, observabilité +- `.env.example` — variables documentées diff --git a/deploy/nginx/default.conf.template b/deploy/nginx/default.conf.template index 9041d01..b81b241 100644 --- a/deploy/nginx/default.conf.template +++ b/deploy/nginx/default.conf.template @@ -18,7 +18,390 @@ server { client_max_body_size 10G; - # ultid API (must stay after ^~ /api/auth/ — mail OIDC routes) + # --- ultid /api/v1/* (before OpenWebUI catch-alls; ^~ beats prefix /api/) --- + location ^~ /api/v1/mail/ { + resolver 127.0.0.11 valid=10s ipv6=off; + set $ultid_upstream ultid:8080; + proxy_hide_header Access-Control-Allow-Origin; + proxy_hide_header Access-Control-Allow-Methods; + proxy_hide_header Access-Control-Allow-Headers; + proxy_hide_header Access-Control-Expose-Headers; + proxy_hide_header Access-Control-Max-Age; + proxy_hide_header Access-Control-Allow-Credentials; + proxy_hide_header Vary; + add_header Access-Control-Allow-Origin $cors_allow_origin always; + add_header Access-Control-Allow-Methods "GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS" always; + add_header Access-Control-Allow-Headers "Accept, Authorization, Content-Type, Idempotency-Key, Origin, X-Requested-With, X-Trace-Id" always; + add_header Access-Control-Expose-Headers "X-Trace-Id" always; + add_header Access-Control-Max-Age 300 always; + add_header Vary Origin always; + proxy_pass http://$ultid_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 ^~ /api/v1/migration/ { + resolver 127.0.0.11 valid=10s ipv6=off; + set $ultid_upstream ultid:8080; + proxy_hide_header Access-Control-Allow-Origin; + proxy_hide_header Access-Control-Allow-Methods; + proxy_hide_header Access-Control-Allow-Headers; + proxy_hide_header Access-Control-Expose-Headers; + proxy_hide_header Access-Control-Max-Age; + proxy_hide_header Access-Control-Allow-Credentials; + proxy_hide_header Vary; + add_header Access-Control-Allow-Origin $cors_allow_origin always; + add_header Access-Control-Allow-Methods "GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS" always; + add_header Access-Control-Allow-Headers "Accept, Authorization, Content-Type, Idempotency-Key, Origin, X-Requested-With, X-Trace-Id" always; + add_header Access-Control-Expose-Headers "X-Trace-Id" always; + add_header Access-Control-Max-Age 300 always; + add_header Vary Origin always; + proxy_pass http://$ultid_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 ^~ /api/v1/admin/ { + resolver 127.0.0.11 valid=10s ipv6=off; + set $ultid_upstream ultid:8080; + proxy_hide_header Access-Control-Allow-Origin; + proxy_hide_header Access-Control-Allow-Methods; + proxy_hide_header Access-Control-Allow-Headers; + proxy_hide_header Access-Control-Expose-Headers; + proxy_hide_header Access-Control-Max-Age; + proxy_hide_header Access-Control-Allow-Credentials; + proxy_hide_header Vary; + add_header Access-Control-Allow-Origin $cors_allow_origin always; + add_header Access-Control-Allow-Methods "GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS" always; + add_header Access-Control-Allow-Headers "Accept, Authorization, Content-Type, Idempotency-Key, Origin, X-Requested-With, X-Trace-Id" always; + add_header Access-Control-Expose-Headers "X-Trace-Id" always; + add_header Access-Control-Max-Age 300 always; + add_header Vary Origin always; + proxy_pass http://$ultid_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 ^~ /api/v1/drive/ { + resolver 127.0.0.11 valid=10s ipv6=off; + set $ultid_upstream ultid:8080; + proxy_hide_header Access-Control-Allow-Origin; + proxy_hide_header Access-Control-Allow-Methods; + proxy_hide_header Access-Control-Allow-Headers; + proxy_hide_header Access-Control-Expose-Headers; + proxy_hide_header Access-Control-Max-Age; + proxy_hide_header Access-Control-Allow-Credentials; + proxy_hide_header Vary; + add_header Access-Control-Allow-Origin $cors_allow_origin always; + add_header Access-Control-Allow-Methods "GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS" always; + add_header Access-Control-Allow-Headers "Accept, Authorization, Content-Type, Idempotency-Key, Origin, X-Requested-With, X-Trace-Id" always; + add_header Access-Control-Expose-Headers "X-Trace-Id" always; + add_header Access-Control-Max-Age 300 always; + add_header Vary Origin always; + proxy_pass http://$ultid_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 ^~ /api/v1/office/ { + resolver 127.0.0.11 valid=10s ipv6=off; + set $ultid_upstream ultid:8080; + proxy_hide_header Access-Control-Allow-Origin; + proxy_hide_header Access-Control-Allow-Methods; + proxy_hide_header Access-Control-Allow-Headers; + proxy_hide_header Access-Control-Expose-Headers; + proxy_hide_header Access-Control-Max-Age; + proxy_hide_header Access-Control-Allow-Credentials; + proxy_hide_header Vary; + add_header Access-Control-Allow-Origin $cors_allow_origin always; + add_header Access-Control-Allow-Methods "GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS" always; + add_header Access-Control-Allow-Headers "Accept, Authorization, Content-Type, Idempotency-Key, Origin, X-Requested-With, X-Trace-Id" always; + add_header Access-Control-Expose-Headers "X-Trace-Id" always; + add_header Access-Control-Max-Age 300 always; + add_header Vary Origin always; + proxy_pass http://$ultid_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 ^~ /api/v1/richtext/ { + resolver 127.0.0.11 valid=10s ipv6=off; + set $ultid_upstream ultid:8080; + proxy_hide_header Access-Control-Allow-Origin; + proxy_hide_header Access-Control-Allow-Methods; + proxy_hide_header Access-Control-Allow-Headers; + proxy_hide_header Access-Control-Expose-Headers; + proxy_hide_header Access-Control-Max-Age; + proxy_hide_header Access-Control-Allow-Credentials; + proxy_hide_header Vary; + add_header Access-Control-Allow-Origin $cors_allow_origin always; + add_header Access-Control-Allow-Methods "GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS" always; + add_header Access-Control-Allow-Headers "Accept, Authorization, Content-Type, Idempotency-Key, Origin, X-Requested-With, X-Trace-Id" always; + add_header Access-Control-Expose-Headers "X-Trace-Id" always; + add_header Access-Control-Max-Age 300 always; + add_header Vary Origin always; + proxy_pass http://$ultid_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 ^~ /api/v1/ultidraw/ { + resolver 127.0.0.11 valid=10s ipv6=off; + set $ultid_upstream ultid:8080; + proxy_hide_header Access-Control-Allow-Origin; + proxy_hide_header Access-Control-Allow-Methods; + proxy_hide_header Access-Control-Allow-Headers; + proxy_hide_header Access-Control-Expose-Headers; + proxy_hide_header Access-Control-Max-Age; + proxy_hide_header Access-Control-Allow-Credentials; + proxy_hide_header Vary; + add_header Access-Control-Allow-Origin $cors_allow_origin always; + add_header Access-Control-Allow-Methods "GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS" always; + add_header Access-Control-Allow-Headers "Accept, Authorization, Content-Type, Idempotency-Key, Origin, X-Requested-With, X-Trace-Id" always; + add_header Access-Control-Expose-Headers "X-Trace-Id" always; + add_header Access-Control-Max-Age 300 always; + add_header Vary Origin always; + proxy_pass http://$ultid_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 ^~ /api/v1/ai/ { + resolver 127.0.0.11 valid=10s ipv6=off; + set $ultid_upstream ultid:8080; + proxy_hide_header Access-Control-Allow-Origin; + proxy_hide_header Access-Control-Allow-Methods; + proxy_hide_header Access-Control-Allow-Headers; + proxy_hide_header Access-Control-Expose-Headers; + proxy_hide_header Access-Control-Max-Age; + proxy_hide_header Access-Control-Allow-Credentials; + proxy_hide_header Vary; + add_header Access-Control-Allow-Origin $cors_allow_origin always; + add_header Access-Control-Allow-Methods "GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS" always; + add_header Access-Control-Allow-Headers "Accept, Authorization, Content-Type, Idempotency-Key, Origin, X-Requested-With, X-Trace-Id" always; + add_header Access-Control-Expose-Headers "X-Trace-Id" always; + add_header Access-Control-Max-Age 300 always; + add_header Vary Origin always; + proxy_pass http://$ultid_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 ^~ /api/v1/users/me { + resolver 127.0.0.11 valid=10s ipv6=off; + set $ultid_upstream ultid:8080; + proxy_hide_header Access-Control-Allow-Origin; + proxy_hide_header Access-Control-Allow-Methods; + proxy_hide_header Access-Control-Allow-Headers; + proxy_hide_header Access-Control-Expose-Headers; + proxy_hide_header Access-Control-Max-Age; + proxy_hide_header Access-Control-Allow-Credentials; + proxy_hide_header Vary; + add_header Access-Control-Allow-Origin $cors_allow_origin always; + add_header Access-Control-Allow-Methods "GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS" always; + add_header Access-Control-Allow-Headers "Accept, Authorization, Content-Type, Idempotency-Key, Origin, X-Requested-With, X-Trace-Id" always; + add_header Access-Control-Expose-Headers "X-Trace-Id" always; + add_header Access-Control-Max-Age 300 always; + add_header Vary Origin always; + proxy_pass http://$ultid_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 ^~ /api/v1/calendar/ { + resolver 127.0.0.11 valid=10s ipv6=off; + set $ultid_upstream ultid:8080; + proxy_hide_header Access-Control-Allow-Origin; + proxy_hide_header Access-Control-Allow-Methods; + proxy_hide_header Access-Control-Allow-Headers; + proxy_hide_header Access-Control-Expose-Headers; + proxy_hide_header Access-Control-Max-Age; + proxy_hide_header Access-Control-Allow-Credentials; + proxy_hide_header Vary; + add_header Access-Control-Allow-Origin $cors_allow_origin always; + add_header Access-Control-Allow-Methods "GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS" always; + add_header Access-Control-Allow-Headers "Accept, Authorization, Content-Type, Idempotency-Key, Origin, X-Requested-With, X-Trace-Id" always; + add_header Access-Control-Expose-Headers "X-Trace-Id" always; + add_header Access-Control-Max-Age 300 always; + add_header Vary Origin always; + proxy_pass http://$ultid_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 ^~ /api/v1/contacts/ { + resolver 127.0.0.11 valid=10s ipv6=off; + set $ultid_upstream ultid:8080; + proxy_hide_header Access-Control-Allow-Origin; + proxy_hide_header Access-Control-Allow-Methods; + proxy_hide_header Access-Control-Allow-Headers; + proxy_hide_header Access-Control-Expose-Headers; + proxy_hide_header Access-Control-Max-Age; + proxy_hide_header Access-Control-Allow-Credentials; + proxy_hide_header Vary; + add_header Access-Control-Allow-Origin $cors_allow_origin always; + add_header Access-Control-Allow-Methods "GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS" always; + add_header Access-Control-Allow-Headers "Accept, Authorization, Content-Type, Idempotency-Key, Origin, X-Requested-With, X-Trace-Id" always; + add_header Access-Control-Expose-Headers "X-Trace-Id" always; + add_header Access-Control-Max-Age 300 always; + add_header Vary Origin always; + proxy_pass http://$ultid_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 ^~ /api/v1/meet/ { + resolver 127.0.0.11 valid=10s ipv6=off; + set $ultid_upstream ultid:8080; + proxy_hide_header Access-Control-Allow-Origin; + proxy_hide_header Access-Control-Allow-Methods; + proxy_hide_header Access-Control-Allow-Headers; + proxy_hide_header Access-Control-Expose-Headers; + proxy_hide_header Access-Control-Max-Age; + proxy_hide_header Access-Control-Allow-Credentials; + proxy_hide_header Vary; + add_header Access-Control-Allow-Origin $cors_allow_origin always; + add_header Access-Control-Allow-Methods "GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS" always; + add_header Access-Control-Allow-Headers "Accept, Authorization, Content-Type, Idempotency-Key, Origin, X-Requested-With, X-Trace-Id" always; + add_header Access-Control-Expose-Headers "X-Trace-Id" always; + add_header Access-Control-Max-Age 300 always; + add_header Vary Origin always; + proxy_pass http://$ultid_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 ^~ /api/v1/photos/ { + resolver 127.0.0.11 valid=10s ipv6=off; + set $ultid_upstream ultid:8080; + proxy_hide_header Access-Control-Allow-Origin; + proxy_hide_header Access-Control-Allow-Methods; + proxy_hide_header Access-Control-Allow-Headers; + proxy_hide_header Access-Control-Expose-Headers; + proxy_hide_header Access-Control-Max-Age; + proxy_hide_header Access-Control-Allow-Credentials; + proxy_hide_header Vary; + add_header Access-Control-Allow-Origin $cors_allow_origin always; + add_header Access-Control-Allow-Methods "GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS" always; + add_header Access-Control-Allow-Headers "Accept, Authorization, Content-Type, Idempotency-Key, Origin, X-Requested-With, X-Trace-Id" always; + add_header Access-Control-Expose-Headers "X-Trace-Id" always; + add_header Access-Control-Max-Age 300 always; + add_header Vary Origin always; + proxy_pass http://$ultid_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 ^~ /api/v1/search { + resolver 127.0.0.11 valid=10s ipv6=off; + set $ultid_upstream ultid:8080; + proxy_hide_header Access-Control-Allow-Origin; + proxy_hide_header Access-Control-Allow-Methods; + proxy_hide_header Access-Control-Allow-Headers; + proxy_hide_header Access-Control-Expose-Headers; + proxy_hide_header Access-Control-Max-Age; + proxy_hide_header Access-Control-Allow-Credentials; + proxy_hide_header Vary; + add_header Access-Control-Allow-Origin $cors_allow_origin always; + add_header Access-Control-Allow-Methods "GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS" always; + add_header Access-Control-Allow-Headers "Accept, Authorization, Content-Type, Idempotency-Key, Origin, X-Requested-With, X-Trace-Id" always; + add_header Access-Control-Expose-Headers "X-Trace-Id" always; + add_header Access-Control-Max-Age 300 always; + add_header Vary Origin always; + proxy_pass http://$ultid_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; + } + + # OpenWebUI trusted-header signin — ultid injects X-Ulti-User-* (auth_request + POST body deadlocks) + location = /api/v1/auths/signin { + resolver 127.0.0.11 valid=10s ipv6=off; + set $ultid_upstream ultid:8080; + proxy_hide_header Access-Control-Allow-Origin; + proxy_hide_header Access-Control-Allow-Methods; + proxy_hide_header Access-Control-Allow-Headers; + proxy_hide_header Access-Control-Expose-Headers; + proxy_hide_header Access-Control-Max-Age; + proxy_hide_header Access-Control-Allow-Credentials; + proxy_hide_header Vary; + add_header Access-Control-Allow-Origin $cors_allow_origin always; + add_header Access-Control-Allow-Methods "GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS" always; + add_header Access-Control-Allow-Headers "Accept, Authorization, Content-Type, Idempotency-Key, Origin, X-Requested-With, X-Trace-Id" always; + add_header Access-Control-Expose-Headers "X-Trace-Id" always; + add_header Access-Control-Max-Age 300 always; + add_header Vary Origin always; + proxy_pass http://$ultid_upstream/api/v1/ai/embed-signin; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header Cookie $http_cookie; + proxy_set_header Authorization $http_authorization; + 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; + } + + # --- OpenWebUI API at site root (prod SPA: WEBUI_BASE_URL="" → fetch("/api/…")) --- + # No auth_request here: POST + auth_request deadlocks (embed-auth timeout → 500). + # Ultimail gate is /ai/ HTML; OpenWebUI API auth uses its own JWT (signin via embed-signin). + location ~ ^/api/(config|changelog|version|webhook|usage|models|embeddings|message|chat|tasks)(/|$) { + resolver 127.0.0.11 valid=10s ipv6=off; + set $openwebui_upstream openwebui:8080; + proxy_pass http://$openwebui_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; + proxy_read_timeout 86400s; + proxy_send_timeout 86400s; + } + location ~ ^/api/v1/(auths|channels|chats|notes|models|knowledge|prompts|tools|skills|memories|folders|groups|files|functions|evaluations|analytics|utils|terminals|automations|calendars|scim|pipelines|tasks|images|audio|retrieval|configs|users|chat|embeddings|messages)(/|$) { + resolver 127.0.0.11 valid=10s ipv6=off; + set $openwebui_upstream openwebui:8080; + proxy_pass http://$openwebui_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; + proxy_read_timeout 86400s; + proxy_send_timeout 86400s; + } + + # ultid API fallback (must stay after ^~ /api/auth/ — mail OIDC routes) location /api/ { resolver 127.0.0.11 valid=10s ipv6=off; set $ultid_upstream ultid:8080; @@ -46,6 +429,22 @@ server { proxy_set_header X-Forwarded-Proto $scheme; } + # OpenWebUI socket.io (ultid uses /ws without /socket.io) + location ^~ /ws/socket.io { + resolver 127.0.0.11 valid=10s ipv6=off; + set $openwebui_upstream openwebui:8080; + proxy_pass http://$openwebui_upstream; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + 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_read_timeout 86400s; + proxy_send_timeout 86400s; + } + location /ws { resolver 127.0.0.11 valid=10s ipv6=off; set $ultid_ws_upstream ultid:8080; @@ -65,6 +464,28 @@ server { proxy_set_header X-Forwarded-Proto $scheme; } + # OpenWebUI Ollama/OpenAI proxy mounts (SPA uses /ollama, /openai at root) + location ^~ /ollama/ { + resolver 127.0.0.11 valid=10s ipv6=off; + set $openwebui_upstream openwebui:8080; + proxy_pass http://$openwebui_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 ^~ /openai/ { + resolver 127.0.0.11 valid=10s ipv6=off; + set $openwebui_upstream openwebui:8080; + proxy_pass http://$openwebui_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; + } + # TipTap / Hocuspocus — proxy WS without redirect (301 breaks upgrade) location /collab { resolver 127.0.0.11 valid=10s ipv6=off; @@ -194,6 +615,7 @@ server { 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 X-Forwarded-Prefix /ai; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; proxy_set_header X-Ulti-User-Email $ulti_user_email; @@ -201,12 +623,123 @@ server { proxy_set_header X-Ulti-User-Role $ulti_user_role; proxy_read_timeout 86400s; proxy_send_timeout 86400s; + + # OpenWebUI serves root-relative /static, /_app, /api — prefix for subpath /ai/ + proxy_set_header Accept-Encoding ""; + sub_filter_once off; + sub_filter_types text/html text/css application/javascript application/json text/javascript; + sub_filter 'href="/static/' 'href="/ai/static/'; + sub_filter 'src="/static/' 'src="/ai/static/'; + sub_filter 'href="/_app/' 'href="/ai/_app/'; + sub_filter 'src="/_app/' 'src="/ai/_app/'; + sub_filter '"/_app/' '"/ai/_app/'; + sub_filter "'/_app/" "'/ai/_app/"; + sub_filter '"/static/' '"/ai/static/'; + sub_filter "'/static/" "'/ai/static/"; + sub_filter '"/api/' '"/ai/api/'; + sub_filter "'/api/" "'/ai/api/"; + sub_filter 'href="/manifest.json' 'href="/ai/manifest.json'; + sub_filter '"/manifest.json' '"/ai/manifest.json'; + # SvelteKit base path — without this, /ai/ routes 404 (base "" expects site root) + sub_filter 'base: ""' 'base: "/ai"'; + sub_filter "base: ''" 'base: "/ai"'; } location = /ai { return 301 /ai/; } + # OpenWebUI API (prefix /ai/api — évite collision avec ultid /api/v1/) + location /ai/api/ { + resolver 127.0.0.11 valid=10s ipv6=off; + set $openwebui_upstream openwebui:8080; + + rewrite ^/ai/api/?(.*)$ /api/$1 break; + proxy_pass http://$openwebui_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; + proxy_read_timeout 86400s; + proxy_send_timeout 86400s; + } + + # OpenWebUI assets (SPA uses root-relative /static, /_app — not /ai/…) + location ^~ /static/ { + resolver 127.0.0.11 valid=10s ipv6=off; + set $openwebui_upstream openwebui:8080; + + auth_request /api/v1/ai/embed-auth; + auth_request_set $ulti_user_email $upstream_http_x_ulti_user_email; + auth_request_set $ulti_user_name $upstream_http_x_ulti_user_name; + auth_request_set $ulti_user_role $upstream_http_x_ulti_user_role; + + proxy_pass http://$openwebui_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 X-Ulti-User-Email $ulti_user_email; + proxy_set_header X-Ulti-User-Name $ulti_user_name; + proxy_set_header X-Ulti-User-Role $ulti_user_role; + + proxy_set_header Accept-Encoding ""; + sub_filter_once off; + sub_filter_types application/javascript text/javascript; + sub_filter '"/api/' '"/ai/api/'; + sub_filter "'/api/" "'/ai/api/"; + } + + location ^~ /_app/ { + resolver 127.0.0.11 valid=10s ipv6=off; + set $openwebui_upstream openwebui:8080; + + auth_request /api/v1/ai/embed-auth; + auth_request_set $ulti_user_email $upstream_http_x_ulti_user_email; + auth_request_set $ulti_user_name $upstream_http_x_ulti_user_name; + auth_request_set $ulti_user_role $upstream_http_x_ulti_user_role; + + proxy_pass http://$openwebui_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 X-Ulti-User-Email $ulti_user_email; + proxy_set_header X-Ulti-User-Name $ulti_user_name; + proxy_set_header X-Ulti-User-Role $ulti_user_role; + + proxy_set_header Accept-Encoding ""; + sub_filter_once off; + sub_filter_types application/javascript text/javascript application/json; + sub_filter '"/api/' '"/ai/api/'; + sub_filter "'/api/" "'/ai/api/"; + } + + location = /manifest.json { + resolver 127.0.0.11 valid=10s ipv6=off; + set $openwebui_upstream openwebui:8080; + + auth_request /api/v1/ai/embed-auth; + auth_request_set $ulti_user_email $upstream_http_x_ulti_user_email; + auth_request_set $ulti_user_name $upstream_http_x_ulti_user_name; + auth_request_set $ulti_user_role $upstream_http_x_ulti_user_role; + + proxy_pass http://$openwebui_upstream/manifest.json; + 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 X-Ulti-User-Email $ulti_user_email; + proxy_set_header X-Ulti-User-Name $ulti_user_name; + proxy_set_header X-Ulti-User-Role $ulti_user_role; + } + location /office/ { resolver 127.0.0.11 valid=10s ipv6=off; set $oo_upstream onlyoffice; diff --git a/deploy/openwebui/docker-compose.openwebui.yml b/deploy/openwebui/docker-compose.openwebui.yml index 8192a72..967f3a2 100644 --- a/deploy/openwebui/docker-compose.openwebui.yml +++ b/deploy/openwebui/docker-compose.openwebui.yml @@ -10,9 +10,11 @@ services: WEBUI_AUTH_TRUSTED_ROLE_HEADER: X-Ulti-User-Role ENABLE_PERSISTENT_CONFIG: "false" ENABLE_DIRECT_CONNECTIONS: "false" + BYPASS_MODEL_ACCESS_CONTROL: "true" OPENAI_API_BASE_URL: http://ultid:8080/api/v1/ai - OPENAI_API_KEY: ulti-gateway + OPENAI_API_KEY: ${AI_GATEWAY_API_KEY:-ulti-gateway} WEBUI_URL: http://${DOMAIN:-localhost}/ai + WEBUI_SECRET_KEY: ${WEBUI_SECRET_KEY:-changeme-openwebui-dev-secret} DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/openwebui USER_PERMISSIONS_CHAT_TEMPORARY_ENFORCED: "false" volumes: diff --git a/internal/ai/gateway.go b/internal/ai/gateway.go index 8520edd..c680ab0 100644 --- a/internal/ai/gateway.go +++ b/internal/ai/gateway.go @@ -68,6 +68,19 @@ func (g *Gateway) ListModels(ctx context.Context, externalUserID string) ([]map[ if err != nil { return nil, err } + return g.listModelsFromSettings(ctx, settings) +} + +func (g *Gateway) ListOrgModels(ctx context.Context) ([]map[string]any, error) { + settings, err := LoadOrgLLMSettings(ctx, g.db) + if err != nil { + return nil, err + } + return g.listModelsFromSettings(ctx, settings) +} + +func (g *Gateway) listModelsFromSettings(ctx context.Context, settings llm.Settings) ([]map[string]any, error) { + policy, _ := LoadAssistantPolicy(ctx, g.db) client := llm.NewClient() seen := make(map[string]struct{}) out := make([]map[string]any, 0) @@ -99,7 +112,7 @@ func (g *Gateway) ListModels(ctx context.Context, externalUserID string) ([]map[ }) } } - return out, nil + return ApplyModelCatalog(out, policy.Models), nil } func (g *Gateway) ProxyChatCompletions(ctx context.Context, externalUserID string, body []byte, w http.ResponseWriter) error { @@ -112,6 +125,11 @@ func (g *Gateway) ProxyChatCompletions(ctx context.Context, externalUserID strin return fmt.Errorf("invalid chat completion request: %w", err) } + policy, _ := LoadAssistantPolicy(ctx, g.db) + if !IsModelAllowed(req.Model, policy.Models) { + return fmt.Errorf("model %q is not allowed", strings.TrimSpace(req.Model)) + } + settings, err := LoadEffectiveLLMSettings(ctx, g.db, externalUserID) if err != nil { return err diff --git a/internal/ai/models.go b/internal/ai/models.go new file mode 100644 index 0000000..cbada94 --- /dev/null +++ b/internal/ai/models.go @@ -0,0 +1,70 @@ +package ai + +import "strings" + +func ApplyModelCatalog(upstream []map[string]any, catalog []ModelCatalogEntry) []map[string]any { + if len(catalog) == 0 { + return upstream + } + + allowed := make(map[string]ModelCatalogEntry) + for _, entry := range catalog { + id := strings.TrimSpace(entry.ModelID) + if id == "" || !entry.Enabled { + continue + } + allowed[id] = entry + } + if len(allowed) == 0 { + return []map[string]any{} + } + + out := make([]map[string]any, 0, len(allowed)) + seen := make(map[string]struct{}, len(allowed)) + for _, model := range upstream { + id, _ := model["id"].(string) + id = strings.TrimSpace(id) + entry, ok := allowed[id] + if !ok { + continue + } + seen[id] = struct{}{} + out = append(out, catalogModel(id, entry.Label, model["owned_by"])) + } + for id, entry := range allowed { + if _, ok := seen[id]; ok { + continue + } + out = append(out, catalogModel(id, entry.Label, nil)) + } + return out +} + +func IsModelAllowed(model string, catalog []ModelCatalogEntry) bool { + model = strings.TrimSpace(model) + if model == "" || len(catalog) == 0 { + return true + } + for _, entry := range catalog { + if strings.TrimSpace(entry.ModelID) == model { + return entry.Enabled + } + } + return false +} + +func catalogModel(id, label string, ownedBy any) map[string]any { + display := strings.TrimSpace(label) + if display == "" { + display = id + } + out := map[string]any{ + "id": id, + "object": "model", + "label": display, + } + if ownedBy != nil { + out["owned_by"] = ownedBy + } + return out +} diff --git a/internal/ai/models_test.go b/internal/ai/models_test.go new file mode 100644 index 0000000..ae62640 --- /dev/null +++ b/internal/ai/models_test.go @@ -0,0 +1,55 @@ +package ai + +import "testing" + +func TestApplyModelCatalogEmptyAllowsAll(t *testing.T) { + upstream := []map[string]any{ + {"id": "gpt-4o", "object": "model"}, + {"id": "llama3", "object": "model"}, + } + got := ApplyModelCatalog(upstream, nil) + if len(got) != 2 { + t.Fatalf("expected 2 models, got %d", len(got)) + } +} + +func TestApplyModelCatalogAllowlist(t *testing.T) { + upstream := []map[string]any{ + {"id": "gpt-4o", "object": "model", "owned_by": "OpenAI"}, + {"id": "llama3", "object": "model", "owned_by": "Local"}, + } + catalog := []ModelCatalogEntry{ + {ModelID: "gpt-4o", Label: "GPT-4o", Enabled: true}, + {ModelID: "llama3", Label: "Llama 3", Enabled: false}, + {ModelID: "manual-model", Label: "Manuel", Enabled: true}, + } + got := ApplyModelCatalog(upstream, catalog) + if len(got) != 2 { + t.Fatalf("expected 2 allowed models, got %d", len(got)) + } + if got[0]["label"] != "GPT-4o" { + t.Fatalf("expected label GPT-4o, got %v", got[0]["label"]) + } + if got[1]["id"] != "manual-model" { + t.Fatalf("expected manual-model, got %v", got[1]["id"]) + } +} + +func TestIsModelAllowed(t *testing.T) { + catalog := []ModelCatalogEntry{ + {ModelID: "gpt-4o", Enabled: true}, + {ModelID: "llama3", Enabled: false}, + } + if !IsModelAllowed("gpt-4o", catalog) { + t.Fatal("gpt-4o should be allowed") + } + if IsModelAllowed("llama3", catalog) { + t.Fatal("llama3 should be blocked") + } + if IsModelAllowed("unknown", catalog) { + t.Fatal("unknown model should be blocked when catalog is set") + } + if !IsModelAllowed("unknown", nil) { + t.Fatal("unknown model should be allowed without catalog") + } +} diff --git a/internal/ai/providers.go b/internal/ai/providers.go index c8d9cbe..803fff5 100644 --- a/internal/ai/providers.go +++ b/internal/ai/providers.go @@ -70,6 +70,15 @@ func orgToSettings(org orgLLMPolicy) llm.Settings { } } +// LoadOrgLLMSettings returns org-level LLM provider configuration. +func LoadOrgLLMSettings(ctx context.Context, db *pgxpool.Pool) (llm.Settings, error) { + org, err := loadOrgLLMPolicy(ctx, db) + if err != nil { + return llm.Settings{}, err + } + return orgToSettings(org), nil +} + func loadOrgLLMPolicy(ctx context.Context, db *pgxpool.Pool) (orgLLMPolicy, error) { var raw []byte err := db.QueryRow(ctx, ` @@ -114,6 +123,44 @@ func loadUserLLMSettings(ctx context.Context, db *pgxpool.Pool, externalUserID s return out, nil } +func IsAssistantEnabled(ctx context.Context, db *pgxpool.Pool, deployEnabled bool) (AssistantPolicy, bool) { + policy, err := LoadAssistantPolicy(ctx, db) + if err != nil { + policy = AssistantPolicy{} + } + enabled := policy.Enabled || deployEnabled || isPluginEnabled(ctx, db, "ai-assistant") + return policy, enabled +} + +func isPluginEnabled(ctx context.Context, db *pgxpool.Pool, pluginID string) bool { + if db == nil { + return false + } + var raw []byte + err := db.QueryRow(ctx, ` + SELECT settings->'plugins' FROM org_settings WHERE id = $1 + `, orgSettingsSingletonID).Scan(&raw) + if err != nil { + return false + } + if len(raw) == 0 || string(raw) == "null" { + return false + } + var plugins []struct { + ID string `json:"id"` + Enabled bool `json:"enabled"` + } + if err := json.Unmarshal(raw, &plugins); err != nil { + return false + } + for _, plugin := range plugins { + if plugin.ID == pluginID { + return plugin.Enabled + } + } + return false +} + func LoadAssistantPolicy(ctx context.Context, db *pgxpool.Pool) (AssistantPolicy, error) { defaults := AssistantPolicy{ Enabled: false, diff --git a/internal/ai/types.go b/internal/ai/types.go index 44e2e75..f27e497 100644 --- a/internal/ai/types.go +++ b/internal/ai/types.go @@ -2,15 +2,22 @@ package ai import "time" +type ModelCatalogEntry struct { + ModelID string `json:"model_id"` + Label string `json:"label"` + Enabled bool `json:"enabled"` +} + type AssistantPolicy struct { - Enabled bool `json:"enabled"` - OpenWebUIInternalURL string `json:"openwebui_internal_url"` - PublicPath string `json:"public_path"` - EmbedDefaultTemporary bool `json:"embed_default_temporary"` - DefaultModel string `json:"default_model"` - EnabledTools []string `json:"enabled_tools"` - ChatSyncEnabled bool `json:"chat_sync_enabled"` - ChatNCPath string `json:"chat_nc_path"` + Enabled bool `json:"enabled"` + OpenWebUIInternalURL string `json:"openwebui_internal_url"` + PublicPath string `json:"public_path"` + EmbedDefaultTemporary bool `json:"embed_default_temporary"` + DefaultModel string `json:"default_model"` + EnabledTools []string `json:"enabled_tools"` + ChatSyncEnabled bool `json:"chat_sync_enabled"` + ChatNCPath string `json:"chat_nc_path"` + Models []ModelCatalogEntry `json:"models,omitempty"` } type QuotaLimits struct { diff --git a/internal/api/admin/handlers.go b/internal/api/admin/handlers.go index 65c96a4..f5436be 100644 --- a/internal/api/admin/handlers.go +++ b/internal/api/admin/handlers.go @@ -69,6 +69,7 @@ func (h *Handler) Routes() chi.Router { r.With(read).Get("/org/settings", h.GetOrgSettings) r.With(write).Put("/org/settings", h.PutOrgSettings) + r.With(read).Post("/org/llm/discover-models", h.DiscoverOrgLLMModels) r.With(read).Get("/org/identity-providers/redirect-uri/{slug}", h.GetIdentityProviderRedirectURI) r.With(write).Post("/org/identity-providers/{providerID}/test", h.TestIdentityProvider) @@ -408,3 +409,27 @@ func (h *Handler) PutOrgSettings(w http.ResponseWriter, r *http.Request) { } apiresponse.WriteJSON(w, http.StatusOK, payload) } + +type discoverOrgLLMModelsRequest struct { + ProviderID string `json:"provider_id"` +} + +func (h *Handler) DiscoverOrgLLMModels(w http.ResponseWriter, r *http.Request) { + var req discoverOrgLLMModelsRequest + if err := apivalidate.DecodeJSON(w, r, 1<<20, &req); err != nil { + return + } + if strings.TrimSpace(req.ProviderID) == "" { + apivalidate.WriteValidationError(w, r, apivalidate.NewValidationError( + apivalidate.FieldDetail{Field: "provider_id", Message: "required"}, + )) + return + } + models, err := h.svc.DiscoverOrgLLMModels(r.Context(), req.ProviderID) + if err != nil { + h.logger.Error("discover org llm models", "error", err, "provider_id", req.ProviderID) + apiresponse.WriteError(w, r, http.StatusBadGateway, "llm_models_unavailable", err.Error(), nil) + return + } + apiresponse.WriteJSON(w, http.StatusOK, map[string]any{"models": models}) +} diff --git a/internal/api/admin/org_llm.go b/internal/api/admin/org_llm.go new file mode 100644 index 0000000..4882dd4 --- /dev/null +++ b/internal/api/admin/org_llm.go @@ -0,0 +1,92 @@ +package admin + +import ( + "context" + "fmt" + "strings" + + "github.com/ultisuite/ulti-backend/internal/llm" +) + +func (s *Service) DiscoverOrgLLMModels(ctx context.Context, providerID string) ([]string, error) { + providerID = strings.TrimSpace(providerID) + if providerID == "" { + return nil, fmt.Errorf("provider_id is required") + } + + stored, _, _, err := s.loadOrgPolicyRaw(ctx) + if err != nil { + return nil, err + } + policy := mergeMaps(defaultOrgPolicy(), stored) + llmSection, _ := policy["llm"].(map[string]any) + providers, _ := llmSection["providers"].([]any) + + var provider llm.Provider + found := false + for _, item := range providers { + pm, ok := item.(map[string]any) + if !ok { + continue + } + id, _ := pm["id"].(string) + if id != providerID { + continue + } + provider = llm.Provider{ + ID: id, + Name: stringValueMap(pm, "name"), + BaseURL: stringValueMap(pm, "base_url"), + APIKey: stringValueMap(pm, "api_key"), + DefaultModel: stringValueMap(pm, "default_model"), + } + found = true + break + } + if !found { + return nil, fmt.Errorf("llm provider not found") + } + if strings.TrimSpace(provider.BaseURL) == "" { + return nil, fmt.Errorf("llm provider base_url is required") + } + + client := llm.NewClient() + models, err := client.ListModels(ctx, provider) + if err != nil { + return nil, err + } + if models == nil { + models = []string{} + } + return models, nil +} + +func buildLLMProviderSecretsStatus(policy map[string]any) map[string]any { + llmSection, ok := policy["llm"].(map[string]any) + if !ok { + return nil + } + providers, ok := llmSection["providers"].([]any) + if !ok { + return nil + } + out := map[string]any{} + for _, item := range providers { + pm, ok := item.(map[string]any) + if !ok { + continue + } + id, _ := pm["id"].(string) + if strings.TrimSpace(id) == "" { + continue + } + apiKey, _ := pm["api_key"].(string) + out[id] = map[string]any{ + "configured": strings.TrimSpace(apiKey) != "", + } + } + if len(out) == 0 { + return nil + } + return out +} diff --git a/internal/api/admin/org_settings.go b/internal/api/admin/org_settings.go index 1744355..7daa02a 100644 --- a/internal/api/admin/org_settings.go +++ b/internal/api/admin/org_settings.go @@ -122,6 +122,7 @@ func defaultOrgPolicy() map[string]any { "enabled_tools": []any{"mail", "drive", "contacts", "search"}, "chat_sync_enabled": true, "chat_nc_path": "/.ultimail/ai/chats", + "models": []any{}, }, "agenda": map[string]any{ "default_theme_mode": "system", @@ -695,6 +696,9 @@ func buildOrgSecretsStatus(policy map[string]any, cfg *config.Config) map[string if idpSecrets := buildIdentityProviderSecretsStatus(policy); len(idpSecrets) > 0 { secrets["identity_providers"] = idpSecrets } + if llmSecrets := buildLLMProviderSecretsStatus(policy); len(llmSecrets) > 0 { + secrets["llm_providers"] = llmSecrets + } return secrets } diff --git a/internal/api/ai/handlers.go b/internal/api/ai/handlers.go index 763d254..2a07c7c 100644 --- a/internal/api/ai/handlers.go +++ b/internal/api/ai/handlers.go @@ -1,6 +1,7 @@ package aiapi import ( + "bytes" "encoding/json" "errors" "io" @@ -18,6 +19,7 @@ import ( "github.com/ultisuite/ulti-backend/internal/auth" "github.com/ultisuite/ulti-backend/internal/config" "github.com/ultisuite/ulti-backend/internal/nextcloud" + "github.com/ultisuite/ulti-backend/internal/permission" ) const sessionAccessCookie = "ulti_access_token" @@ -46,12 +48,14 @@ func (h *Handler) Routes(authMiddleware func(http.Handler) http.Handler) chi.Rou r := chi.NewRouter() r.Get("/config", h.GetConfig) r.Get("/embed-auth", h.EmbedAuth) + r.Post("/embed-signin", h.EmbedSignin) + // OpenWebUI gateway (Bearer AI_GATEWAY_API_KEY) or user JWT — not behind Auth middleware + r.Get("/models", h.ListModels) + r.Post("/chat/completions", h.ChatCompletions) + r.Post("/v1/chat/completions", h.ChatCompletions) r.Group(func(r chi.Router) { r.Use(authMiddleware) r.Get("/quota", h.GetQuota) - r.Get("/models", h.ListModels) - r.Post("/chat/completions", h.ChatCompletions) - r.Post("/v1/chat/completions", h.ChatCompletions) r.Post("/sessions", h.CreateSession) r.Get("/chats/{chatID}", h.GetChat) r.Delete("/chats/{chatID}", h.DeleteChat) @@ -61,11 +65,8 @@ func (h *Handler) Routes(authMiddleware func(http.Handler) http.Handler) chi.Rou } func (h *Handler) GetConfig(w http.ResponseWriter, r *http.Request) { - policy, err := ai.LoadAssistantPolicy(r.Context(), h.db) - if err != nil { - apiresponse.WriteError(w, r, http.StatusInternalServerError, apiresponse.CodeInternal, "failed to load ai config", nil) - return - } + deployEnabled := h.cfg != nil && h.cfg.AIAssistantEnabled + policy, enabled := ai.IsAssistantEnabled(r.Context(), h.db, deployEnabled) publicPath := policy.PublicPath if strings.TrimSpace(publicPath) == "" { publicPath = "/ai" @@ -73,30 +74,107 @@ func (h *Handler) GetConfig(w http.ResponseWriter, r *http.Request) { if h.cfg != nil && strings.TrimSpace(h.cfg.AIAssistantPublicPath) != "" { publicPath = h.cfg.AIAssistantPublicPath } + models := make([]map[string]any, 0, len(policy.Models)) + for _, entry := range policy.Models { + models = append(models, map[string]any{ + "model_id": entry.ModelID, + "label": entry.Label, + "enabled": entry.Enabled, + }) + } apiresponse.WriteJSON(w, http.StatusOK, map[string]any{ - "enabled": policy.Enabled || (h.cfg != nil && h.cfg.AIAssistantEnabled), + "enabled": enabled, "public_path": publicPath, "embed_default_temporary": policy.EmbedDefaultTemporary, "default_model": policy.DefaultModel, "enabled_tools": policy.EnabledTools, "chat_sync_enabled": policy.ChatSyncEnabled, + "models": models, + "restrict_models": len(policy.Models) > 0, }) } func (h *Handler) EmbedAuth(w http.ResponseWriter, r *http.Request) { claims, ok := h.resolveClaims(r) - if !ok { + if !ok || strings.TrimSpace(claims.Email) == "" { w.WriteHeader(http.StatusUnauthorized) return } + h.writeTrustedUserHeaders(w, claims) + w.WriteHeader(http.StatusOK) +} + +// EmbedSignin proxies OpenWebUI trusted-header signin (nginx routes /api/v1/auths/signin here). +func (h *Handler) EmbedSignin(w http.ResponseWriter, r *http.Request) { + claims, ok := h.resolveClaims(r) + if !ok || strings.TrimSpace(claims.Email) == "" { + apiresponse.WriteError(w, r, http.StatusUnauthorized, apiresponse.CodeAuthUnauthorized, "unauthorized", nil) + return + } + + baseURL := strings.TrimRight(strings.TrimSpace(h.cfg.OpenWebUIInternalURL), "/") + if baseURL == "" { + baseURL = "http://openwebui:8080" + } + + body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) + if err != nil { + apiresponse.WriteError(w, r, http.StatusBadRequest, apiresponse.CodeInvalidRequest, "invalid body", nil) + return + } + if len(body) == 0 { + body = []byte(`{"email":"","password":""}`) + } + + upstreamReq, err := http.NewRequestWithContext(r.Context(), http.MethodPost, baseURL+"/api/v1/auths/signin", bytes.NewReader(body)) + if err != nil { + apiresponse.WriteError(w, r, http.StatusInternalServerError, apiresponse.CodeInternal, err.Error(), nil) + return + } + contentType := strings.TrimSpace(r.Header.Get("Content-Type")) + if contentType == "" { + contentType = "application/json" + } + upstreamReq.Header.Set("Content-Type", contentType) + upstreamReq.Header.Set("X-Ulti-User-Email", claims.Email) + name := strings.TrimSpace(claims.Name) + if name == "" { + name = claims.Email + } + upstreamReq.Header.Set("X-Ulti-User-Name", name) + upstreamReq.Header.Set("X-Ulti-User-Role", openWebUIRole(claims)) + + resp, err := http.DefaultClient.Do(upstreamReq) + if err != nil { + apiresponse.WriteError(w, r, http.StatusBadGateway, apiresponse.CodeInternal, err.Error(), nil) + return + } + defer resp.Body.Close() + + for k, vals := range resp.Header { + for _, v := range vals { + w.Header().Add(k, v) + } + } + w.WriteHeader(resp.StatusCode) + _, _ = io.Copy(w, io.LimitReader(resp.Body, 8<<20)) +} + +func (h *Handler) writeTrustedUserHeaders(w http.ResponseWriter, claims *auth.Claims) { w.Header().Set("X-Ulti-User-Email", claims.Email) if strings.TrimSpace(claims.Name) != "" { w.Header().Set("X-Ulti-User-Name", claims.Name) } else { w.Header().Set("X-Ulti-User-Name", claims.Email) } - w.Header().Set("X-Ulti-User-Role", "user") - w.WriteHeader(http.StatusOK) + w.Header().Set("X-Ulti-User-Role", openWebUIRole(claims)) +} + +func openWebUIRole(claims *auth.Claims) string { + if claims != nil && permission.HasRole(claims.Groups, permission.RoleAdmin) { + return "admin" + } + return "user" } func (h *Handler) GetQuota(w http.ResponseWriter, r *http.Request) { @@ -114,12 +192,20 @@ func (h *Handler) GetQuota(w http.ResponseWriter, r *http.Request) { } func (h *Handler) ListModels(w http.ResponseWriter, r *http.Request) { - claims := middleware.ClaimsFromContext(r.Context()) - if claims == nil { + externalUserID, useOrg, ok := h.resolveAIAccess(r) + if !ok { apiresponse.WriteError(w, r, http.StatusUnauthorized, apiresponse.CodeAuthUnauthorized, "unauthorized", nil) return } - models, err := h.gateway.ListModels(r.Context(), claims.Sub) + var ( + models []map[string]any + err error + ) + if useOrg { + models, err = h.gateway.ListOrgModels(r.Context()) + } else { + models, err = h.gateway.ListModels(r.Context(), externalUserID) + } if err != nil { apiresponse.WriteError(w, r, http.StatusBadRequest, apiresponse.CodeInvalidRequest, err.Error(), nil) return @@ -131,8 +217,8 @@ func (h *Handler) ListModels(w http.ResponseWriter, r *http.Request) { } func (h *Handler) ChatCompletions(w http.ResponseWriter, r *http.Request) { - claims := middleware.ClaimsFromContext(r.Context()) - if claims == nil { + externalUserID, useOrg, ok := h.resolveAIAccess(r) + if !ok { apiresponse.WriteError(w, r, http.StatusUnauthorized, apiresponse.CodeAuthUnauthorized, "unauthorized", nil) return } @@ -141,7 +227,11 @@ func (h *Handler) ChatCompletions(w http.ResponseWriter, r *http.Request) { apiresponse.WriteError(w, r, http.StatusBadRequest, apiresponse.CodeInvalidRequest, "invalid body", nil) return } - if err := h.gateway.ProxyChatCompletions(r.Context(), claims.Sub, body, w); err != nil { + subject := externalUserID + if useOrg { + subject = "openwebui-gateway" + } + if err := h.gateway.ProxyChatCompletions(r.Context(), subject, body, w); err != nil { if errors.Is(err, ai.ErrQuotaExceeded) { apiresponse.WriteError(w, r, http.StatusTooManyRequests, apiresponse.CodeRateLimited, err.Error(), nil) return @@ -263,6 +353,32 @@ func (h *Handler) DeleteChat(w http.ResponseWriter, r *http.Request) { apiresponse.WriteJSON(w, http.StatusOK, map[string]any{"ok": true}) } +func (h *Handler) resolveAIAccess(r *http.Request) (externalUserID string, useOrgSettings bool, ok bool) { + if token := bearerToken(r); token != "" { + if h.cfg != nil && h.cfg.AIGatewayAPIKey != "" && token == h.cfg.AIGatewayAPIKey { + return "", true, true + } + if h.verify != nil && h.verify.Ready() { + claims, err := h.verify.Verify(r.Context(), token) + if err == nil && claims != nil && strings.TrimSpace(claims.Sub) != "" { + return claims.Sub, false, true + } + } + } + if claims := middleware.ClaimsFromContext(r.Context()); claims != nil && strings.TrimSpace(claims.Sub) != "" { + return claims.Sub, false, true + } + return "", false, false +} + +func bearerToken(r *http.Request) string { + header := strings.TrimSpace(r.Header.Get("Authorization")) + if !strings.HasPrefix(header, "Bearer ") { + return "" + } + return strings.TrimSpace(strings.TrimPrefix(header, "Bearer ")) +} + func (h *Handler) resolveClaims(r *http.Request) (*auth.Claims, bool) { if header := strings.TrimSpace(r.Header.Get("Authorization")); strings.HasPrefix(header, "Bearer ") { token := strings.TrimSpace(strings.TrimPrefix(header, "Bearer ")) diff --git a/internal/api/mail/handlers.go b/internal/api/mail/handlers.go index c503586..7c1d5f4 100644 --- a/internal/api/mail/handlers.go +++ b/internal/api/mail/handlers.go @@ -22,13 +22,14 @@ import ( ) type Handler struct { - svc ServiceAPI - mailSender MailSender - logger *slog.Logger - sendLimiter *sendguard.RateLimiter - oauth *mailoauth.Service - appURL string - accountSync AccountSyncTrigger + svc ServiceAPI + mailSender MailSender + logger *slog.Logger + sendLimiter *sendguard.RateLimiter + oauth *mailoauth.Service + appURL string + accountSync AccountSyncTrigger + hostedPlatformDomain string } // SetAccountSync wires the IMAP sync worker for on-demand account sync. @@ -96,6 +97,8 @@ func (h *Handler) Routes() chi.Router { r.Get("/accounts/oauth/providers", h.ListOAuthProviders) r.Post("/accounts/oauth/start", h.StartOAuthAccount) r.Get("/addresses/check", h.CheckAddressAvailability) + r.Get("/hosted/status", h.GetHostedMailStatus) + r.Post("/hosted/setup", h.SetupHostedMailbox) r.Get("/accounts/{accountID}", h.GetAccount) r.Put("/accounts/{accountID}", h.UpdateAccount) r.Delete("/accounts/{accountID}", h.DeleteAccount) diff --git a/internal/api/mail/handlers_hosted.go b/internal/api/mail/handlers_hosted.go index d51d91c..cee384f 100644 --- a/internal/api/mail/handlers_hosted.go +++ b/internal/api/mail/handlers_hosted.go @@ -1,11 +1,16 @@ package mail import ( + "errors" "net/http" "strings" + "github.com/jackc/pgx/v5" + "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/api/query" "github.com/ultisuite/ulti-backend/internal/mail/hosted" ) @@ -15,6 +20,10 @@ func (h *Handler) SetHostedService(svc *hosted.Service) { } } +func (h *Handler) SetHostedPlatformDomain(domain string) { + h.hostedPlatformDomain = strings.ToLower(strings.TrimSpace(domain)) +} + func (h *Handler) CheckAddressAvailability(w http.ResponseWriter, r *http.Request) { svc := h.hostedService() if svc == nil { @@ -26,6 +35,9 @@ func (h *Handler) CheckAddressAvailability(w http.ResponseWriter, r *http.Reques if domain == "" { domain = strings.TrimSpace(r.URL.Query().Get("domain_name")) } + if domain == "" { + domain = h.hostedPlatformDomain + } if local == "" || domain == "" { apivalidate.WriteValidationError(w, r, apivalidate.NewValidationError(apivalidate.FieldDetail{ Field: "local", Message: "local and domain required", @@ -40,6 +52,148 @@ func (h *Handler) CheckAddressAvailability(w http.ResponseWriter, r *http.Reques apiresponse.WriteJSON(w, http.StatusOK, map[string]any{"available": available}) } +func (h *Handler) GetHostedMailStatus(w http.ResponseWriter, r *http.Request) { + svc := h.hostedService() + if svc == nil || !svc.Available() { + apiresponse.WriteJSON(w, http.StatusOK, map[string]any{"enabled": false}) + return + } + + ctx := r.Context() + domainName := h.hostedPlatformDomain + domainStatus := "" + if domain, err := svc.GetPlatformDomain(ctx); err == nil { + domainName = domain.Name + domainStatus = domain.Status + } + if domainName == "" { + apiresponse.WriteJSON(w, http.StatusOK, map[string]any{"enabled": false}) + return + } + + resp := map[string]any{ + "enabled": true, + "platform_domain": domainName, + "domain_status": domainStatus, + "endpoints": svc.Endpoints(), + } + + claims := middleware.ClaimsFromContext(ctx) + if claims != nil { + userID, err := h.svc.ResolveUserID(ctx, claims.Sub) + if err == nil { + if mailbox, err := svc.GetUserMailbox(ctx, userID); err == nil { + resp["mailbox"] = mailbox + } else if !errors.Is(err, pgx.ErrNoRows) { + h.logger.Error("hosted mailbox lookup", "error", err) + } + accounts, listErr := h.svc.ListAccounts(ctx, claims.Sub, query.ListParams{}) + if listErr == nil { + for _, acct := range accounts.Accounts { + provider, _ := acct["provider"].(string) + if provider == "hosted" { + resp["hosted_mail_account_id"] = acct["id"] + resp["hosted_mail_account_email"] = acct["email"] + break + } + } + } + } + } + + apiresponse.WriteJSON(w, http.StatusOK, resp) +} + +type setupHostedMailboxRequest struct { + LocalPart string `json:"local_part"` + Password string `json:"password"` + DisplayName string `json:"display_name"` +} + +func (h *Handler) SetupHostedMailbox(w http.ResponseWriter, r *http.Request) { + svc := h.hostedService() + if svc == nil || !svc.Available() { + apiresponse.WriteError(w, r, http.StatusServiceUnavailable, "hosted_mail_disabled", "hosted mail is not configured", nil) + return + } + + var req setupHostedMailboxRequest + if err := apivalidate.DecodeJSON(w, r, 16*1024, &req); err != nil { + return + } + localPart := strings.TrimSpace(req.LocalPart) + password := req.Password + if localPart == "" { + apivalidate.WriteValidationError(w, r, apivalidate.NewValidationError( + apivalidate.FieldDetail{Field: "local_part", Message: "local part required"}, + )) + return + } + if len(password) < 8 { + apivalidate.WriteValidationError(w, r, apivalidate.NewValidationError( + apivalidate.FieldDetail{Field: "password", Message: "password must be at least 8 characters"}, + )) + return + } + + domainName := h.hostedPlatformDomain + if domain, err := svc.GetPlatformDomain(r.Context()); err == nil { + domainName = domain.Name + } + if domainName == "" { + apiresponse.WriteError(w, r, http.StatusServiceUnavailable, "hosted_mail_disabled", "platform mail domain not configured", nil) + return + } + + claims := middleware.ClaimsFromContext(r.Context()) + userID, err := h.svc.ResolveUserID(r.Context(), claims.Sub) + if err != nil { + apiresponse.WriteError(w, r, http.StatusUnauthorized, apiresponse.CodeAuthUnauthorized, "user not provisioned", nil) + return + } + + displayName := strings.TrimSpace(req.DisplayName) + if displayName == "" { + displayName = localPart + } + email := strings.ToLower(localPart + "@" + domainName) + + result, err := svc.EnsureMailboxProvisioned(r.Context(), hosted.ProvisionMailboxInput{ + UserID: userID, + Email: email, + DisplayName: displayName, + Password: password, + }) + if err != nil { + switch { + case errors.Is(err, hosted.ErrAddressTaken): + apiresponse.WriteError(w, r, http.StatusConflict, "address_taken", err.Error(), nil) + case errors.Is(err, hosted.ErrInvalidLocalPart): + apivalidate.WriteValidationError(w, r, apivalidate.NewValidationError( + apivalidate.FieldDetail{Field: "local_part", Message: err.Error()}, + )) + case errors.Is(err, hosted.ErrDomainNotActive): + apiresponse.WriteError(w, r, http.StatusConflict, "domain_not_active", err.Error(), nil) + default: + h.logger.Error("setup hosted mailbox", "error", err, "email", email) + apiresponse.WriteError(w, r, http.StatusInternalServerError, apiresponse.CodeInternal, "failed to provision mailbox", nil) + } + return + } + + if h.accountSync != nil && result.MailAccountID != "" { + if syncErr := h.accountSync.SyncAccountForUser(r.Context(), claims.Sub, result.MailAccountID); syncErr != nil { + h.logger.Warn("hosted mailbox initial sync", "error", syncErr, "account_id", result.MailAccountID) + } + } + + apiresponse.WriteJSON(w, http.StatusOK, map[string]any{ + "mailbox": result.Mailbox, + "mail_account_id": result.MailAccountID, + "email": email, + }) +} + func (h *Handler) hostedService() *hosted.Service { if s, ok := h.svc.(*Service); ok { return s.HostedService() diff --git a/internal/config/config.go b/internal/config/config.go index de1a0b5..030e517 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -80,6 +80,7 @@ type Config struct { AIAssistantEnabled bool OpenWebUIInternalURL string AIAssistantPublicPath string + AIGatewayAPIKey string UltimailMCPURL string // Jitsi @@ -236,6 +237,7 @@ func Load() (*Config, error) { AIAssistantEnabled: envBool("AI_ASSISTANT_ENABLED", false), OpenWebUIInternalURL: envOrDefault("OPENWEBUI_URL", "http://openwebui:8080"), AIAssistantPublicPath: envOrDefault("AI_ASSISTANT_PUBLIC_PATH", "/ai"), + AIGatewayAPIKey: envOrDefault("AI_GATEWAY_API_KEY", "ulti-gateway"), UltimailMCPURL: envOrDefault("ULTIMAIL_MCP_URL", "http://ultimail-mcp:3100"), JitsiEnabled: envBool("JITSI_ENABLED", true), diff --git a/internal/mail/hosted/service.go b/internal/mail/hosted/service.go index cb3f97e..dd10208 100644 --- a/internal/mail/hosted/service.go +++ b/internal/mail/hosted/service.go @@ -83,6 +83,74 @@ func normalizeLocalPart(v string) (string, error) { return v, nil } +type EndpointInfo struct { + IMAPHost string `json:"imap_host"` + IMAPPort int `json:"imap_port"` + IMAPTLS bool `json:"imap_tls"` + SMTPHost string `json:"smtp_host"` + SMTPPort int `json:"smtp_port"` + SMTPTLS bool `json:"smtp_tls"` +} + +func (s *Service) Available() bool { + return s != nil && s.stlw != nil && s.stlw.Enabled() +} + +func (s *Service) Endpoints() EndpointInfo { + return EndpointInfo{ + IMAPHost: s.imapHost, + IMAPPort: s.imapPort, + IMAPTLS: s.imapTLS, + SMTPHost: s.smtpHost, + SMTPPort: s.smtpPort, + SMTPTLS: s.smtpTLS, + } +} + +func (s *Service) GetPlatformDomain(ctx context.Context) (DomainRow, error) { + var row DomainRow + err := s.db.QueryRow(ctx, ` + SELECT id::text, name, status, verification_token, dkim_selector, dkim_public_key, + stalwart_domain_id, is_platform_domain, + mx_verified_at::text, txt_verified_at::text, created_at::text + FROM mail_domains + WHERE is_platform_domain = true + ORDER BY created_at ASC + LIMIT 1 + `).Scan( + &row.ID, &row.Name, &row.Status, &row.VerificationToken, &row.DKIMSelector, &row.DKIMPublicKey, + &row.StalwartDomainID, &row.IsPlatformDomain, &row.MXVerifiedAt, &row.TXTVerifiedAt, &row.CreatedAt, + ) + if err != nil { + return DomainRow{}, err + } + return row, nil +} + +func (s *Service) GetUserMailbox(ctx context.Context, userID string) (MailboxRow, error) { + userID = strings.TrimSpace(userID) + if userID == "" { + return MailboxRow{}, fmt.Errorf("user id required") + } + var row MailboxRow + err := s.db.QueryRow(ctx, ` + SELECT mb.id::text, mb.domain_id::text, mb.local_part, + lower(mb.local_part || '@' || md.name), + COALESCE(mb.user_id::text, ''), + COALESCE(mb.mail_account_id::text, ''), + mb.stalwart_account_id, mb.quota_bytes, mb.status + FROM mailboxes mb + JOIN mail_domains md ON md.id = mb.domain_id + WHERE mb.user_id = $1::uuid + ORDER BY mb.created_at ASC + LIMIT 1 + `, userID).Scan( + &row.ID, &row.DomainID, &row.LocalPart, &row.Email, + &row.UserID, &row.MailAccountID, &row.StalwartAccountID, &row.QuotaBytes, &row.Status, + ) + return row, err +} + func (s *Service) IsAddressAvailable(ctx context.Context, domainName, localPart string) (bool, error) { localPart, err := normalizeLocalPart(localPart) if err != nil { diff --git a/internal/server/bootstrap.go b/internal/server/bootstrap.go index aa9deed..f4bf185 100644 --- a/internal/server/bootstrap.go +++ b/internal/server/bootstrap.go @@ -319,6 +319,7 @@ func New(ctx context.Context, cfg *config.Config, opts Options) (*App, error) { sendRateLimiter := sendguard.NewRateLimiter(cfg.MailSendRatePerMinute, cfg.MailSendBurst) mailHandler := mailapi.NewHandler(pool, auditLogger, credentialManager, attachmentStorage, cfg.MailAttachmentsBucket, sendRateLimiter, mailOAuthSvc, cfg.MailAppURL, sender) mailHandler.SetHostedService(hostedSvc) + mailHandler.SetHostedPlatformDomain(cfg.PlatformMailDomain) migrationHandler := migrationapi.NewHandler(migrationSvc, migrationOAuthSvc, cfg.MailAppURL) provisionHandler := provision.NewHandler(cfg.ProvisionWebhookSecret, cfg.PlatformMailDomain, hostedSvc, ncClient, pool) mailHandler.SetFileScanner(fileScanner)