feat(config): enhance AI gateway and model management features
Some checks are pending
CI / Go tests (push) Waiting to run
CI / Integration tests (push) Waiting to run
CI / DB migrations (push) Waiting to run

- 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.
This commit is contained in:
R3D347HR4Y 2026-06-13 20:38:26 +02:00
parent de27906baa
commit bda75aeb0d
18 changed files with 1301 additions and 36 deletions

View File

@ -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)

64
CLAUDE.md Normal file
View File

@ -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

View File

@ -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;

View File

@ -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:

View File

@ -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

70
internal/ai/models.go Normal file
View File

@ -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
}

View File

@ -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")
}
}

View File

@ -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,

View File

@ -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 {

View File

@ -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})
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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 "))

View File

@ -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)

View File

@ -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()

View File

@ -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),

View File

@ -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 {

View File

@ -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)