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.
This commit is contained in:
parent
de27906baa
commit
bda75aeb0d
@ -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
64
CLAUDE.md
Normal 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
|
||||
@ -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;
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
70
internal/ai/models.go
Normal 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
|
||||
}
|
||||
55
internal/ai/models_test.go
Normal file
55
internal/ai/models_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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})
|
||||
}
|
||||
|
||||
92
internal/api/admin/org_llm.go
Normal file
92
internal/api/admin/org_llm.go
Normal 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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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 "))
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user