diff --git a/.env.example b/.env.example index 5da0d44..0b3a5b4 100644 --- a/.env.example +++ b/.env.example @@ -169,6 +169,15 @@ HOCUSPOCUS_SECRET=changeme-hocuspocus-secret RICHTEXT_STORAGE_MODE=sidecar # RICHTEXT_EXPORT_MIRROR=docx +# ----------------------------------------------------------------------------- +# UltiAI (OpenWebUI + gateway LLM) +# ----------------------------------------------------------------------------- +AI_ASSISTANT_ENABLED=false +OPENWEBUI_URL=http://openwebui:8080 +AI_ASSISTANT_PUBLIC_PATH=/ai +ULTIMAIL_MCP_URL=http://ultimail-mcp:3100 +OPENWEBUI_DB_PASSWORD=changeme-openwebui + # ----------------------------------------------------------------------------- # Jitsi Meet (Visioconference) # Mode local : Jitsi deploye dans la stack diff --git a/deploy/authentik/README.md b/deploy/authentik/README.md index c67527c..0cc56e0 100644 --- a/deploy/authentik/README.md +++ b/deploy/authentik/README.md @@ -5,21 +5,21 @@ Blueprints in `blueprints/` are mounted into Authentik at `/blueprints/custom` a | Fichier | Rôle | |---------|------| | `01-ulti-enrollment.yaml` | Inscription self-service (`ulti-enrollment`) | -| `02-ulti-brand.yaml` | Branding Ultimail + lien « Créer un compte » sur login | +| `02-ulti-brand.yaml` | Branding UltiSuite + lien « Créer un compte » sur login | | `03-ulti-suite-groups.yaml` | Claim OIDC `groups` (RBAC contacts/calendar/drive/photos) | | `ulti-oidc.yaml` | App OIDC Ultimail | | `nextcloud-oidc.yaml` | App OIDC Nextcloud | | `onlyoffice-oidc.yaml` | App OIDC OnlyOffice | -Assets branding : générés depuis le frontend (`pnpm run brand:authentik` dans `gmail-interface-clone`) : +Assets branding : générés depuis le frontend (`pnpm run brand:authentik` dans `gmail-interface-clone`, source : `public/ultisuite-mark.svg`) : | Fichier Authentik | Thème | Description | |-------------------|-------|-------------| -| `ultimail-logo-light.png` | clair | Picto + wordmark sur fond blanc | -| `ultimail-logo-dark.png` | sombre | Picto + texte clair, fond transparent | -| `ultimail-favicon.png` | — | Mark 32×32 transparent (favicon onglet, URL **sans** `%(theme)s`) | -| `ultimail-favicon-light.png` | clair | Variante archive (fond blanc) | -| `ultimail-favicon-dark.png` | sombre | Variante archive (fond sombre) | +| `ultisuite-logo-light.png` | clair | Mark + wordmark UltiSuite, texte sombre, fond transparent | +| `ultisuite-logo-dark.png` | sombre | Mark + texte clair, fond transparent | +| `ultisuite-favicon.png` | — | Mark 32×32 transparent (favicon onglet, URL **sans** `%(theme)s`) | +| `ultisuite-favicon-light.png` | clair | Variante archive (fond blanc) | +| `ultisuite-favicon-dark.png` | sombre | Variante archive (fond sombre) | Logo : placeholder Authentik `%(theme)s` + fallback CSS `prefers-color-scheme`. Favicon onglet : **chemin statique** — Authentik ne substitue pas `%(theme)s` dans le `` SSR (erreur 400). @@ -28,7 +28,7 @@ Regénérer après MAJ du master brand : ```bash cd ../gmail-interface-clone -pnpm run brand:build && pnpm run brand:authentik +pnpm run brand:authentik cd ../ulti-backend ./deploy/compose-up.sh up -d authentik-server authentik-worker docker exec deploy-authentik-server-1 ak apply_blueprint /blueprints/custom/02-ulti-brand.yaml @@ -48,8 +48,8 @@ Sur la page de connexion Authentik, lien **« Besoin d'un compte ? S'inscrire » ## Branding -- Titre navigateur : **Ultimail** -- Logo / favicon : marque Ultimail, variantes **light** et **dark** (thème Authentik) +- Titre navigateur : **UltiSuite** +- Logo / favicon : marque UltiSuite (grille 4 couleurs plates), variantes **light** et **dark** (thème Authentik) - CSS custom : masque « Powered by authentik » et liens goauthentik.io - Locale par défaut : `fr` diff --git a/deploy/authentik/blueprints/02-ulti-brand.yaml b/deploy/authentik/blueprints/02-ulti-brand.yaml index c826b31..9cf0362 100644 --- a/deploy/authentik/blueprints/02-ulti-brand.yaml +++ b/deploy/authentik/blueprints/02-ulti-brand.yaml @@ -1,7 +1,7 @@ -# Ultimail — branding + lien inscription sur le flow de connexion +# UltiSuite — branding + lien inscription sur le flow de connexion version: 1 metadata: - name: Ultimail brand and authentication + name: UltiSuite brand and authentication labels: blueprints.goauthentik.io/instantiate: "true" entries: @@ -9,8 +9,8 @@ entries: identifiers: slug: default-authentication-flow attrs: - name: Connexion Ultimail - title: Connexion Ultimail + name: Connexion UltiSuite + title: Connexion UltiSuite - model: authentik_stages_identification.identificationstage identifiers: @@ -25,11 +25,11 @@ entries: identifiers: domain: authentik-default attrs: - branding_title: Ultimail - branding_logo: /static/dist/assets/branding/ultimail-logo-%(theme)s.png - branding_favicon: /static/dist/assets/branding/ultimail-favicon.png + branding_title: UltiSuite + branding_logo: /static/dist/assets/branding/ultisuite-logo-%(theme)s.png + branding_favicon: /static/dist/assets/branding/ultisuite-favicon.png branding_custom_css: | - /* Ultimail — masquer le branding Authentik */ + /* UltiSuite — masquer le branding Authentik */ ak-branding-footer, .pf-c-login__footer, .pf-c-login__footer-text, @@ -45,12 +45,12 @@ entries: max-height: 48px; width: auto; max-width: min(280px, 80vw); - content: url("/auth/static/dist/assets/branding/ultimail-logo-light.png"); + content: url("/auth/static/dist/assets/branding/ultisuite-logo-light.png"); } @media (prefers-color-scheme: dark) { ak-brand-logo img, .pf-c-brand img { - content: url("/auth/static/dist/assets/branding/ultimail-logo-dark.png"); + content: url("/auth/static/dist/assets/branding/ultisuite-logo-dark.png"); } } ak-flow-executor::part(footer) { diff --git a/deploy/authentik/branding/ulti-authentik.css b/deploy/authentik/branding/ulti-authentik.css index 5280eb4..a69b23c 100644 --- a/deploy/authentik/branding/ulti-authentik.css +++ b/deploy/authentik/branding/ulti-authentik.css @@ -1,4 +1,4 @@ -/* Ultimail — masquer le branding Authentik sur flows et portail utilisateur */ +/* UltiSuite — masquer le branding Authentik sur flows et portail utilisateur */ ak-branding-footer, .pf-c-login__footer, diff --git a/deploy/authentik/branding/ultimail-favicon-dark.png b/deploy/authentik/branding/ultimail-favicon-dark.png deleted file mode 100644 index 45e1ca9..0000000 Binary files a/deploy/authentik/branding/ultimail-favicon-dark.png and /dev/null differ diff --git a/deploy/authentik/branding/ultimail-favicon-light.png b/deploy/authentik/branding/ultimail-favicon-light.png deleted file mode 100644 index e3ad7cc..0000000 Binary files a/deploy/authentik/branding/ultimail-favicon-light.png and /dev/null differ diff --git a/deploy/authentik/branding/ultimail-favicon.png b/deploy/authentik/branding/ultimail-favicon.png deleted file mode 100644 index b1aa982..0000000 Binary files a/deploy/authentik/branding/ultimail-favicon.png and /dev/null differ diff --git a/deploy/authentik/branding/ultimail-logo-dark.png b/deploy/authentik/branding/ultimail-logo-dark.png deleted file mode 100644 index e2fc127..0000000 Binary files a/deploy/authentik/branding/ultimail-logo-dark.png and /dev/null differ diff --git a/deploy/authentik/branding/ultimail-logo-light.png b/deploy/authentik/branding/ultimail-logo-light.png deleted file mode 100644 index c9bdeda..0000000 Binary files a/deploy/authentik/branding/ultimail-logo-light.png and /dev/null differ diff --git a/deploy/authentik/branding/ultisuite-favicon-dark.png b/deploy/authentik/branding/ultisuite-favicon-dark.png new file mode 100644 index 0000000..8901135 Binary files /dev/null and b/deploy/authentik/branding/ultisuite-favicon-dark.png differ diff --git a/deploy/authentik/branding/ultisuite-favicon-light.png b/deploy/authentik/branding/ultisuite-favicon-light.png new file mode 100644 index 0000000..a05b14a Binary files /dev/null and b/deploy/authentik/branding/ultisuite-favicon-light.png differ diff --git a/deploy/authentik/branding/ultisuite-favicon.png b/deploy/authentik/branding/ultisuite-favicon.png new file mode 100644 index 0000000..7756e92 Binary files /dev/null and b/deploy/authentik/branding/ultisuite-favicon.png differ diff --git a/deploy/authentik/branding/ultisuite-logo-dark.png b/deploy/authentik/branding/ultisuite-logo-dark.png new file mode 100644 index 0000000..c920bb4 Binary files /dev/null and b/deploy/authentik/branding/ultisuite-logo-dark.png differ diff --git a/deploy/authentik/branding/ultisuite-logo-light.png b/deploy/authentik/branding/ultisuite-logo-light.png new file mode 100644 index 0000000..b98880f Binary files /dev/null and b/deploy/authentik/branding/ultisuite-logo-light.png differ diff --git a/deploy/compose-up.sh b/deploy/compose-up.sh index e15dc92..baf4aa0 100755 --- a/deploy/compose-up.sh +++ b/deploy/compose-up.sh @@ -54,4 +54,8 @@ if [[ "$(to_bool "${ONLYOFFICE_ENABLED:-false}")" == "true" ]]; then compose_files+=("-f" "deploy/onlyoffice/docker-compose.onlyoffice.yml") fi +if [[ "$(to_bool "${AI_ASSISTANT_ENABLED:-false}")" == "true" ]]; then + compose_files+=("-f" "deploy/openwebui/docker-compose.openwebui.yml") +fi + exec docker compose --env-file .env.resolved "${compose_files[@]}" "$@" diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 3325e1b..08c08a9 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -107,11 +107,11 @@ services: env_file: ../.env.resolved volumes: - ./authentik/blueprints:/blueprints/custom:ro - - ./authentik/branding/ultimail-logo-light.png:/web/dist/assets/branding/ultimail-logo-light.png:ro - - ./authentik/branding/ultimail-logo-dark.png:/web/dist/assets/branding/ultimail-logo-dark.png:ro - - ./authentik/branding/ultimail-favicon.png:/web/dist/assets/branding/ultimail-favicon.png:ro - - ./authentik/branding/ultimail-favicon-light.png:/web/dist/assets/branding/ultimail-favicon-light.png:ro - - ./authentik/branding/ultimail-favicon-dark.png:/web/dist/assets/branding/ultimail-favicon-dark.png:ro + - ./authentik/branding/ultisuite-logo-light.png:/web/dist/assets/branding/ultisuite-logo-light.png:ro + - ./authentik/branding/ultisuite-logo-dark.png:/web/dist/assets/branding/ultisuite-logo-dark.png:ro + - ./authentik/branding/ultisuite-favicon.png:/web/dist/assets/branding/ultisuite-favicon.png:ro + - ./authentik/branding/ultisuite-favicon-light.png:/web/dist/assets/branding/ultisuite-favicon-light.png:ro + - ./authentik/branding/ultisuite-favicon-dark.png:/web/dist/assets/branding/ultisuite-favicon-dark.png:ro networks: - ulti-net healthcheck: @@ -142,11 +142,11 @@ services: env_file: ../.env.resolved volumes: - ./authentik/blueprints:/blueprints/custom:ro - - ./authentik/branding/ultimail-logo-light.png:/web/dist/assets/branding/ultimail-logo-light.png:ro - - ./authentik/branding/ultimail-logo-dark.png:/web/dist/assets/branding/ultimail-logo-dark.png:ro - - ./authentik/branding/ultimail-favicon.png:/web/dist/assets/branding/ultimail-favicon.png:ro - - ./authentik/branding/ultimail-favicon-light.png:/web/dist/assets/branding/ultimail-favicon-light.png:ro - - ./authentik/branding/ultimail-favicon-dark.png:/web/dist/assets/branding/ultimail-favicon-dark.png:ro + - ./authentik/branding/ultisuite-logo-light.png:/web/dist/assets/branding/ultisuite-logo-light.png:ro + - ./authentik/branding/ultisuite-logo-dark.png:/web/dist/assets/branding/ultisuite-logo-dark.png:ro + - ./authentik/branding/ultisuite-favicon.png:/web/dist/assets/branding/ultisuite-favicon.png:ro + - ./authentik/branding/ultisuite-favicon-light.png:/web/dist/assets/branding/ultisuite-favicon-light.png:ro + - ./authentik/branding/ultisuite-favicon-dark.png:/web/dist/assets/branding/ultisuite-favicon-dark.png:ro networks: - ulti-net depends_on: diff --git a/deploy/init-db.sh b/deploy/init-db.sh index fbb2f6b..215cc7a 100644 --- a/deploy/init-db.sh +++ b/deploy/init-db.sh @@ -5,4 +5,5 @@ psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-E CREATE DATABASE authentik; CREATE DATABASE nextcloud; CREATE DATABASE immich; + CREATE DATABASE openwebui; EOSQL diff --git a/deploy/nginx/default.conf.template b/deploy/nginx/default.conf.template index 61686f9..e5594ea 100644 --- a/deploy/nginx/default.conf.template +++ b/deploy/nginx/default.conf.template @@ -155,6 +155,52 @@ server { proxy_read_timeout 300s; } + # OpenWebUI — same-origin proxy with trusted-header SSO + location = /api/v1/ai/embed-auth { + resolver 127.0.0.11 valid=10s ipv6=off; + set $ultid_upstream ultid:8080; + proxy_pass http://$ultid_upstream; + 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; + } + + location /ai/ { + 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_hide_header X-Frame-Options; + add_header Content-Security-Policy "frame-ancestors 'self'" always; + + rewrite ^/ai/?(.*)$ /$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_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_read_timeout 86400s; + proxy_send_timeout 86400s; + } + + location = /ai { + return 301 /ai/; + } + location /office/ { resolver 127.0.0.11 valid=10s ipv6=off; set $oo_upstream onlyoffice; @@ -204,6 +250,32 @@ server { # Ulti Suite frontend (mail + drive + contacts) — dev: pnpm dev on host (MAIL_FRONTEND_UPSTREAM=host.docker.internal:3004) # Prod: set MAIL_FRONTEND_UPSTREAM=suite-frontend:3000 + # Démos publiques de la landing (zéro rétention) — frontend Next. + location ^~ /demo { + resolver 127.0.0.11 valid=10s ipv6=off; + set $mail_upstream ${MAIL_FRONTEND_UPSTREAM}; + proxy_pass http://$mail_upstream; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + 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; + } + + # Sauvegarde no-op des démos — route API du frontend Next, pas ultid. + location ^~ /api/demo/ { + resolver 127.0.0.11 valid=10s ipv6=off; + set $mail_upstream ${MAIL_FRONTEND_UPSTREAM}; + proxy_pass http://$mail_upstream; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + location ^~ /api/auth/ { resolver 127.0.0.11 valid=10s ipv6=off; set $mail_upstream ${MAIL_FRONTEND_UPSTREAM}; @@ -259,6 +331,19 @@ server { proxy_set_header Connection $connection_upgrade; } + location ^~ /chat { + resolver 127.0.0.11 valid=10s ipv6=off; + set $mail_upstream ${MAIL_FRONTEND_UPSTREAM}; + proxy_pass http://$mail_upstream; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + } + location ^~ /contacts { resolver 127.0.0.11 valid=10s ipv6=off; set $mail_upstream ${MAIL_FRONTEND_UPSTREAM}; @@ -327,8 +412,16 @@ server { proxy_set_header X-Forwarded-Proto $scheme; } + # Landing page de la suite (servie par le frontend Next). location = / { - return 302 /mail/inbox; + resolver 127.0.0.11 valid=10s ipv6=off; + set $mail_upstream ${MAIL_FRONTEND_UPSTREAM}; + proxy_pass http://$mail_upstream; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; } location / { diff --git a/deploy/openwebui/docker-compose.openwebui.yml b/deploy/openwebui/docker-compose.openwebui.yml new file mode 100644 index 0000000..2b6ce44 --- /dev/null +++ b/deploy/openwebui/docker-compose.openwebui.yml @@ -0,0 +1,44 @@ +# Optional overlay: docker compose -f docker-compose.yml -f openwebui/docker-compose.openwebui.yml up -d +services: + openwebui: + image: ghcr.io/open-webui/open-webui:main + restart: unless-stopped + environment: + WEBUI_AUTH: "true" + WEBUI_AUTH_TRUSTED_EMAIL_HEADER: X-Ulti-User-Email + WEBUI_AUTH_TRUSTED_NAME_HEADER: X-Ulti-User-Name + WEBUI_AUTH_TRUSTED_ROLE_HEADER: X-Ulti-User-Role + ENABLE_PERSISTENT_CONFIG: "false" + ENABLE_DIRECT_CONNECTIONS: "false" + OPENAI_API_BASE_URL: http://ultid:8080/api/v1/ai + OPENAI_API_KEY: ulti-gateway + WEBUI_URL: http://${DOMAIN:-localhost}/ai + DATABASE_URL: postgresql://openwebui:${OPENWEBUI_DB_PASSWORD:-changeme}@postgres:5432/openwebui + USER_PERMISSIONS_CHAT_TEMPORARY_ENFORCED: "false" + volumes: + - openwebui_data:/app/backend/data + - ../services/openwebui/pipelines:/app/pipelines/custom:ro + networks: + - ulti-net + depends_on: + postgres: + condition: service_healthy + ultid: + condition: service_started + + ultimail-mcp: + build: + context: ../services/ultimail-mcp + dockerfile: Dockerfile + restart: unless-stopped + environment: + ULTID_API_URL: http://ultid:8080/api/v1 + MCP_PORT: "3100" + networks: + - ulti-net + depends_on: + ultid: + condition: service_started + +volumes: + openwebui_data: diff --git a/internal/ai/chat_sync.go b/internal/ai/chat_sync.go new file mode 100644 index 0000000..255acb9 --- /dev/null +++ b/internal/ai/chat_sync.go @@ -0,0 +1,101 @@ +package ai + +import ( + "context" + "encoding/json" + "fmt" + "io" + "path" + "strings" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/ultisuite/ulti-backend/internal/nextcloud" +) + +type ChatStore struct { + nc *nextcloud.Client + db *pgxpool.Pool +} + +func NewChatStore(nc *nextcloud.Client, db *pgxpool.Pool) *ChatStore { + return &ChatStore{nc: nc, db: db} +} + +func (s *ChatStore) Sync(ctx context.Context, userEmail string, record ChatRecord) error { + if s == nil || s.nc == nil { + return fmt.Errorf("nextcloud unavailable") + } + policy, _ := LoadAssistantPolicy(ctx, s.db) + basePath := policy.ChatNCPath + if strings.TrimSpace(basePath) == "" { + basePath = nextcloud.DefaultChatNCBasePath + } + if strings.TrimSpace(record.ID) == "" { + return fmt.Errorf("chat id required") + } + if record.UpdatedAt.IsZero() { + record.UpdatedAt = time.Now().UTC() + } + if record.CreatedAt.IsZero() { + record.CreatedAt = record.UpdatedAt + } + if strings.TrimSpace(record.Source) == "" { + record.Source = "openwebui" + } + + userID := nextcloud.UserIDFromClaims(userEmail, "") + sidecarPath := nextcloud.ChatSidecarPath(basePath, record.ID) + dir := path.Dir(sidecarPath) + if dir != "/" && dir != "." { + _ = s.nc.CreateFolder(ctx, userID, dir) + } + + payload, err := json.MarshalIndent(record, "", " ") + if err != nil { + return err + } + return s.nc.Upload(ctx, userID, sidecarPath, strings.NewReader(string(payload)), "application/json") +} + +func (s *ChatStore) Get(ctx context.Context, userEmail, chatID string) (ChatRecord, error) { + if s == nil || s.nc == nil { + return ChatRecord{}, fmt.Errorf("nextcloud unavailable") + } + policy, _ := LoadAssistantPolicy(ctx, s.db) + basePath := policy.ChatNCPath + if strings.TrimSpace(basePath) == "" { + basePath = nextcloud.DefaultChatNCBasePath + } + userID := nextcloud.UserIDFromClaims(userEmail, "") + sidecarPath := nextcloud.ChatSidecarPath(basePath, chatID) + body, _, err := s.nc.Download(ctx, userID, sidecarPath) + if err != nil { + return ChatRecord{}, err + } + defer body.Close() + raw, err := io.ReadAll(io.LimitReader(body, 8<<20)) + if err != nil { + return ChatRecord{}, err + } + var record ChatRecord + if err := json.Unmarshal(raw, &record); err != nil { + return ChatRecord{}, err + } + return record, nil +} + +func (s *ChatStore) Delete(ctx context.Context, userEmail, chatID string) error { + if s == nil || s.nc == nil { + return fmt.Errorf("nextcloud unavailable") + } + policy, _ := LoadAssistantPolicy(ctx, s.db) + basePath := policy.ChatNCPath + if strings.TrimSpace(basePath) == "" { + basePath = nextcloud.DefaultChatNCBasePath + } + userID := nextcloud.UserIDFromClaims(userEmail, "") + sidecarPath := nextcloud.ChatSidecarPath(basePath, chatID) + return s.nc.Delete(ctx, userID, sidecarPath) +} diff --git a/internal/ai/gateway.go b/internal/ai/gateway.go new file mode 100644 index 0000000..8520edd --- /dev/null +++ b/internal/ai/gateway.go @@ -0,0 +1,253 @@ +package ai + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/ultisuite/ulti-backend/internal/llm" +) + +type Gateway struct { + db *pgxpool.Pool + client *http.Client + quota *QuotaService +} + +func NewGateway(db *pgxpool.Pool) *Gateway { + return &Gateway{ + db: db, + client: &http.Client{ + Timeout: 0, + }, + quota: NewQuotaService(db), + } +} + +type chatCompletionRequest struct { + Model string `json:"model"` + Messages []llm.ChatMessage `json:"messages"` + Temperature *float64 `json:"temperature,omitempty"` + Stream bool `json:"stream,omitempty"` + Tools []any `json:"tools,omitempty"` +} + +type usagePayload struct { + PromptTokens int `json:"prompt_tokens"` + CompletionTokens int `json:"completion_tokens"` + TotalTokens int `json:"total_tokens"` +} + +type chatCompletionResponse struct { + ID string `json:"id"` + Object string `json:"object"` + Created int64 `json:"created"` + Model string `json:"model"` + Choices []struct { + Index int `json:"index"` + Message llm.ChatMessage `json:"message"` + FinishReason string `json:"finish_reason"` + Delta *llm.ChatMessage `json:"delta,omitempty"` + } `json:"choices"` + Usage *usagePayload `json:"usage,omitempty"` + Error *struct { + Message string `json:"message"` + } `json:"error,omitempty"` +} + +func (g *Gateway) ListModels(ctx context.Context, externalUserID string) ([]map[string]any, error) { + settings, err := LoadEffectiveLLMSettings(ctx, g.db, externalUserID) + if err != nil { + return nil, err + } + client := llm.NewClient() + seen := make(map[string]struct{}) + out := make([]map[string]any, 0) + for _, provider := range settings.Providers { + models, err := client.ListModels(ctx, provider) + if err != nil { + continue + } + for _, modelID := range models { + if _, ok := seen[modelID]; ok { + continue + } + seen[modelID] = struct{}{} + out = append(out, map[string]any{ + "id": modelID, + "object": "model", + "owned_by": provider.Name, + }) + } + } + if len(out) == 0 && len(settings.Providers) > 0 { + p := settings.Providers[0] + model := strings.TrimSpace(p.DefaultModel) + if model != "" { + out = append(out, map[string]any{ + "id": model, + "object": "model", + "owned_by": p.Name, + }) + } + } + return out, nil +} + +func (g *Gateway) ProxyChatCompletions(ctx context.Context, externalUserID string, body []byte, w http.ResponseWriter) error { + if err := g.quota.AssertAvailable(ctx, externalUserID); err != nil { + return err + } + + var req chatCompletionRequest + if err := json.Unmarshal(body, &req); err != nil { + return fmt.Errorf("invalid chat completion request: %w", err) + } + + settings, err := LoadEffectiveLLMSettings(ctx, g.db, externalUserID) + if err != nil { + return err + } + provider, model, err := resolveProviderForModel(settings, req.Model) + if err != nil { + return err + } + if strings.TrimSpace(req.Model) == "" { + req.Model = model + } + + upstreamBody, err := json.Marshal(req) + if err != nil { + return err + } + baseURL := strings.TrimRight(strings.TrimSpace(provider.BaseURL), "/") + upstreamReq, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+"/chat/completions", bytes.NewReader(upstreamBody)) + if err != nil { + return err + } + upstreamReq.Header.Set("Content-Type", "application/json") + if strings.TrimSpace(provider.APIKey) != "" { + upstreamReq.Header.Set("Authorization", "Bearer "+strings.TrimSpace(provider.APIKey)) + } + + resp, err := g.client.Do(upstreamReq) + if err != nil { + return err + } + defer resp.Body.Close() + + if req.Stream { + return g.proxyStream(ctx, externalUserID, w, resp) + } + payload, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20)) + if err != nil { + return err + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(resp.StatusCode) + _, _ = w.Write(payload) + if resp.StatusCode >= 400 { + return nil + } + tokens := extractUsageTokens(payload) + _ = g.quota.Record(ctx, externalUserID, tokens) + return nil +} + +func (g *Gateway) proxyStream(ctx context.Context, externalUserID string, w http.ResponseWriter, resp *http.Response) error { + flusher, ok := w.(http.Flusher) + if !ok { + return fmt.Errorf("streaming not supported") + } + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + w.WriteHeader(resp.StatusCode) + + reader := bufio.NewReader(resp.Body) + var totalTokens int64 + for { + line, err := reader.ReadString('\n') + if len(line) > 0 { + _, _ = w.Write([]byte(line)) + flusher.Flush() + if strings.HasPrefix(line, "data: ") && !strings.Contains(line, "[DONE]") { + totalTokens += extractStreamUsageTokens([]byte(strings.TrimPrefix(strings.TrimSpace(line), "data: "))) + } + } + if err != nil { + if err == io.EOF { + break + } + return err + } + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + } + if resp.StatusCode < 400 { + if totalTokens == 0 { + totalTokens = 1 + } + _ = g.quota.Record(ctx, externalUserID, totalTokens) + } + return nil +} + +func resolveProviderForModel(settings llm.Settings, model string) (llm.Provider, string, error) { + model = strings.TrimSpace(model) + if model != "" { + for _, p := range settings.Providers { + if p.ID == model { + return p, strings.TrimSpace(p.DefaultModel), nil + } + } + } + provider, resolvedModel, err := llm.ResolveProvider(settings, settings.DefaultProviderID) + if err != nil { + return llm.Provider{}, "", err + } + if model != "" { + resolvedModel = model + } + return provider, resolvedModel, nil +} + +func extractUsageTokens(payload []byte) int64 { + var parsed chatCompletionResponse + if err := json.Unmarshal(payload, &parsed); err != nil { + return 1 + } + if parsed.Usage != nil && parsed.Usage.TotalTokens > 0 { + return int64(parsed.Usage.TotalTokens) + } + if parsed.Usage != nil && parsed.Usage.CompletionTokens > 0 { + return int64(parsed.Usage.CompletionTokens) + } + return 1 +} + +func extractStreamUsageTokens(payload []byte) int64 { + var parsed chatCompletionResponse + if err := json.Unmarshal(payload, &parsed); err != nil { + return 0 + } + if parsed.Usage != nil && parsed.Usage.TotalTokens > 0 { + return int64(parsed.Usage.TotalTokens) + } + return 0 +} + +func NowUnix() int64 { + return time.Now().Unix() +} diff --git a/internal/ai/gateway_test.go b/internal/ai/gateway_test.go new file mode 100644 index 0000000..b5a04d9 --- /dev/null +++ b/internal/ai/gateway_test.go @@ -0,0 +1,16 @@ +package ai + +import "testing" + +func TestExtractUsageTokens(t *testing.T) { + payload := []byte(`{"usage":{"total_tokens":42,"completion_tokens":10}}`) + if got := extractUsageTokens(payload); got != 42 { + t.Fatalf("extractUsageTokens() = %d, want 42", got) + } +} + +func TestExtractUsageTokensFallback(t *testing.T) { + if got := extractUsageTokens([]byte(`{"choices":[]}`)); got != 1 { + t.Fatalf("expected fallback token count 1, got %d", got) + } +} diff --git a/internal/ai/providers.go b/internal/ai/providers.go new file mode 100644 index 0000000..c8d9cbe --- /dev/null +++ b/internal/ai/providers.go @@ -0,0 +1,187 @@ +package ai + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/ultisuite/ulti-backend/internal/llm" +) + +const orgSettingsSingletonID = 1 + +type orgLLMPolicy struct { + DefaultProviderID string `json:"default_provider_id"` + Providers []llm.Provider `json:"providers"` + EnforceOrgProviders bool `json:"enforce_org_providers"` + AllowUserOverride bool `json:"allow_user_override"` + ContactDiscoveryModel string `json:"contact_discovery_model,omitempty"` +} + +func LoadEffectiveLLMSettings(ctx context.Context, db *pgxpool.Pool, externalUserID string) (llm.Settings, error) { + if db == nil { + return llm.Settings{}, fmt.Errorf("database unavailable") + } + org, err := loadOrgLLMPolicy(ctx, db) + if err != nil { + return llm.Settings{}, err + } + user, err := loadUserLLMSettings(ctx, db, externalUserID) + if err != nil { + return llm.Settings{}, err + } + + if org.EnforceOrgProviders && len(org.Providers) > 0 { + if !org.AllowUserOverride { + return orgToSettings(org), nil + } + merged := orgToSettings(org) + if strings.TrimSpace(user.DefaultProviderID) != "" { + merged.DefaultProviderID = user.DefaultProviderID + } + if strings.TrimSpace(user.ContactDiscoveryModel) != "" { + merged.ContactDiscoveryModel = user.ContactDiscoveryModel + } + if strings.TrimSpace(user.ContactDiscoveryProvider) != "" { + merged.ContactDiscoveryProvider = user.ContactDiscoveryProvider + } + return merged, nil + } + + if len(user.Providers) > 0 { + return user, nil + } + if len(org.Providers) > 0 { + return orgToSettings(org), nil + } + return user, nil +} + +func orgToSettings(org orgLLMPolicy) llm.Settings { + return llm.Settings{ + DefaultProviderID: org.DefaultProviderID, + Providers: org.Providers, + ContactDiscoveryModel: org.ContactDiscoveryModel, + ContactDiscoveryProvider: org.DefaultProviderID, + } +} + +func loadOrgLLMPolicy(ctx context.Context, db *pgxpool.Pool) (orgLLMPolicy, error) { + var raw []byte + err := db.QueryRow(ctx, ` + SELECT settings->'llm' FROM org_settings WHERE id = $1 + `, orgSettingsSingletonID).Scan(&raw) + if err != nil { + if err == pgx.ErrNoRows { + return orgLLMPolicy{}, nil + } + return orgLLMPolicy{}, err + } + if len(raw) == 0 || string(raw) == "null" { + return orgLLMPolicy{}, nil + } + var out orgLLMPolicy + if err := json.Unmarshal(raw, &out); err != nil { + return orgLLMPolicy{}, err + } + return out, nil +} + +func loadUserLLMSettings(ctx context.Context, db *pgxpool.Pool, externalUserID string) (llm.Settings, error) { + var raw []byte + err := db.QueryRow(ctx, ` + SELECT COALESCE(s.preferences->'llm', '{}'::jsonb) + FROM users u + LEFT JOIN settings s ON s.user_id = u.id + WHERE u.external_id = $1 + `, externalUserID).Scan(&raw) + if err != nil { + if err == pgx.ErrNoRows { + return llm.Settings{}, nil + } + return llm.Settings{}, err + } + var out llm.Settings + if len(raw) > 0 { + if err := json.Unmarshal(raw, &out); err != nil { + return llm.Settings{}, err + } + } + return out, nil +} + +func LoadAssistantPolicy(ctx context.Context, db *pgxpool.Pool) (AssistantPolicy, error) { + defaults := AssistantPolicy{ + Enabled: false, + PublicPath: "/ai", + EmbedDefaultTemporary: true, + EnabledTools: []string{"mail", "drive", "contacts", "search"}, + ChatSyncEnabled: true, + ChatNCPath: "/.ultimail/ai/chats", + } + if db == nil { + return defaults, nil + } + var raw []byte + err := db.QueryRow(ctx, ` + SELECT settings->'ai_assistant' FROM org_settings WHERE id = $1 + `, orgSettingsSingletonID).Scan(&raw) + if err != nil { + if err == pgx.ErrNoRows { + return defaults, nil + } + return defaults, err + } + if len(raw) == 0 || string(raw) == "null" { + return defaults, nil + } + var stored AssistantPolicy + if err := json.Unmarshal(raw, &stored); err != nil { + return defaults, err + } + if stored.PublicPath == "" { + stored.PublicPath = defaults.PublicPath + } + if stored.ChatNCPath == "" { + stored.ChatNCPath = defaults.ChatNCPath + } + if len(stored.EnabledTools) == 0 { + stored.EnabledTools = defaults.EnabledTools + } + return stored, nil +} + +func LoadQuotaLimits(ctx context.Context, db *pgxpool.Pool) (QuotaLimits, error) { + defaults := QuotaLimits{RequestsPerDay: 100, TokensPerMonth: 500_000} + if db == nil { + return defaults, nil + } + var raw []byte + err := db.QueryRow(ctx, ` + SELECT settings->'usage_quotas' FROM org_settings WHERE id = $1 + `, orgSettingsSingletonID).Scan(&raw) + if err != nil { + if err == pgx.ErrNoRows { + return defaults, nil + } + return defaults, err + } + if len(raw) == 0 || string(raw) == "null" { + return defaults, nil + } + var stored map[string]any + if err := json.Unmarshal(raw, &stored); err != nil { + return defaults, err + } + if v, ok := stored["llm_requests_per_day"].(float64); ok && v > 0 { + defaults.RequestsPerDay = int(v) + } + if v, ok := stored["llm_tokens_per_month"].(float64); ok && v > 0 { + defaults.TokensPerMonth = int64(v) + } + return defaults, nil +} diff --git a/internal/ai/quota.go b/internal/ai/quota.go new file mode 100644 index 0000000..2729a9a --- /dev/null +++ b/internal/ai/quota.go @@ -0,0 +1,124 @@ +package ai + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +var ErrQuotaExceeded = errors.New("llm quota exceeded") + +type QuotaService struct { + db *pgxpool.Pool +} + +func NewQuotaService(db *pgxpool.Pool) *QuotaService { + return &QuotaService{db: db} +} + +func (s *QuotaService) Check(ctx context.Context, externalUserID string) (QuotaStatus, error) { + limits, err := LoadQuotaLimits(ctx, s.db) + if err != nil { + return QuotaStatus{}, err + } + userID, err := s.resolveUserID(ctx, externalUserID) + if err != nil { + return QuotaStatus{}, err + } + + today := time.Now().UTC().Truncate(24 * time.Hour) + month := time.Date(today.Year(), today.Month(), 1, 0, 0, 0, 0, time.UTC) + + var requestsToday int + var tokensMonth int64 + _ = s.db.QueryRow(ctx, ` + SELECT COALESCE(requests, 0) FROM ai_usage_daily + WHERE user_id = $1 AND usage_date = $2 + `, userID, today).Scan(&requestsToday) + _ = s.db.QueryRow(ctx, ` + SELECT COALESCE(tokens, 0) FROM ai_usage_monthly + WHERE user_id = $1 AND usage_month = $2 + `, userID, month).Scan(&tokensMonth) + + status := QuotaStatus{ + RequestsUsedToday: requestsToday, + RequestsLimit: limits.RequestsPerDay, + TokensUsedMonth: tokensMonth, + TokensLimit: limits.TokensPerMonth, + } + if limits.RequestsPerDay > 0 { + status.RequestsRemaining = limits.RequestsPerDay - requestsToday + if status.RequestsRemaining < 0 { + status.RequestsRemaining = 0 + } + } + if limits.TokensPerMonth > 0 { + status.TokensRemaining = limits.TokensPerMonth - tokensMonth + if status.TokensRemaining < 0 { + status.TokensRemaining = 0 + } + } + return status, nil +} + +func (s *QuotaService) AssertAvailable(ctx context.Context, externalUserID string) error { + status, err := s.Check(ctx, externalUserID) + if err != nil { + return err + } + if status.RequestsLimit > 0 && status.RequestsUsedToday >= status.RequestsLimit { + return fmt.Errorf("%w: daily request limit reached", ErrQuotaExceeded) + } + if status.TokensLimit > 0 && status.TokensUsedMonth >= status.TokensLimit { + return fmt.Errorf("%w: monthly token limit reached", ErrQuotaExceeded) + } + return nil +} + +func (s *QuotaService) Record(ctx context.Context, externalUserID string, tokens int64) error { + if tokens < 0 { + tokens = 0 + } + userID, err := s.resolveUserID(ctx, externalUserID) + if err != nil { + return err + } + today := time.Now().UTC().Truncate(24 * time.Hour) + month := time.Date(today.Year(), today.Month(), 1, 0, 0, 0, 0, time.UTC) + + _, err = s.db.Exec(ctx, ` + INSERT INTO ai_usage_daily (user_id, usage_date, requests, tokens) + VALUES ($1, $2, 1, $3) + ON CONFLICT (user_id, usage_date) DO UPDATE SET + requests = ai_usage_daily.requests + 1, + tokens = ai_usage_daily.tokens + EXCLUDED.tokens + `, userID, today, tokens) + if err != nil { + return err + } + _, err = s.db.Exec(ctx, ` + INSERT INTO ai_usage_monthly (user_id, usage_month, tokens) + VALUES ($1, $2, $3) + ON CONFLICT (user_id, usage_month) DO UPDATE SET + tokens = ai_usage_monthly.tokens + EXCLUDED.tokens + `, userID, month, tokens) + return err +} + +func (s *QuotaService) resolveUserID(ctx context.Context, externalUserID string) (string, error) { + var userID string + err := s.db.QueryRow(ctx, ` + SELECT id::text FROM users WHERE external_id = $1 + `, externalUserID).Scan(&userID) + if err != nil { + if err == pgx.ErrNoRows { + return "", fmt.Errorf("user not found") + } + return "", err + } + return userID, nil +} diff --git a/internal/ai/quota_test.go b/internal/ai/quota_test.go new file mode 100644 index 0000000..dd79692 --- /dev/null +++ b/internal/ai/quota_test.go @@ -0,0 +1,26 @@ +package ai + +import "testing" + +func TestQuotaStatusRemaining(t *testing.T) { + status := QuotaStatus{ + RequestsUsedToday: 40, + RequestsLimit: 100, + TokensUsedMonth: 100_000, + TokensLimit: 500_000, + } + status.RequestsRemaining = status.RequestsLimit - status.RequestsUsedToday + status.TokensRemaining = status.TokensLimit - status.TokensUsedMonth + if status.RequestsRemaining != 60 { + t.Fatalf("requests remaining = %d", status.RequestsRemaining) + } + if status.TokensRemaining != 400_000 { + t.Fatalf("tokens remaining = %d", status.TokensRemaining) + } +} + +func TestErrQuotaExceeded(t *testing.T) { + if ErrQuotaExceeded.Error() == "" { + t.Fatal("expected error message") + } +} diff --git a/internal/ai/types.go b/internal/ai/types.go new file mode 100644 index 0000000..44e2e75 --- /dev/null +++ b/internal/ai/types.go @@ -0,0 +1,78 @@ +package ai + +import "time" + +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"` +} + +type QuotaLimits struct { + RequestsPerDay int `json:"llm_requests_per_day"` + TokensPerMonth int64 `json:"llm_tokens_per_month"` +} + +type QuotaStatus struct { + RequestsUsedToday int `json:"requests_used_today"` + RequestsLimit int `json:"requests_limit"` + TokensUsedMonth int64 `json:"tokens_used_month"` + TokensLimit int64 `json:"tokens_limit"` + RequestsRemaining int `json:"requests_remaining"` + TokensRemaining int64 `json:"tokens_remaining"` +} + +type ChatMessage struct { + Role string `json:"role"` + Content string `json:"content,omitempty"` + ToolCalls []any `json:"tool_calls,omitempty"` + Attachments []any `json:"attachments,omitempty"` +} + +type ChatMeta struct { + Model string `json:"model,omitempty"` + TokensUsed int64 `json:"tokens_used,omitempty"` + Context string `json:"context,omitempty"` +} + +type ChatRecord struct { + ID string `json:"id"` + Title string `json:"title"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Source string `json:"source"` + OpenWebUIChatID string `json:"openwebui_chat_id,omitempty"` + Messages []ChatMessage `json:"messages"` + Meta ChatMeta `json:"meta"` +} + +type ChatListItem struct { + ID string `json:"id"` + Title string `json:"title"` + UpdatedAt time.Time `json:"updated_at"` + Source string `json:"source"` +} + +type SessionContext struct { + App string `json:"app"` + Temporary bool `json:"temporary"` + MessageID string `json:"message_id,omitempty"` + AccountID string `json:"account_id,omitempty"` + DrivePath string `json:"drive_path,omitempty"` + FileID string `json:"file_id,omitempty"` + ContactID string `json:"contact_id,omitempty"` + Subject string `json:"subject,omitempty"` + Snippet string `json:"snippet,omitempty"` +} + +type SessionResponse struct { + SessionID string `json:"session_id"` + EmbedURL string `json:"embed_url"` + TokenSecret string `json:"token_secret,omitempty"` + Temporary bool `json:"temporary"` +} diff --git a/internal/api/admin/org_settings.go b/internal/api/admin/org_settings.go index 42cc372..63c9ed3 100644 --- a/internal/api/admin/org_settings.go +++ b/internal/api/admin/org_settings.go @@ -111,11 +111,22 @@ func defaultOrgPolicy() map[string]any { "export_mirror_format": "", "hocuspocus_url": "", }, + "ai_assistant": map[string]any{ + "enabled": false, + "openwebui_internal_url": "", + "public_path": "/ai", + "embed_default_temporary": true, + "default_model": "", + "enabled_tools": []any{"mail", "drive", "contacts", "search"}, + "chat_sync_enabled": true, + "chat_nc_path": "/.ultimail/ai/chats", + }, "plugins": []any{ map[string]any{"id": "mail-automation", "name": "Automatisations mail", "description": "Règles, webhooks et tri IA sur la réception.", "enabled": true, "version": "1.0.0"}, map[string]any{"id": "contact-discovery", "name": "Découverte contacts", "description": "Enrichissement IA et signatures détectées.", "enabled": true, "version": "1.0.0"}, map[string]any{"id": "public-share", "name": "Partage public Drive", "description": "Liens publics et partages externes.", "enabled": true, "version": "1.0.0"}, map[string]any{"id": "office-editor", "name": "Édition OnlyOffice", "description": "Édition collaborative de documents.", "enabled": false, "version": "1.0.0"}, + map[string]any{"id": "ai-assistant", "name": "UltiAI", "description": "Assistant IA intégré (chat, tools mail/drive/contacts).", "enabled": false, "version": "1.0.0"}, }, "integrations": []any{ map[string]any{"id": "authentik", "name": "Authentik", "description": "SSO, groupes et provisionnement des comptes.", "enabled": true, "configured": false}, @@ -571,6 +582,11 @@ func buildOrgEffective(cfg *config.Config) map[string]any { "enabled": cfg.JitsiEnabled, "public_url": cfg.JitsiPublicURL, }, + "ai_assistant": map[string]any{ + "enabled": cfg.AIAssistantEnabled, + "openwebui_internal_url": cfg.OpenWebUIInternalURL, + "public_path": cfg.AIAssistantPublicPath, + }, } } diff --git a/internal/api/admin/org_settings_env.go b/internal/api/admin/org_settings_env.go index d2fa50e..4bdb16d 100644 --- a/internal/api/admin/org_settings_env.go +++ b/internal/api/admin/org_settings_env.go @@ -31,6 +31,11 @@ var orgEnvVarSpecs = []envVarSpec{ {Name: "ONLYOFFICE_URL", Group: "onlyoffice", Secret: false}, {Name: "ONLYOFFICE_PUBLIC_URL", Group: "onlyoffice", Secret: false}, {Name: "ONLYOFFICE_JWT_SECRET", Group: "onlyoffice", Secret: true}, + // AI assistant + {Name: "AI_ASSISTANT_ENABLED", Group: "ai_assistant", Secret: false}, + {Name: "OPENWEBUI_URL", Group: "ai_assistant", Secret: false}, + {Name: "AI_ASSISTANT_PUBLIC_PATH", Group: "ai_assistant", Secret: false}, + {Name: "ULTIMAIL_MCP_URL", Group: "ai_assistant", Secret: false}, // Search {Name: "SEARCH_ENGINE", Group: "search", Secret: false}, {Name: "MEILISEARCH_URL", Group: "search", Secret: false}, @@ -81,6 +86,11 @@ func buildOrgDeployLocked(cfg *config.Config) map[string]any { "reason": "docker_compose", "fields": []string{"enabled", "document_server_url", "jwt_secret", "jwt_header"}, }, + "ai_assistant": map[string]any{ + "locked": true, + "reason": "docker_compose", + "fields": []string{"enabled", "openwebui_internal_url", "public_path"}, + }, "search": map[string]any{ "locked": true, "reason": "docker_compose", diff --git a/internal/api/ai/handlers.go b/internal/api/ai/handlers.go new file mode 100644 index 0000000..763d254 --- /dev/null +++ b/internal/api/ai/handlers.go @@ -0,0 +1,286 @@ +package aiapi + +import ( + "encoding/json" + "errors" + "io" + "net/http" + "net/url" + "strings" + + "github.com/go-chi/chi/v5" + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/ultisuite/ulti-backend/internal/ai" + "github.com/ultisuite/ulti-backend/internal/api/apiresponse" + "github.com/ultisuite/ulti-backend/internal/api/middleware" + "github.com/ultisuite/ulti-backend/internal/apitokens" + "github.com/ultisuite/ulti-backend/internal/auth" + "github.com/ultisuite/ulti-backend/internal/config" + "github.com/ultisuite/ulti-backend/internal/nextcloud" +) + +const sessionAccessCookie = "ulti_access_token" + +type Handler struct { + db *pgxpool.Pool + cfg *config.Config + gateway *ai.Gateway + quota *ai.QuotaService + chats *ai.ChatStore + verify *auth.Holder +} + +func NewHandler(db *pgxpool.Pool, cfg *config.Config, nc *nextcloud.Client, verifier *auth.Holder) *Handler { + return &Handler{ + db: db, + cfg: cfg, + gateway: ai.NewGateway(db), + quota: ai.NewQuotaService(db), + chats: ai.NewChatStore(nc, db), + verify: verifier, + } +} + +func (h *Handler) Routes(authMiddleware func(http.Handler) http.Handler) chi.Router { + r := chi.NewRouter() + r.Get("/config", h.GetConfig) + r.Get("/embed-auth", h.EmbedAuth) + 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) + r.Post("/chats/sync", h.SyncChat) + }) + return r +} + +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 + } + publicPath := policy.PublicPath + if strings.TrimSpace(publicPath) == "" { + publicPath = "/ai" + } + if h.cfg != nil && strings.TrimSpace(h.cfg.AIAssistantPublicPath) != "" { + publicPath = h.cfg.AIAssistantPublicPath + } + apiresponse.WriteJSON(w, http.StatusOK, map[string]any{ + "enabled": policy.Enabled || (h.cfg != nil && h.cfg.AIAssistantEnabled), + "public_path": publicPath, + "embed_default_temporary": policy.EmbedDefaultTemporary, + "default_model": policy.DefaultModel, + "enabled_tools": policy.EnabledTools, + "chat_sync_enabled": policy.ChatSyncEnabled, + }) +} + +func (h *Handler) EmbedAuth(w http.ResponseWriter, r *http.Request) { + claims, ok := h.resolveClaims(r) + if !ok { + w.WriteHeader(http.StatusUnauthorized) + return + } + 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) +} + +func (h *Handler) GetQuota(w http.ResponseWriter, r *http.Request) { + claims := middleware.ClaimsFromContext(r.Context()) + if claims == nil { + apiresponse.WriteError(w, r, http.StatusUnauthorized, apiresponse.CodeAuthUnauthorized, "unauthorized", nil) + return + } + status, err := h.quota.Check(r.Context(), claims.Sub) + if err != nil { + apiresponse.WriteError(w, r, http.StatusInternalServerError, apiresponse.CodeInternal, err.Error(), nil) + return + } + apiresponse.WriteJSON(w, http.StatusOK, status) +} + +func (h *Handler) ListModels(w http.ResponseWriter, r *http.Request) { + claims := middleware.ClaimsFromContext(r.Context()) + if claims == nil { + apiresponse.WriteError(w, r, http.StatusUnauthorized, apiresponse.CodeAuthUnauthorized, "unauthorized", nil) + return + } + models, err := h.gateway.ListModels(r.Context(), claims.Sub) + if err != nil { + apiresponse.WriteError(w, r, http.StatusBadRequest, apiresponse.CodeInvalidRequest, err.Error(), nil) + return + } + apiresponse.WriteJSON(w, http.StatusOK, map[string]any{ + "object": "list", + "data": models, + }) +} + +func (h *Handler) ChatCompletions(w http.ResponseWriter, r *http.Request) { + claims := middleware.ClaimsFromContext(r.Context()) + if claims == nil { + apiresponse.WriteError(w, r, http.StatusUnauthorized, apiresponse.CodeAuthUnauthorized, "unauthorized", nil) + return + } + body, err := io.ReadAll(io.LimitReader(r.Body, 8<<20)) + if err != nil { + 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 { + if errors.Is(err, ai.ErrQuotaExceeded) { + apiresponse.WriteError(w, r, http.StatusTooManyRequests, apiresponse.CodeRateLimited, err.Error(), nil) + return + } + apiresponse.WriteError(w, r, http.StatusBadGateway, apiresponse.CodeInternal, err.Error(), nil) + return + } +} + +func (h *Handler) CreateSession(w http.ResponseWriter, r *http.Request) { + claims := middleware.ClaimsFromContext(r.Context()) + if claims == nil { + apiresponse.WriteError(w, r, http.StatusUnauthorized, apiresponse.CodeAuthUnauthorized, "unauthorized", nil) + return + } + var req ai.SessionContext + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + apiresponse.WriteError(w, r, http.StatusBadRequest, apiresponse.CodeInvalidRequest, "invalid json", nil) + return + } + preset := apitokens.ChatSessionStandalone + switch strings.ToLower(strings.TrimSpace(req.App)) { + case "mail": + preset = apitokens.ChatSessionMail + case "drive": + preset = apitokens.ChatSessionDrive + case "contacts": + preset = apitokens.ChatSessionContacts + case "docs": + preset = apitokens.ChatSessionDocs + } + allowWrite := preset == apitokens.ChatSessionDocs + created, err := apitokens.CreateChatSession(r.Context(), h.db, claims.Sub, claims.Email, apitokens.ChatSessionInput{ + Preset: preset, + DrivePath: req.DrivePath, + AllowWrite: allowWrite, + }) + if err != nil { + apiresponse.WriteError(w, r, http.StatusInternalServerError, apiresponse.CodeInternal, err.Error(), nil) + return + } + policy, _ := ai.LoadAssistantPolicy(r.Context(), h.db) + publicPath := policy.PublicPath + if strings.TrimSpace(publicPath) == "" { + publicPath = "/ai" + } + if h.cfg != nil && strings.TrimSpace(h.cfg.AIAssistantPublicPath) != "" { + publicPath = h.cfg.AIAssistantPublicPath + } + temporary := req.Temporary || policy.EmbedDefaultTemporary + q := url.Values{} + if temporary { + q.Set("temporary-chat", "true") + } + if strings.TrimSpace(req.App) != "" { + q.Set("app", req.App) + } + embedURL := strings.TrimRight(publicPath, "/") + "/" + if enc := q.Encode(); enc != "" { + embedURL += "?" + enc + } + apiresponse.WriteJSON(w, http.StatusOK, ai.SessionResponse{ + SessionID: created.ID, + EmbedURL: embedURL, + TokenSecret: created.TokenSecret, + Temporary: temporary, + }) +} + +func (h *Handler) SyncChat(w http.ResponseWriter, r *http.Request) { + claims := middleware.ClaimsFromContext(r.Context()) + if claims == nil { + apiresponse.WriteError(w, r, http.StatusUnauthorized, apiresponse.CodeAuthUnauthorized, "unauthorized", nil) + return + } + policy, _ := ai.LoadAssistantPolicy(r.Context(), h.db) + if !policy.ChatSyncEnabled { + apiresponse.WriteError(w, r, http.StatusForbidden, apiresponse.CodeAuthForbidden, "chat sync disabled", nil) + return + } + var record ai.ChatRecord + if err := json.NewDecoder(r.Body).Decode(&record); err != nil { + apiresponse.WriteError(w, r, http.StatusBadRequest, apiresponse.CodeInvalidRequest, "invalid json", nil) + return + } + if err := h.chats.Sync(r.Context(), claims.Email, record); err != nil { + apiresponse.WriteError(w, r, http.StatusBadGateway, apiresponse.CodeInternal, err.Error(), nil) + return + } + apiresponse.WriteJSON(w, http.StatusOK, map[string]any{"ok": true}) +} + +func (h *Handler) GetChat(w http.ResponseWriter, r *http.Request) { + claims := middleware.ClaimsFromContext(r.Context()) + if claims == nil { + apiresponse.WriteError(w, r, http.StatusUnauthorized, apiresponse.CodeAuthUnauthorized, "unauthorized", nil) + return + } + chatID := chi.URLParam(r, "chatID") + record, err := h.chats.Get(r.Context(), claims.Email, chatID) + if err != nil { + apiresponse.WriteError(w, r, http.StatusNotFound, apiresponse.CodeNotFound, "chat not found", nil) + return + } + apiresponse.WriteJSON(w, http.StatusOK, record) +} + +func (h *Handler) DeleteChat(w http.ResponseWriter, r *http.Request) { + claims := middleware.ClaimsFromContext(r.Context()) + if claims == nil { + apiresponse.WriteError(w, r, http.StatusUnauthorized, apiresponse.CodeAuthUnauthorized, "unauthorized", nil) + return + } + chatID := chi.URLParam(r, "chatID") + if err := h.chats.Delete(r.Context(), claims.Email, chatID); err != nil { + apiresponse.WriteError(w, r, http.StatusNotFound, apiresponse.CodeNotFound, "chat not found", nil) + return + } + apiresponse.WriteJSON(w, http.StatusOK, map[string]any{"ok": true}) +} + +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 ")) + if h.verify != nil && h.verify.Ready() { + claims, err := h.verify.Verify(r.Context(), token) + if err == nil { + return claims, true + } + } + } + if cookie, err := r.Cookie(sessionAccessCookie); err == nil { + token := strings.TrimSpace(cookie.Value) + if token != "" && h.verify != nil && h.verify.Ready() { + claims, err := h.verify.Verify(r.Context(), token) + if err == nil { + return claims, true + } + } + } + return nil, false +} diff --git a/internal/api/drive/blank_office.go b/internal/api/drive/blank_office.go index ce4403c..11108c4 100644 --- a/internal/api/drive/blank_office.go +++ b/internal/api/drive/blank_office.go @@ -11,6 +11,9 @@ var blankXlsx []byte //go:embed testdata/blank.pptx var blankPptx []byte +//go:embed testdata/blank.excalidraw +var blankExcalidraw []byte + func blankOfficeFile(kind NewFileKind) ([]byte, string) { switch kind { case NewFileDocument: @@ -19,6 +22,8 @@ func blankOfficeFile(kind NewFileKind) ([]byte, string) { return blankXlsx, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" case NewFilePresentation: return blankPptx, "application/vnd.openxmlformats-officedocument.presentationml.presentation" + case NewFileDrawing: + return blankExcalidraw, "application/json" default: return nil, "" } diff --git a/internal/api/drive/blank_office_test.go b/internal/api/drive/blank_office_test.go new file mode 100644 index 0000000..65ce779 --- /dev/null +++ b/internal/api/drive/blank_office_test.go @@ -0,0 +1,23 @@ +package drive + +import "testing" + +func TestBlankOfficeFileDrawing(t *testing.T) { + content, ct := blankOfficeFile(NewFileDrawing) + if content == nil || len(content) == 0 { + t.Fatal("expected blank excalidraw content") + } + if ct != "application/json" { + t.Fatalf("content type = %q, want application/json", ct) + } +} + +func TestBlankOfficeFileUnknownKind(t *testing.T) { + content, ct := blankOfficeFile(NewFileKind("drawing")) + if content == nil { + t.Fatal("NewFileKind drawing should produce content") + } + if ct == "" { + t.Fatal("expected content type") + } +} diff --git a/internal/api/drive/handlers.go b/internal/api/drive/handlers.go index f6c8775..1d33abc 100644 --- a/internal/api/drive/handlers.go +++ b/internal/api/drive/handlers.go @@ -26,6 +26,7 @@ type Handler struct { svc *Service publicOffice PublicOfficeAPI publicRichText PublicRichTextAPI + publicUltidraw PublicUltidrawAPI logger *slog.Logger } @@ -33,6 +34,10 @@ type PublicRichTextAPI interface { RegisterPublicShareRoutes(r chi.Router) } +type PublicUltidrawAPI interface { + RegisterPublicShareRoutes(r chi.Router) +} + func NewHandler(nc *nextcloud.Client, hub *realtime.Hub, db *pgxpool.Pool) *Handler { return NewHandlerWithService(NewService(nc, hub, db)) } @@ -52,6 +57,10 @@ func (h *Handler) SetPublicRichText(api PublicRichTextAPI) { h.publicRichText = api } +func (h *Handler) SetPublicUltidraw(api PublicUltidrawAPI) { + h.publicUltidraw = api +} + func (h *Handler) nextcloudUser(w http.ResponseWriter, r *http.Request, claims *auth.Claims) (string, bool) { userID, err := h.svc.EnsureNextcloudUser(r.Context(), claims) if err != nil { diff --git a/internal/api/drive/public_handlers.go b/internal/api/drive/public_handlers.go index 27b1034..daed6c4 100644 --- a/internal/api/drive/public_handlers.go +++ b/internal/api/drive/public_handlers.go @@ -27,6 +27,9 @@ func (h *Handler) PublicRoutes() chi.Router { if h.publicRichText != nil { h.publicRichText.RegisterPublicShareRoutes(r) } + if h.publicUltidraw != nil { + h.publicUltidraw.RegisterPublicShareRoutes(r) + } return r } diff --git a/internal/api/drive/service.go b/internal/api/drive/service.go index 904f741..c6bc4b1 100644 --- a/internal/api/drive/service.go +++ b/internal/api/drive/service.go @@ -297,13 +297,16 @@ func (s *Service) CreateFolder(ctx context.Context, userID, path string) error { func (s *Service) Move(ctx context.Context, userID, source, destination string) error { source = nextcloud.NormalizeClientFilePath(userID, nextcloud.NormalizeClientPath(source)) destination = nextcloud.NormalizeClientFilePath(userID, nextcloud.NormalizeClientPath(destination)) - return mapDriveError(s.nc.Move(ctx, userID, source, destination)) + return s.moveWithUltidocSidecar(ctx, userID, source, destination) } func (s *Service) Copy(ctx context.Context, userID, source, destination string) error { source = nextcloud.NormalizeClientFilePath(userID, nextcloud.NormalizeClientPath(source)) destination = nextcloud.NormalizeClientFilePath(userID, nextcloud.NormalizeClientPath(destination)) - return mapDriveError(s.nc.Copy(ctx, userID, source, destination)) + if err := mapDriveError(s.nc.Copy(ctx, userID, source, destination)); err != nil { + return err + } + return s.syncUltidocSidecarCopy(ctx, userID, source, destination) } func (s *Service) Rename(ctx context.Context, userID, filePath, newName string) error { @@ -313,7 +316,7 @@ func (s *Service) Rename(ctx context.Context, userID, filePath, newName string) filePath = nextcloud.NormalizeClientFilePath(userID, nextcloud.NormalizeClientPath(filePath)) dir := path.Dir("/" + strings.TrimPrefix(filePath, "/")) destination := path.Join(dir, newName) - return mapDriveError(s.nc.Move(ctx, userID, filePath, destination)) + return s.moveWithUltidocSidecar(ctx, userID, filePath, destination) } func (s *Service) CreateShare(ctx context.Context, userID, filePath string, req createShareRequest, permissions int) (*nextcloud.ShareInfo, error) { @@ -568,6 +571,7 @@ const ( NewFileDocument NewFileKind = "document" NewFileSpreadsheet NewFileKind = "spreadsheet" NewFilePresentation NewFileKind = "presentation" + NewFileDrawing NewFileKind = "drawing" ) func (s *Service) CreateNewFile(ctx context.Context, userID, parentPath, name string, kind NewFileKind) (string, int64, error) { diff --git a/internal/api/drive/testdata/blank.excalidraw b/internal/api/drive/testdata/blank.excalidraw new file mode 100644 index 0000000..e367b91 --- /dev/null +++ b/internal/api/drive/testdata/blank.excalidraw @@ -0,0 +1 @@ +{"type":"excalidraw","version":2,"source":"https://ultidrive","elements":[],"appState":{"gridSize":null,"viewBackgroundColor":"#ffffff"},"files":{}} diff --git a/internal/api/drive/ultidoc_sidecar.go b/internal/api/drive/ultidoc_sidecar.go new file mode 100644 index 0000000..88591b0 --- /dev/null +++ b/internal/api/drive/ultidoc_sidecar.go @@ -0,0 +1,106 @@ +package drive + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + + "github.com/ultisuite/ulti-backend/internal/nextcloud" +) + +func shouldSyncUltidocSidecar(path string) bool { + return !nextcloud.IsUltidocSidecarPath(path) +} + +func sidecarPathsForSourceMove(sourcePath, destinationPath string) (sidecarSource, sidecarDestination string, ok bool) { + if !shouldSyncUltidocSidecar(sourcePath) { + return "", "", false + } + return nextcloud.SidecarPathForSource(sourcePath), nextcloud.SidecarPathForSource(destinationPath), true +} + +func (s *Service) sidecarExists(ctx context.Context, userID, path string) bool { + _, err := s.nc.FileRevision(ctx, userID, path) + return err == nil +} + +func (s *Service) syncUltidocSidecarCopy( + ctx context.Context, + userID, sourcePath, destinationPath string, +) error { + sidecarSource, sidecarDestination, ok := sidecarPathsForSourceMove(sourcePath, destinationPath) + if !ok || !s.sidecarExists(ctx, userID, sidecarSource) { + return nil + } + + if err := mapDriveError(s.nc.Copy(ctx, userID, sidecarSource, sidecarDestination)); err != nil { + return err + } + + return s.patchUltidocSidecarSourcePath(ctx, userID, sidecarDestination, destinationPath) +} + +func (s *Service) patchUltidocSidecarSourcePath( + ctx context.Context, + userID, sidecarPath, newSourcePath string, +) error { + body, _, err := s.nc.Download(ctx, userID, sidecarPath) + if err != nil { + return mapDriveError(err) + } + defer body.Close() + + raw, err := io.ReadAll(body) + if err != nil { + return err + } + + var doc map[string]any + if err := json.Unmarshal(raw, &doc); err != nil { + return err + } + + source, _ := doc["source"].(map[string]any) + if source == nil { + source = map[string]any{} + doc["source"] = source + } + source["path"] = newSourcePath + + encoded, err := json.Marshal(doc) + if err != nil { + return err + } + + if err := mapDriveError(s.nc.Upload(ctx, userID, sidecarPath, bytes.NewReader(encoded), "application/json")); err != nil { + return err + } + return nil +} + +func (s *Service) moveWithUltidocSidecar(ctx context.Context, userID, source, destination string) error { + sidecarSource, sidecarDestination, ok := sidecarPathsForSourceMove(source, destination) + hasSidecar := ok && s.sidecarExists(ctx, userID, sidecarSource) + + if hasSidecar { + if err := mapDriveError(s.nc.Move(ctx, userID, sidecarSource, sidecarDestination)); err != nil { + return err + } + } + + if err := mapDriveError(s.nc.Move(ctx, userID, source, destination)); err != nil { + if hasSidecar { + _ = s.nc.Move(ctx, userID, sidecarDestination, sidecarSource) + } + return err + } + + if hasSidecar { + if err := s.patchUltidocSidecarSourcePath(ctx, userID, sidecarDestination, destination); err != nil { + return errors.Join(err, mapDriveError(s.nc.Move(ctx, userID, destination, source))) + } + } + return nil +} diff --git a/internal/api/drive/ultidoc_sidecar_test.go b/internal/api/drive/ultidoc_sidecar_test.go new file mode 100644 index 0000000..c5bc607 --- /dev/null +++ b/internal/api/drive/ultidoc_sidecar_test.go @@ -0,0 +1,31 @@ +package drive + +import ( + "testing" + + "github.com/ultisuite/ulti-backend/internal/nextcloud" +) + +func TestSidecarPathsForSourceMove(t *testing.T) { + src, dest, ok := sidecarPathsForSourceMove("/docs/a.docx", "/archive/a.docx") + if !ok { + t.Fatal("expected sidecar sync for docx") + } + if src != "/docs/a.ultidoc.json" || dest != "/archive/a.ultidoc.json" { + t.Fatalf("unexpected sidecar paths: %q -> %q", src, dest) + } + + _, _, ok = sidecarPathsForSourceMove("/docs/a.ultidoc.json", "/archive/a.ultidoc.json") + if ok { + t.Fatal("ultidoc sidecar itself should not trigger companion sync") + } +} + +func TestShouldSyncUltidocSidecar(t *testing.T) { + if !shouldSyncUltidocSidecar("/docs/report.docx") { + t.Fatal("docx should sync sidecar") + } + if shouldSyncUltidocSidecar(nextcloud.SidecarPathForSource("/docs/report.docx")) { + t.Fatal("sidecar path should not sync again") + } +} diff --git a/internal/api/drive/validate.go b/internal/api/drive/validate.go index 38fd5bd..5019a4c 100644 --- a/internal/api/drive/validate.go +++ b/internal/api/drive/validate.go @@ -177,7 +177,7 @@ func validateNewFileRequest(req *newFileRequest) *apivalidate.ValidationError { details = append(details, apivalidate.FieldDetail{Field: "name", Message: "required"}) } k := strings.TrimSpace(strings.ToLower(req.Kind)) - if k != "document" && k != "spreadsheet" && k != "presentation" { + if k != "document" && k != "spreadsheet" && k != "presentation" && k != "drawing" { details = append(details, apivalidate.FieldDetail{Field: "kind", Message: "invalid"}) } if len(details) > 0 { diff --git a/internal/api/richtext/document.go b/internal/api/richtext/document.go index c5f1409..6b47f31 100644 --- a/internal/api/richtext/document.go +++ b/internal/api/richtext/document.go @@ -15,8 +15,9 @@ type UltiDoc struct { Editor string `json:"editor"` Source *UltiDocSource `json:"source,omitempty"` Content json.RawMessage `json:"content"` - PageSetup *UltiDocPageSetup `json:"pageSetup,omitempty"` - YjsState string `json:"yjsState,omitempty"` + PageSetup *UltiDocPageSetup `json:"pageSetup,omitempty"` + ParagraphStyles *UltiDocParagraphStyles `json:"paragraphStyles,omitempty"` + YjsState string `json:"yjsState,omitempty"` UpdatedAt string `json:"updatedAt,omitempty"` } @@ -168,6 +169,9 @@ func preserveUltiDocMetadata(dst *UltiDoc, existing UltiDoc) { dst.Content = existing.Content } } + if dst.ParagraphStyles == nil && existing.ParagraphStyles != nil { + dst.ParagraphStyles = existing.ParagraphStyles + } if dst.YjsState == "" && existing.YjsState != "" { dst.YjsState = existing.YjsState } @@ -178,8 +182,9 @@ type ultiDocPatch struct { Editor string `json:"editor"` Content json.RawMessage `json:"content"` Document json.RawMessage `json:"document"` - PageSetup *UltiDocPageSetup `json:"pageSetup"` - YjsState string `json:"yjsState"` + PageSetup *UltiDocPageSetup `json:"pageSetup"` + ParagraphStyles *UltiDocParagraphStyles `json:"paragraphStyles"` + YjsState string `json:"yjsState"` } // ApplyUltiDocPatch merges a partial JSON payload into an existing UltiDoc. @@ -207,6 +212,9 @@ func ApplyUltiDocPatch(existing UltiDoc, raw json.RawMessage) (UltiDoc, error) { if patch.PageSetup != nil { doc.PageSetup = patch.PageSetup } + if patch.ParagraphStyles != nil { + doc.ParagraphStyles = patch.ParagraphStyles + } if patch.YjsState != "" { doc.YjsState = patch.YjsState } diff --git a/internal/api/richtext/handlers.go b/internal/api/richtext/handlers.go index 7cfeda4..8ea06e4 100644 --- a/internal/api/richtext/handlers.go +++ b/internal/api/richtext/handlers.go @@ -44,6 +44,9 @@ func (h *Handler) Routes(authMiddleware func(http.Handler) http.Handler) chi.Rou pr.With(read).Post("/session", h.CreateSession) pr.With(read).Post("/import", h.Import) pr.With(read).Post("/export", h.Export) + pr.With(read).Get("/fonts", h.ListFonts) + pr.With(read).Get("/user-paragraph-styles", h.GetUserParagraphStyles) + pr.With(write).Put("/user-paragraph-styles", h.PutUserParagraphStyles) pr.With(write).Post("/assets", h.UploadAsset) pr.With(write).Put("/save", h.Save) }) @@ -133,10 +136,11 @@ func (h *Handler) Export(w http.ResponseWriter, r *http.Request) { } type saveRequest struct { - Path string `json:"path"` - Document json.RawMessage `json:"document"` - PageSetup json.RawMessage `json:"pageSetup,omitempty"` - YjsState string `json:"yjsState,omitempty"` + Path string `json:"path"` + Document json.RawMessage `json:"document"` + PageSetup json.RawMessage `json:"pageSetup,omitempty"` + ParagraphStyles json.RawMessage `json:"paragraphStyles,omitempty"` + YjsState string `json:"yjsState,omitempty"` } func (h *Handler) Save(w http.ResponseWriter, r *http.Request) { @@ -169,9 +173,15 @@ func (h *Handler) Save(w http.ResponseWriter, r *http.Request) { } else { doc = NewUltiDoc(nil, nil) } + case len(req.ParagraphStyles) > 0: + if existing.SchemaVersion > 0 { + doc = existing + } else { + doc = NewUltiDoc(nil, nil) + } default: apivalidate.WriteValidationError(w, r, apivalidate.NewValidationError( - apivalidate.FieldDetail{Field: "document", Message: "document or pageSetup required"}, + apivalidate.FieldDetail{Field: "document", Message: "document, pageSetup or paragraphStyles required"}, )) return } @@ -182,6 +192,12 @@ func (h *Handler) Save(w http.ResponseWriter, r *http.Request) { doc.PageSetup = &pageSetup } } + if len(req.ParagraphStyles) > 0 { + var paragraphStyles UltiDocParagraphStyles + if err := json.Unmarshal(req.ParagraphStyles, ¶graphStyles); err == nil { + doc.ParagraphStyles = ¶graphStyles + } + } if req.YjsState != "" { doc.YjsState = req.YjsState } @@ -375,3 +391,41 @@ func (h *Handler) UploadAsset(w http.ResponseWriter, r *http.Request) { } apiresponse.WriteJSON(w, http.StatusOK, result) } + +func (h *Handler) ListFonts(w http.ResponseWriter, r *http.Request) { + fonts := h.svc.ListFonts(r.Context()) + apiresponse.WriteJSON(w, http.StatusOK, map[string]any{"fonts": fonts}) +} + +func (h *Handler) GetUserParagraphStyles(w http.ResponseWriter, r *http.Request) { + claims := middleware.ClaimsFromContext(r.Context()) + ncUser, err := h.svc.EnsureNextcloudUser(r.Context(), claims) + if err != nil { + apivalidate.WriteInternal(w, r) + return + } + styles, err := h.svc.LoadUserParagraphStyles(r.Context(), ncUser) + if err != nil { + apivalidate.WriteInternal(w, r) + return + } + apiresponse.WriteJSON(w, http.StatusOK, styles) +} + +func (h *Handler) PutUserParagraphStyles(w http.ResponseWriter, r *http.Request) { + claims := middleware.ClaimsFromContext(r.Context()) + ncUser, err := h.svc.EnsureNextcloudUser(r.Context(), claims) + if err != nil { + apivalidate.WriteInternal(w, r) + return + } + var styles UltiDocParagraphStyles + if err := apivalidate.DecodeJSON(w, r, 512<<10, &styles); err != nil { + return + } + if err := h.svc.SaveUserParagraphStyles(r.Context(), ncUser, styles); err != nil { + apivalidate.WriteInternal(w, r) + return + } + apiresponse.WriteJSON(w, http.StatusOK, styles) +} diff --git a/internal/api/richtext/paragraph_styles.go b/internal/api/richtext/paragraph_styles.go new file mode 100644 index 0000000..76dabbf --- /dev/null +++ b/internal/api/richtext/paragraph_styles.go @@ -0,0 +1,114 @@ +package richtext + +import ( + "context" + "encoding/json" + "fmt" + "strings" +) + +const userParagraphStylesPath = "/.ultidrive/docs-user-paragraph-styles.json" + +type UltiDocParagraphStyleDefinition struct { + ID string `json:"id"` + Name string `json:"name"` + Scope string `json:"scope"` + BasedOn string `json:"basedOn,omitempty"` + BlockType string `json:"blockType"` + Level *int `json:"level,omitempty"` + FontFamily string `json:"fontFamily,omitempty"` + FontSizePx float64 `json:"fontSizePx,omitempty"` + Bold *bool `json:"bold,omitempty"` + Italic *bool `json:"italic,omitempty"` + Underline *bool `json:"underline,omitempty"` + Color string `json:"color,omitempty"` + TextAlign string `json:"textAlign,omitempty"` + LineHeight float64 `json:"lineHeight,omitempty"` + SpaceBeforePt float64 `json:"spaceBeforePt,omitempty"` + SpaceAfterPt float64 `json:"spaceAfterPt,omitempty"` +} + +type UltiDocParagraphStyles struct { + Definitions map[string]UltiDocParagraphStyleDefinition `json:"definitions"` +} + +type DocsFontDefinition struct { + Name string `json:"name"` + Stack string `json:"stack"` + Source string `json:"source,omitempty"` + URL string `json:"url,omitempty"` +} + +var defaultDocsFonts = []DocsFontDefinition{ + {Name: "Arial", Stack: "Arial, Helvetica, sans-serif", Source: "system"}, + {Name: "Calibri", Stack: "Calibri, Candara, Segoe, sans-serif", Source: "system"}, + {Name: "Comic Sans MS", Stack: `"Comic Sans MS", cursive, sans-serif`, Source: "system"}, + {Name: "Courier New", Stack: `"Courier New", Courier, monospace`, Source: "system"}, + {Name: "Georgia", Stack: "Georgia, serif", Source: "system"}, + {Name: "Times New Roman", Stack: `"Times New Roman", Times, serif`, Source: "system"}, + {Name: "Trebuchet MS", Stack: `"Trebuchet MS", Helvetica, sans-serif`, Source: "system"}, + {Name: "Verdana", Stack: "Verdana, Geneva, sans-serif", Source: "system"}, +} + +func defaultUltiDocParagraphStyles() UltiDocParagraphStyles { + level := func(n int) *int { v := n; return &v } + bold := func(v bool) *bool { return &v } + italic := func(v bool) *bool { return &v } + return UltiDocParagraphStyles{ + Definitions: map[string]UltiDocParagraphStyleDefinition{ + "normal": {ID: "normal", Name: "Normal", Scope: "document", BlockType: "paragraph", FontFamily: "Arial, Helvetica, sans-serif", FontSizePx: 11, LineHeight: 1.15}, + "title": {ID: "title", Name: "Titre", Scope: "document", BlockType: "paragraph", FontFamily: "Arial, Helvetica, sans-serif", FontSizePx: 26, LineHeight: 1.15}, + "subtitle": {ID: "subtitle", Name: "Sous-titre", Scope: "document", BlockType: "paragraph", FontFamily: "Arial, Helvetica, sans-serif", FontSizePx: 15, Color: "#666666", LineHeight: 1.15}, + "heading1": {ID: "heading1", Name: "Titre 1", Scope: "document", BlockType: "heading", Level: level(1), FontFamily: "Arial, Helvetica, sans-serif", FontSizePx: 20, LineHeight: 1.15}, + "heading2": {ID: "heading2", Name: "Titre 2", Scope: "document", BlockType: "heading", Level: level(2), FontFamily: "Arial, Helvetica, sans-serif", FontSizePx: 16, LineHeight: 1.15}, + "heading3": {ID: "heading3", Name: "Titre 3", Scope: "document", BlockType: "heading", Level: level(3), FontFamily: "Arial, Helvetica, sans-serif", FontSizePx: 14, LineHeight: 1.15}, + "heading4": {ID: "heading4", Name: "Titre 4", Scope: "document", BlockType: "heading", Level: level(4), FontFamily: "Arial, Helvetica, sans-serif", FontSizePx: 12, Bold: bold(true), LineHeight: 1.15}, + "heading5": {ID: "heading5", Name: "Titre 5", Scope: "document", BlockType: "heading", Level: level(5), FontFamily: "Arial, Helvetica, sans-serif", FontSizePx: 11, Bold: bold(true), LineHeight: 1.15}, + "heading6": {ID: "heading6", Name: "Titre 6", Scope: "document", BlockType: "heading", Level: level(6), FontFamily: "Arial, Helvetica, sans-serif", FontSizePx: 11, Italic: italic(true), LineHeight: 1.15}, + }, + } +} + +func (s *Service) ListFonts(_ context.Context) []DocsFontDefinition { + out := make([]DocsFontDefinition, len(defaultDocsFonts)) + copy(out, defaultDocsFonts) + return out +} + +func (s *Service) LoadUserParagraphStyles(ctx context.Context, ncUser string) (UltiDocParagraphStyles, error) { + body, err := s.LoadDocument(ctx, ncUser, userParagraphStylesPath) + if err != nil || len(body) == 0 { + return UltiDocParagraphStyles{Definitions: map[string]UltiDocParagraphStyleDefinition{}}, nil + } + var styles UltiDocParagraphStyles + if err := json.Unmarshal(body, &styles); err != nil { + return UltiDocParagraphStyles{}, fmt.Errorf("parse user paragraph styles: %w", err) + } + if styles.Definitions == nil { + styles.Definitions = map[string]UltiDocParagraphStyleDefinition{} + } + return styles, nil +} + +func (s *Service) SaveUserParagraphStyles(ctx context.Context, ncUser string, styles UltiDocParagraphStyles) error { + if styles.Definitions == nil { + styles.Definitions = map[string]UltiDocParagraphStyleDefinition{} + } + payload, err := json.MarshalIndent(styles, "", " ") + if err != nil { + return err + } + return s.nc.Upload(ctx, ncUser, userParagraphStylesPath, strings.NewReader(string(payload)), "application/json") +} + +func (s *Service) loadParagraphStylesFromSidecar(ctx context.Context, ncUser, canonical string) (json.RawMessage, error) { + raw, err := s.LoadDocument(ctx, ncUser, canonical) + if err != nil || len(raw) == 0 { + return nil, err + } + doc, err := ParseUltiDoc(raw) + if err != nil || doc.ParagraphStyles == nil { + return nil, err + } + return json.Marshal(doc.ParagraphStyles) +} diff --git a/internal/api/richtext/service.go b/internal/api/richtext/service.go index 137d653..36d6227 100644 --- a/internal/api/richtext/service.go +++ b/internal/api/richtext/service.go @@ -44,7 +44,8 @@ type SessionResult struct { Mode string `json:"mode"` ImportRequired bool `json:"importRequired"` Collaboration bool `json:"collaboration"` - PageSetup json.RawMessage `json:"pageSetup,omitempty"` + PageSetup json.RawMessage `json:"pageSetup,omitempty"` + ParagraphStyles json.RawMessage `json:"paragraphStyles,omitempty"` } func (s *Service) CreateSession(ctx context.Context, ncUser, filePath, mode, editorUserID, editorName string) (*SessionResult, error) { @@ -83,6 +84,7 @@ func (s *Service) CreateSession(ctx context.Context, ncUser, filePath, mode, edi collab := wsURL != "" && s.Cfg.HocuspocusSecret != "" pageSetup, _ := s.loadPageSetupFromSidecar(ctx, ncUser, canonical) + paragraphStyles, _ := s.loadParagraphStylesFromSidecar(ctx, ncUser, canonical) return &SessionResult{ RoomID: roomID, @@ -94,6 +96,7 @@ func (s *Service) CreateSession(ctx context.Context, ncUser, filePath, mode, edi ImportRequired: importRequired, Collaboration: collab, PageSetup: pageSetup, + ParagraphStyles: paragraphStyles, }, nil } diff --git a/internal/api/ultidraw/collab_room.go b/internal/api/ultidraw/collab_room.go new file mode 100644 index 0000000..7e2ffca --- /dev/null +++ b/internal/api/ultidraw/collab_room.go @@ -0,0 +1,27 @@ +package ultidraw + +import ( + "context" + "fmt" + "strings" +) + +func (s *Service) resolveCollabRoomID(ctx context.Context, ownerID, filePath string) (string, error) { + ownerID = strings.TrimSpace(ownerID) + filePath = normalizePath(filePath) + if ownerID == "" || filePath == "" { + return "", fmt.Errorf("collab room: missing owner or path") + } + if rev, err := s.nc.FileRevision(ctx, ownerID, filePath); err == nil && rev.FileID > 0 { + return fmt.Sprintf("draw:%s:%d", ownerID, rev.FileID), nil + } + return fmt.Sprintf("draw:%s:%s", ownerID, hashPath(filePath)), nil +} + +func (s *Service) ownerPathForPublic(ctx context.Context, token, password, clientCanonical, displayName string) (ownerID, ownerPath string, err error) { + binding, err := s.nc.ResolvePublicShareBinding(ctx, token, password) + if err != nil { + return "", "", err + } + return binding.OwnerID, binding.OwnerPathForClient(clientCanonical, displayName), nil +} diff --git a/internal/api/ultidraw/document.go b/internal/api/ultidraw/document.go new file mode 100644 index 0000000..216f5d6 --- /dev/null +++ b/internal/api/ultidraw/document.go @@ -0,0 +1,90 @@ +package ultidraw + +import ( + "encoding/json" + "strings" + "time" +) + +// UltiDrawDoc is the on-disk format for Excalidraw drawings in UltiDrive. +type UltiDrawDoc struct { + Type string `json:"type"` + Version int `json:"version"` + Source string `json:"source,omitempty"` + Elements json.RawMessage `json:"elements"` + AppState json.RawMessage `json:"appState,omitempty"` + Files json.RawMessage `json:"files,omitempty"` + YjsState string `json:"yjsState,omitempty"` + UpdatedAt string `json:"updatedAt,omitempty"` +} + +func ParseUltiDrawDoc(raw []byte) (UltiDrawDoc, error) { + var doc UltiDrawDoc + if err := json.Unmarshal(raw, &doc); err != nil { + return UltiDrawDoc{}, err + } + if doc.Type == "" { + doc.Type = "excalidraw" + } + if doc.Version == 0 { + doc.Version = 2 + } + return doc, nil +} + +func NewUltiDrawDoc(elements, appState, files json.RawMessage) UltiDrawDoc { + if len(elements) == 0 { + elements = json.RawMessage("[]") + } + if len(appState) == 0 { + appState = json.RawMessage(`{"gridSize":null,"viewBackgroundColor":"#ffffff"}`) + } + if len(files) == 0 { + files = json.RawMessage("{}") + } + return UltiDrawDoc{ + Type: "excalidraw", + Version: 2, + Source: "https://ultidrive", + Elements: elements, + AppState: appState, + Files: files, + } +} + +func (d UltiDrawDoc) Marshal() ([]byte, error) { + d.UpdatedAt = time.Now().UTC().Format(time.RFC3339) + return json.Marshal(d) +} + +func ApplyUltiDrawPatch(existing UltiDrawDoc, raw []byte) (UltiDrawDoc, error) { + var patch map[string]json.RawMessage + if err := json.Unmarshal(raw, &patch); err != nil { + return UltiDrawDoc{}, err + } + doc := existing + if doc.Type == "" { + doc = NewUltiDrawDoc(nil, nil, nil) + } + if v, ok := patch["elements"]; ok { + doc.Elements = v + } + if v, ok := patch["appState"]; ok { + doc.AppState = v + } + if v, ok := patch["files"]; ok { + doc.Files = v + } + if v, ok := patch["yjsState"]; ok { + doc.YjsState = strings.Trim(string(v), `"`) + } + return doc, nil +} + +func isEmptyElements(elements json.RawMessage) bool { + if len(elements) == 0 { + return true + } + s := strings.TrimSpace(string(elements)) + return s == "" || s == "[]" || s == "null" +} diff --git a/internal/api/ultidraw/handlers.go b/internal/api/ultidraw/handlers.go new file mode 100644 index 0000000..9cf5bc6 --- /dev/null +++ b/internal/api/ultidraw/handlers.go @@ -0,0 +1,178 @@ +package ultidraw + +import ( + "encoding/json" + "log/slog" + "net/http" + "strings" + + "github.com/go-chi/chi/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/permission" +) + +type Handler struct { + svc *Service + logger *slog.Logger +} + +func NewHandler(svc *Service) *Handler { + return &Handler{ + svc: svc, + logger: slog.Default().With("component", "ultidraw-api"), + } +} + +func (h *Handler) Routes(authMiddleware func(http.Handler) http.Handler) chi.Router { + r := chi.NewRouter() + r.Post("/hooks/store", h.HookStore) + r.Get("/internal/document", h.InternalLoadDocument) + + r.Group(func(pr chi.Router) { + pr.Use(authMiddleware) + read := middleware.RequirePermission(permission.ResourceDrive, permission.LevelRead) + pr.With(read).Post("/session", h.CreateSession) + }) + return r +} + +func (h *Handler) RegisterPublicShareRoutes(r chi.Router) { + r.Post("/shares/{token}/ultidraw/session", h.PublicShareSession) + r.Get("/shares/{token}/ultidraw/document", h.PublicShareDocument) + r.Put("/shares/{token}/ultidraw/document", h.PublicSharePutDocument) +} + +type sessionRequest struct { + Path string `json:"path"` + Mode string `json:"mode"` +} + +func (h *Handler) CreateSession(w http.ResponseWriter, r *http.Request) { + claims := middleware.ClaimsFromContext(r.Context()) + ncUser, err := h.svc.EnsureNextcloudUser(r.Context(), claims) + if err != nil { + apivalidate.WriteInternal(w, r) + return + } + var req sessionRequest + if err := apivalidate.DecodeJSON(w, r, 32<<10, &req); err != nil { + return + } + if strings.TrimSpace(req.Path) == "" { + apivalidate.WriteValidationError(w, r, apivalidate.NewValidationError( + apivalidate.FieldDetail{Field: "path", Message: "required"}, + )) + return + } + mode := strings.TrimSpace(req.Mode) + if mode == "" { + mode = "edit" + } + result, err := h.svc.CreateSession(r.Context(), ncUser, req.Path, mode, claims.Sub, claims.Name) + if err != nil { + h.logger.Error("ultidraw session", "error", err) + apiresponse.WriteError(w, r, http.StatusBadRequest, apiresponse.CodeInvalidRequest, err.Error(), nil) + return + } + apiresponse.WriteJSON(w, http.StatusOK, result) +} + +func (h *Handler) InternalLoadDocument(w http.ResponseWriter, r *http.Request) { + secret := r.Header.Get("X-Hocuspocus-Secret") + if h.svc.Cfg.HocuspocusSecret != "" && secret != h.svc.Cfg.HocuspocusSecret { + http.Error(w, "forbidden", http.StatusForbidden) + return + } + user := strings.TrimSpace(r.URL.Query().Get("user")) + path := strings.TrimSpace(r.URL.Query().Get("path")) + body, err := h.svc.LoadDocumentForUser(r.Context(), user, path) + if err != nil { + http.Error(w, "not found", http.StatusNotFound) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(body) +} + +type hookStorePayload struct { + Room string `json:"room"` + Path string `json:"path"` + User string `json:"user"` + Sub string `json:"sub"` + YjsState string `json:"yjsState"` + Document json.RawMessage `json:"document,omitempty"` +} + +func (h *Handler) HookStore(w http.ResponseWriter, r *http.Request) { + secret := r.Header.Get("X-Hocuspocus-Secret") + if h.svc.Cfg.HocuspocusSecret != "" && secret != h.svc.Cfg.HocuspocusSecret { + http.Error(w, "forbidden", http.StatusForbidden) + return + } + var payload hookStorePayload + if err := apivalidate.DecodeJSON(w, r, 32<<20, &payload); err != nil { + return + } + path := normalizePath(payload.Path) + existingRaw, _ := h.svc.LoadDocumentForUser(r.Context(), payload.User, path) + var existing UltiDrawDoc + if len(existingRaw) > 0 { + if parsed, err := ParseUltiDrawDoc(existingRaw); err == nil { + existing = parsed + } + } + var raw []byte + if len(payload.Document) > 0 { + doc, err := ApplyUltiDrawPatch(existing, payload.Document) + if err != nil { + apivalidate.WriteValidationError(w, r, apivalidate.NewValidationError( + apivalidate.FieldDetail{Field: "document", Message: "invalid JSON"}, + )) + return + } + if !isEmptyElements(doc.Elements) { + doc.YjsState = payload.YjsState + } + if isEmptyElements(doc.Elements) && len(existingRaw) > 0 && !isEmptyElements(existing.Elements) { + w.WriteHeader(http.StatusNoContent) + return + } + raw, err = doc.Marshal() + if err != nil { + apivalidate.WriteInternal(w, r) + return + } + } else if payload.YjsState != "" { + doc := existing + if doc.Type == "" { + doc = NewUltiDrawDoc(nil, nil, nil) + } + if isEmptyElements(doc.Elements) { + doc.YjsState = payload.YjsState + } + if isEmptyElements(doc.Elements) && len(existingRaw) > 0 && !isEmptyElements(existing.Elements) { + w.WriteHeader(http.StatusNoContent) + return + } + var err error + raw, err = doc.Marshal() + if err != nil { + apivalidate.WriteInternal(w, r) + return + } + } else { + apivalidate.WriteValidationError(w, r, apivalidate.NewValidationError( + apivalidate.FieldDetail{Field: "document", Message: "required"}, + )) + return + } + if err := h.svc.SaveDocument(r.Context(), payload.User, path, raw, payload.Sub); err != nil { + h.logger.Error("hook store", "error", err, "path", path) + apivalidate.WriteInternal(w, r) + return + } + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/api/ultidraw/jwt.go b/internal/api/ultidraw/jwt.go new file mode 100644 index 0000000..02b2460 --- /dev/null +++ b/internal/api/ultidraw/jwt.go @@ -0,0 +1,135 @@ +package ultidraw + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "strings" + "time" +) + +type roomTokenPayload struct { + Room string `json:"room"` + Path string `json:"path"` + User string `json:"user"` + Sub string `json:"sub"` + Name string `json:"name"` + Mode string `json:"mode"` + Expires int64 `json:"exp"` +} + +func signRoomToken(payload roomTokenPayload, secret string) (string, error) { + if secret == "" { + return "", nil + } + return signJWT(payload, secret) +} + +func signJWT(payload any, secret string) (string, error) { + if secret == "" { + return "", nil + } + header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"HS256","typ":"JWT"}`)) + bodyBytes, err := json.Marshal(payload) + if err != nil { + return "", err + } + body := base64.RawURLEncoding.EncodeToString(bodyBytes) + mac := hmac.New(sha256.New, []byte(secret)) + _, _ = mac.Write([]byte(header + "." + body)) + sig := base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) + return header + "." + body + "." + sig, nil +} + +func verifyJWT(token, secret string) (map[string]any, error) { + if secret == "" || token == "" { + return nil, fmt.Errorf("missing token or secret") + } + parts := splitJWT(token) + if len(parts) != 3 { + return nil, fmt.Errorf("invalid token") + } + mac := hmac.New(sha256.New, []byte(secret)) + _, _ = mac.Write([]byte(parts[0] + "." + parts[1])) + expected := base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) + if !hmac.Equal([]byte(expected), []byte(parts[2])) { + return nil, fmt.Errorf("invalid signature") + } + raw, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return nil, err + } + var payload map[string]any + if err := json.Unmarshal(raw, &payload); err != nil { + return nil, err + } + return payload, nil +} + +func splitJWT(token string) []string { + var parts []string + start := 0 + for i := 0; i < len(token); i++ { + if token[i] == '.' { + parts = append(parts, token[start:i]) + start = i + 1 + } + } + parts = append(parts, token[start:]) + return parts +} + +func sha256Hex(b []byte) string { + sum := sha256.Sum256(b) + return hexEncode(sum[:]) +} + +func hexEncode(b []byte) string { + const hexdigits = "0123456789abcdef" + out := make([]byte, len(b)*2) + for i, v := range b { + out[i*2] = hexdigits[v>>4] + out[i*2+1] = hexdigits[v&0x0f] + } + return string(out) +} + +func hashPath(p string) string { + h := sha256Hex([]byte(normalizePath(p))) + if len(h) > 16 { + return h[:16] + } + return h +} + +func verifyPublicDocAccess(token, filePath, password, sig, secret string) bool { + if secret == "" { + return true + } + payload, err := verifyJWT(sig, secret) + if err != nil { + return false + } + if payload["token"] != strings.TrimSpace(token) || payload["path"] != normalizePath(filePath) { + return false + } + if pw, _ := payload["password"].(string); pw != password { + return false + } + if exp, ok := payload["exp"].(float64); ok && int64(exp) < time.Now().Unix() { + return false + } + return true +} + +func signPublicDocAccess(token, filePath, password, secret string) (string, error) { + payload := map[string]any{ + "token": strings.TrimSpace(token), + "path": normalizePath(filePath), + "password": password, + "exp": time.Now().Add(2 * time.Hour).Unix(), + } + return signJWT(payload, secret) +} diff --git a/internal/api/ultidraw/paths.go b/internal/api/ultidraw/paths.go new file mode 100644 index 0000000..317ffc7 --- /dev/null +++ b/internal/api/ultidraw/paths.go @@ -0,0 +1,40 @@ +package ultidraw + +import "strings" + +const ExcalidrawExtension = "excalidraw" + +// Config holds UltiDraw editor integration settings. +type Config struct { + Enabled bool + HocuspocusPublicURL string + HocuspocusSecret string + APIInternalURL string +} + +func normalizePath(p string) string { + p = strings.TrimSpace(p) + if p == "" { + return "/" + } + if !strings.HasPrefix(p, "/") { + p = "/" + p + } + return strings.ReplaceAll(p, "//", "/") +} + +func fileNameFromPath(p string) string { + p = normalizePath(p) + if p == "/" { + return "" + } + if i := strings.LastIndex(p, "/"); i >= 0 { + return p[i+1:] + } + return p +} + +func isExcalidrawPath(path string) bool { + lower := strings.ToLower(path) + return strings.HasSuffix(lower, "."+ExcalidrawExtension) || strings.HasSuffix(lower, ".excalidraw.json") +} diff --git a/internal/api/ultidraw/public_handlers.go b/internal/api/ultidraw/public_handlers.go new file mode 100644 index 0000000..17ee3d0 --- /dev/null +++ b/internal/api/ultidraw/public_handlers.go @@ -0,0 +1,140 @@ +package ultidraw + +import ( + "io" + "net/http" + "strings" + + "github.com/go-chi/chi/v5" + + "github.com/ultisuite/ulti-backend/internal/api/apiresponse" + "github.com/ultisuite/ulti-backend/internal/api/apivalidate" + "github.com/ultisuite/ulti-backend/internal/nextcloud" +) + +type publicSessionRequest struct { + Path string `json:"path"` + Mode string `json:"mode"` + Password string `json:"password"` + GuestID string `json:"guest_id"` + GuestName string `json:"guest_name"` + DisplayName string `json:"display_name"` +} + +func (h *Handler) PublicShareSession(w http.ResponseWriter, r *http.Request) { + token := strings.TrimSpace(chi.URLParam(r, "token")) + if token == "" { + apivalidate.WriteNotFound(w, r, "not found") + return + } + var req publicSessionRequest + if err := apivalidate.DecodeJSON(w, r, 32<<10, &req); err != nil { + return + } + if strings.TrimSpace(req.Path) == "" { + apivalidate.WriteValidationError(w, r, apivalidate.NewValidationError( + apivalidate.FieldDetail{Field: "path", Message: "required"}, + )) + return + } + password := strings.TrimSpace(req.Password) + perms, err := h.svc.EffectivePublicSharePermissions(r.Context(), token, req.Path, password) + if err != nil { + apivalidate.WriteInternal(w, r) + return + } + if !nextcloud.PublicShareCanRead(perms) { + http.Error(w, "forbidden", http.StatusForbidden) + return + } + mode := strings.TrimSpace(req.Mode) + if mode == "" { + mode = "edit" + } + if mode == "edit" && !nextcloud.PublicShareCanUpdate(perms) { + mode = "view" + } + + guestID := strings.TrimSpace(req.GuestID) + if guestID == "" { + guestID = "public-guest" + } else { + guestID = "public:" + guestID + } + guestName := strings.TrimSpace(req.GuestName) + if guestName == "" { + guestName = "Invité" + } + + result, err := h.svc.CreatePublicSession(r.Context(), token, req.Path, mode, password, guestID, guestName, strings.TrimSpace(req.DisplayName)) + if err != nil { + h.logger.Error("public ultidraw session", "error", err) + apivalidate.WriteInternal(w, r) + return + } + result.Mode = mode + apiresponse.WriteJSON(w, http.StatusOK, result) +} + +func (h *Handler) PublicShareDocument(w http.ResponseWriter, r *http.Request) { + token := strings.TrimSpace(chi.URLParam(r, "token")) + path := strings.TrimSpace(r.URL.Query().Get("path")) + password := strings.TrimSpace(r.URL.Query().Get("password")) + sig := strings.TrimSpace(r.URL.Query().Get("sig")) + if h.svc.Cfg.HocuspocusSecret != "" && !verifyPublicDocAccess(token, path, password, sig, h.svc.Cfg.HocuspocusSecret) { + http.Error(w, "forbidden", http.StatusForbidden) + return + } + body, err := h.svc.LoadPublicDocumentLegacy(r.Context(), token, path, password) + if err != nil { + http.Error(w, "not found", http.StatusNotFound) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(body) +} + +func (h *Handler) PublicSharePutDocument(w http.ResponseWriter, r *http.Request) { + token := strings.TrimSpace(chi.URLParam(r, "token")) + path := strings.TrimSpace(r.URL.Query().Get("path")) + password := strings.TrimSpace(r.URL.Query().Get("password")) + sig := strings.TrimSpace(r.URL.Query().Get("sig")) + if h.svc.Cfg.HocuspocusSecret != "" && !verifyPublicDocAccess(token, path, password, sig, h.svc.Cfg.HocuspocusSecret) { + http.Error(w, "forbidden", http.StatusForbidden) + return + } + perms, err := h.svc.EffectivePublicSharePermissions(r.Context(), token, path, password) + if err != nil || !nextcloud.PublicShareCanUpdate(perms) { + http.Error(w, "forbidden", http.StatusForbidden) + return + } + raw, err := io.ReadAll(r.Body) + if err != nil { + apivalidate.WriteInternal(w, r) + return + } + existingRaw, loadErr := h.svc.LoadPublicDocumentLegacy(r.Context(), token, path, password) + var existing UltiDrawDoc + if loadErr == nil && len(existingRaw) > 0 { + if parsed, parseErr := ParseUltiDrawDoc(existingRaw); parseErr == nil { + existing = parsed + } + } + doc, err := ApplyUltiDrawPatch(existing, raw) + if err != nil { + apivalidate.WriteValidationError(w, r, apivalidate.NewValidationError( + apivalidate.FieldDetail{Field: "document", Message: "invalid JSON"}, + )) + return + } + payload, err := doc.Marshal() + if err != nil { + apivalidate.WriteInternal(w, r) + return + } + if err := h.svc.SavePublicDocumentLegacy(r.Context(), token, path, password, payload); err != nil { + http.Error(w, "save failed", http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/api/ultidraw/public_share.go b/internal/api/ultidraw/public_share.go new file mode 100644 index 0000000..0247066 --- /dev/null +++ b/internal/api/ultidraw/public_share.go @@ -0,0 +1,162 @@ +package ultidraw + +import ( + "context" + "fmt" + "io" + "net/url" + "strings" + "time" + + "github.com/ultisuite/ulti-backend/internal/nextcloud" +) + +func (s *Service) CreatePublicSession(ctx context.Context, token, filePath, mode, password, guestID, guestName, displayName string) (*PublicSessionResult, error) { + if !s.Cfg.Enabled { + return nil, fmt.Errorf("ultidraw editor disabled") + } + resolvedPath, err := s.resolvePublicFilePath(ctx, token, filePath, password, displayName) + if err != nil { + return nil, err + } + filePath = resolvedPath + if mode == "" { + mode = "edit" + } + + ownerID, ownerPath, err := s.ownerPathForPublic(ctx, token, password, filePath, displayName) + if err != nil { + return nil, err + } + roomID, err := s.resolveCollabRoomID(ctx, ownerID, ownerPath) + if err != nil { + return nil, err + } + + tokenJWT, err := signRoomToken(roomTokenPayload{ + Room: roomID, + Path: filePath, + User: "public:" + token, + Sub: guestID, + Name: guestName, + Mode: mode, + Expires: time.Now().Add(8 * time.Hour).Unix(), + }, s.Cfg.HocuspocusSecret) + if err != nil { + return nil, err + } + + apiBase := strings.TrimRight(s.Cfg.APIInternalURL, "/") + sig, _ := signPublicDocAccess(token, filePath, password, s.Cfg.HocuspocusSecret) + docURL := fmt.Sprintf("%s/api/v1/drive/public/shares/%s/ultidraw/document?path=%s&password=%s&sig=%s", + apiBase, url.PathEscape(token), url.QueryEscape(filePath), url.QueryEscape(password), url.QueryEscape(sig)) + saveURL := docURL + + wsURL := strings.TrimSpace(s.Cfg.HocuspocusPublicURL) + collab := wsURL != "" && s.Cfg.HocuspocusSecret != "" + + return &PublicSessionResult{ + SessionResult: SessionResult{ + RoomID: roomID, + CanonicalPath: filePath, + WsURL: wsURL, + Token: tokenJWT, + Mode: mode, + Collaboration: collab, + }, + DocumentURL: docURL, + SaveURL: saveURL, + }, nil +} + +func (s *Service) resolvePublicFilePath(ctx context.Context, token, filePath, password, displayName string) (string, error) { + filePath = normalizePath(filePath) + if filePath == "/" { + filePath = s.publicClientSourcePath(ctx, token, password, filePath, displayName) + } + checkPath := filePath + if !isExcalidrawPath(checkPath) && strings.TrimSpace(displayName) != "" { + checkPath = normalizePath("/" + strings.TrimSpace(displayName)) + } + if !isExcalidrawPath(checkPath) { + return "", fmt.Errorf("not an excalidraw file") + } + if !isExcalidrawPath(filePath) { + filePath = checkPath + } + if _, err := s.publicFileExists(ctx, token, filePath, password); err != nil { + if checkPath != filePath { + if _, err2 := s.publicFileExists(ctx, token, checkPath, password); err2 != nil { + return "", fmt.Errorf("file not found") + } + filePath = checkPath + } else { + return "", fmt.Errorf("file not found") + } + } + return filePath, nil +} + +func (s *Service) publicClientSourcePath(ctx context.Context, token, password, clientPath, displayName string) string { + binding, err := s.nc.ResolvePublicShareBinding(ctx, token, password) + if err == nil { + return binding.ClientSourcePath(clientPath, displayName) + } + if name := strings.TrimSpace(displayName); name != "" { + return normalizePath("/" + name) + } + return clientPath +} + +func (s *Service) publicFileExists(ctx context.Context, token, path, password string) (bool, error) { + _, err := s.nc.PublicShareFileRevision(ctx, token, path, password) + if err != nil { + return false, err + } + return true, nil +} + +func (s *Service) LoadPublicDocument(ctx context.Context, token, clientPath, password, displayName string) ([]byte, error) { + if binding, err := s.nc.ResolvePublicShareBinding(ctx, token, password); err == nil { + ownerPath := binding.OwnerPathForClient(clientPath, displayName) + body, _, err := s.nc.Download(ctx, binding.OwnerID, ownerPath) + if err == nil { + defer body.Close() + return io.ReadAll(body) + } + } + body, _, err := s.nc.DownloadPublicShare(ctx, token, clientPath, password) + if err != nil { + return nil, err + } + defer body.Close() + return io.ReadAll(body) +} + +func (s *Service) SavePublicDocument(ctx context.Context, token, clientPath, password, displayName string, raw []byte) error { + if binding, err := s.nc.ResolvePublicShareBinding(ctx, token, password); err == nil { + ownerPath := binding.OwnerPathForClient(clientPath, displayName) + reader := strings.NewReader(string(raw)) + if err := s.nc.Upload(ctx, binding.OwnerID, ownerPath, reader, "application/json"); err == nil { + return nil + } + } + reader := strings.NewReader(string(raw)) + return s.nc.UploadPublicShare(ctx, token, clientPath, password, reader, "application/json") +} + +func (s *Service) LoadPublicDocumentLegacy(ctx context.Context, token, path, password string) ([]byte, error) { + return s.LoadPublicDocument(ctx, token, path, password, "") +} + +func (s *Service) SavePublicDocumentLegacy(ctx context.Context, token, path, password string, raw []byte) error { + return s.SavePublicDocument(ctx, token, path, password, "", raw) +} + +func (s *Service) EffectivePublicSharePermissions(ctx context.Context, token, path, password string) (int, error) { + return s.nc.EffectivePublicSharePermissions(ctx, token, path, password) +} + +func (s *Service) PublicShareCanUpdate(perms int) bool { + return nextcloud.PublicShareCanUpdate(perms) +} diff --git a/internal/api/ultidraw/service.go b/internal/api/ultidraw/service.go new file mode 100644 index 0000000..d7bf764 --- /dev/null +++ b/internal/api/ultidraw/service.go @@ -0,0 +1,127 @@ +package ultidraw + +import ( + "bytes" + "context" + "fmt" + "io" + "strings" + "time" + + "github.com/ultisuite/ulti-backend/internal/auth" + "github.com/ultisuite/ulti-backend/internal/nextcloud" +) + +type Service struct { + nc *nextcloud.Client + Cfg Config + hub fileChangePublisher +} + +type fileChangePublisher interface { + PublishFileChanged(platformUserID, path string) +} + +func NewService(nc *nextcloud.Client, cfg Config, hub fileChangePublisher) *Service { + return &Service{nc: nc, Cfg: cfg, hub: hub} +} + +func (s *Service) EnsureNextcloudUser(ctx context.Context, claims *auth.Claims) (string, error) { + return s.nc.EnsurePrincipal(ctx, claims.Email, claims.Sub, claims.Name) +} + +type SessionResult struct { + RoomID string `json:"roomId"` + CanonicalPath string `json:"canonicalPath"` + WsURL string `json:"wsUrl"` + Token string `json:"token"` + Mode string `json:"mode"` + Collaboration bool `json:"collaboration"` +} + +type PublicSessionResult struct { + SessionResult + DocumentURL string `json:"documentUrl,omitempty"` + SaveURL string `json:"saveUrl,omitempty"` +} + +func (s *Service) CreateSession(ctx context.Context, ncUser, filePath, mode, editorUserID, editorName string) (*SessionResult, error) { + if !s.Cfg.Enabled { + return nil, fmt.Errorf("ultidraw editor disabled") + } + filePath = normalizePath(filePath) + if !isExcalidrawPath(filePath) { + return nil, fmt.Errorf("not an excalidraw file: %s", filePath) + } + if _, err := s.nc.FileRevision(ctx, ncUser, filePath); err != nil { + return nil, fmt.Errorf("file not found: %s", filePath) + } + if mode == "" { + mode = "edit" + } + + roomID, err := s.resolveCollabRoomID(ctx, ncUser, filePath) + if err != nil { + return nil, err + } + + token, err := signRoomToken(roomTokenPayload{ + Room: roomID, + Path: filePath, + User: ncUser, + Sub: editorUserID, + Name: editorName, + Mode: mode, + Expires: time.Now().Add(8 * time.Hour).Unix(), + }, s.Cfg.HocuspocusSecret) + if err != nil { + return nil, err + } + + wsURL := strings.TrimSpace(s.Cfg.HocuspocusPublicURL) + collab := wsURL != "" && s.Cfg.HocuspocusSecret != "" + + return &SessionResult{ + RoomID: roomID, + CanonicalPath: filePath, + WsURL: wsURL, + Token: token, + Mode: mode, + Collaboration: collab, + }, nil +} + +func (s *Service) LoadDocument(ctx context.Context, ncUser, path string) ([]byte, error) { + path = normalizePath(path) + body, _, err := s.nc.Download(ctx, ncUser, path) + if err != nil { + return nil, err + } + defer body.Close() + return io.ReadAll(body) +} + +func (s *Service) LoadDocumentForUser(ctx context.Context, ncUser, path string) ([]byte, error) { + path = normalizePath(path) + if strings.HasPrefix(ncUser, "public:") { + token := strings.TrimPrefix(ncUser, "public:") + return s.LoadPublicDocumentLegacy(ctx, token, path, "") + } + return s.LoadDocument(ctx, ncUser, path) +} + +func (s *Service) SaveDocument(ctx context.Context, ncUser, path string, raw []byte, platformUserID string) error { + path = normalizePath(path) + reader := bytes.NewReader(raw) + if strings.HasPrefix(ncUser, "public:") { + token := strings.TrimPrefix(ncUser, "public:") + return s.SavePublicDocumentLegacy(ctx, token, path, "", raw) + } + if err := s.nc.Upload(ctx, ncUser, path, reader, "application/json"); err != nil { + return err + } + if s.hub != nil && platformUserID != "" { + s.hub.PublishFileChanged(platformUserID, path) + } + return nil +} diff --git a/internal/apitokens/chat_session.go b/internal/apitokens/chat_session.go new file mode 100644 index 0000000..baf0b58 --- /dev/null +++ b/internal/apitokens/chat_session.go @@ -0,0 +1,88 @@ +package apitokens + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +type ChatSessionPreset string + +const ( + ChatSessionMail ChatSessionPreset = "mail" + ChatSessionDrive ChatSessionPreset = "drive" + ChatSessionContacts ChatSessionPreset = "contacts" + ChatSessionDocs ChatSessionPreset = "docs" + ChatSessionStandalone ChatSessionPreset = "standalone" +) + +type ChatSessionInput struct { + Preset ChatSessionPreset + DrivePath string + AllowWrite bool + TTL time.Duration +} + +func CreateChatSession(ctx context.Context, db *pgxpool.Pool, externalID, email string, in ChatSessionInput) (CreatedToken, error) { + if in.TTL <= 0 { + in.TTL = 8 * time.Hour + } + expiresAt := time.Now().UTC().Add(in.TTL) + perms, mailScope, driveScope := chatSessionGrants(in) + name := fmt.Sprintf("UltiAI session %s", time.Now().UTC().Format("2006-01-02 15:04")) + return Create(ctx, db, externalID, name, perms, mailScope, driveScope, &expiresAt) +} + +func chatSessionGrants(in ChatSessionInput) ([]PermissionGrant, MailScope, DriveScope) { + mailScope := MailScope{AllAccounts: true} + driveScope := DriveScope{AllFolders: true} + if strings.TrimSpace(in.DrivePath) != "" { + driveScope = DriveScope{ + AllFolders: false, + FolderPaths: []string{in.DrivePath}, + } + } + + switch in.Preset { + case ChatSessionMail: + return []PermissionGrant{ + {Resource: "mail.messages", Read: true}, + {Resource: "mail.search", Read: true}, + {Resource: "contacts.read", Read: true}, + {Resource: "automation.chat", Read: true}, + }, mailScope, driveScope + case ChatSessionDrive: + return []PermissionGrant{ + {Resource: "drive.files", Read: true, Write: in.AllowWrite}, + {Resource: "automation.chat", Read: true}, + }, mailScope, driveScope + case ChatSessionContacts: + return []PermissionGrant{ + {Resource: "contacts.read", Read: true}, + {Resource: "contacts.search", Read: true}, + {Resource: "mail.search", Read: true}, + {Resource: "automation.chat", Read: true}, + }, mailScope, driveScope + case ChatSessionDocs: + return []PermissionGrant{ + {Resource: "drive.files", Read: true, Write: in.AllowWrite}, + {Resource: "drive.download", Read: true}, + {Resource: "automation.chat", Read: true}, + }, mailScope, driveScope + default: + return []PermissionGrant{ + {Resource: "mail.messages", Read: true}, + {Resource: "mail.search", Read: true}, + {Resource: "mail.send", Write: true}, + {Resource: "mail.labels", Read: true, Write: true}, + {Resource: "drive.files", Read: true, Write: true}, + {Resource: "contacts.read", Read: true}, + {Resource: "contacts.search", Read: true}, + {Resource: "automation.search", Read: true}, + {Resource: "automation.chat", Read: true}, + }, mailScope, driveScope + } +} diff --git a/internal/apitokens/chat_session_test.go b/internal/apitokens/chat_session_test.go new file mode 100644 index 0000000..dc6a71d --- /dev/null +++ b/internal/apitokens/chat_session_test.go @@ -0,0 +1,54 @@ +package apitokens + +import "testing" + +func TestChatSessionGrantsMail(t *testing.T) { + perms, _, _ := chatSessionGrants(ChatSessionInput{Preset: ChatSessionMail}) + if len(perms) == 0 { + t.Fatal("expected grants") + } + found := false + for _, p := range perms { + if p.Resource == "mail.messages" && p.Read { + found = true + } + } + if !found { + t.Fatal("expected mail.messages read grant") + } +} + +func TestChatSessionGrantsDocs(t *testing.T) { + perms, _, drive := chatSessionGrants(ChatSessionInput{ + Preset: ChatSessionDocs, + DrivePath: "/Docs/note.ultidoc", + AllowWrite: true, + }) + foundRead := false + foundWrite := false + for _, p := range perms { + if p.Resource == "drive.files" && p.Read { + foundRead = true + foundWrite = p.Write + } + } + if !foundRead || !foundWrite { + t.Fatalf("expected drive.files read+write: %+v", perms) + } + if drive.AllFolders || len(drive.FolderPaths) != 1 { + t.Fatalf("unexpected drive scope: %+v", drive) + } +} + +func TestChatSessionGrantsDriveScoped(t *testing.T) { + _, _, drive := chatSessionGrants(ChatSessionInput{ + Preset: ChatSessionDrive, + DrivePath: "/docs", + }) + if drive.AllFolders { + t.Fatal("expected folder scope") + } + if len(drive.FolderPaths) != 1 || drive.FolderPaths[0] != "/docs" { + t.Fatalf("unexpected drive scope: %+v", drive) + } +} diff --git a/internal/apitokens/policy.go b/internal/apitokens/policy.go index 74e0cfc..23b075d 100644 --- a/internal/apitokens/policy.go +++ b/internal/apitokens/policy.go @@ -31,6 +31,21 @@ func RequirementForRequest(method, fullPath, typesQuery string) (Requirement, bo write := method != http.MethodGet && method != http.MethodHead switch { + case strings.HasPrefix(path, "/api/v1/ai/chat/completions"), + strings.HasPrefix(path, "/api/v1/ai/v1/chat/completions"): + return Requirement{Resource: "automation.chat", Write: true}, true + case strings.HasPrefix(path, "/api/v1/ai/sessions"), + strings.HasPrefix(path, "/api/v1/ai/chats/sync"): + return Requirement{Resource: "automation.chat", Write: true}, true + case strings.HasPrefix(path, "/api/v1/ai/chats/"): + if write || method == http.MethodDelete { + return Requirement{Resource: "automation.chat", Write: true}, true + } + return Requirement{Resource: "automation.chat", Write: false}, true + case strings.HasPrefix(path, "/api/v1/ai/quota"), + strings.HasPrefix(path, "/api/v1/ai/models"): + return Requirement{Resource: "automation.chat", Write: false}, true + case strings.HasPrefix(path, "/api/v1/mail/api-tokens"): return Requirement{Resource: "automation.api_tokens", Write: write || method == http.MethodDelete}, true case strings.HasPrefix(path, "/api/v1/mail/webhooks"): @@ -67,6 +82,9 @@ func RequirementForRequest(method, fullPath, typesQuery string) (Requirement, bo case strings.HasPrefix(path, "/api/v1/drive/"): return driveRequirement(method, path) + case strings.HasPrefix(path, "/api/v1/richtext/"): + return richtextRequirement(method, path) + case strings.HasPrefix(path, "/api/v1/search"): return searchRequirement(typesQuery) @@ -130,6 +148,18 @@ func mailRequirement(method, path string) (Requirement, bool) { } } +func richtextRequirement(method, path string) (Requirement, bool) { + write := method != http.MethodGet && method != http.MethodHead + switch { + case strings.HasSuffix(path, "/save"), + strings.HasSuffix(path, "/assets"), + strings.HasSuffix(path, "/user-paragraph-styles"): + return Requirement{Resource: "drive.files", Write: true}, true + default: + return Requirement{Resource: "drive.files", Write: write}, true + } +} + func driveRequirement(method, path string) (Requirement, bool) { write := method != http.MethodGet && method != http.MethodHead diff --git a/internal/apitokens/policy_ai_test.go b/internal/apitokens/policy_ai_test.go new file mode 100644 index 0000000..d35d211 --- /dev/null +++ b/internal/apitokens/policy_ai_test.go @@ -0,0 +1,26 @@ +package apitokens + +import ( + "net/http" + "testing" +) + +func TestRequirementForAIChatCompletions(t *testing.T) { + req, ok := RequirementForRequest(http.MethodPost, "/api/v1/ai/chat/completions", "") + if !ok { + t.Fatal("expected requirement") + } + if req.Resource != "automation.chat" || !req.Write { + t.Fatalf("unexpected requirement: %+v", req) + } +} + +func TestRequirementForAIQuotaRead(t *testing.T) { + req, ok := RequirementForRequest(http.MethodGet, "/api/v1/ai/quota", "") + if !ok { + t.Fatal("expected requirement") + } + if req.Resource != "automation.chat" || req.Write { + t.Fatalf("unexpected requirement: %+v", req) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 702d5bd..a258e50 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -76,6 +76,12 @@ type Config struct { RichTextStorageMode string RichTextExportMirror string + // AI assistant (OpenWebUI + Ulti gateway) + AIAssistantEnabled bool + OpenWebUIInternalURL string + AIAssistantPublicPath string + UltimailMCPURL string + // Jitsi JitsiEnabled bool JitsiDomain string @@ -194,6 +200,11 @@ func Load() (*Config, error) { RichTextStorageMode: envOrDefault("RICHTEXT_STORAGE_MODE", "sidecar"), RichTextExportMirror: envOrDefault("RICHTEXT_EXPORT_MIRROR", ""), + AIAssistantEnabled: envBool("AI_ASSISTANT_ENABLED", false), + OpenWebUIInternalURL: envOrDefault("OPENWEBUI_URL", "http://openwebui:8080"), + AIAssistantPublicPath: envOrDefault("AI_ASSISTANT_PUBLIC_PATH", "/ai"), + UltimailMCPURL: envOrDefault("ULTIMAIL_MCP_URL", "http://ultimail-mcp:3100"), + JitsiEnabled: envBool("JITSI_ENABLED", true), JitsiDomain: envOrDefault("JITSI_DOMAIN", "meet.jitsi"), JitsiAppID: envOrDefault("JITSI_APP_ID", "ulti"), diff --git a/internal/nextcloud/ultichat_paths.go b/internal/nextcloud/ultichat_paths.go new file mode 100644 index 0000000..93d08e7 --- /dev/null +++ b/internal/nextcloud/ultichat_paths.go @@ -0,0 +1,27 @@ +package nextcloud + +import "strings" + +const ( + UltichatSidecarSuffix = ".ultichat.json" + DefaultChatNCBasePath = "/.ultimail/ai/chats" +) + +// ChatSidecarPath returns the WebDAV path for a chat history sidecar. +func ChatSidecarPath(basePath, chatID string) string { + basePath = NormalizeClientPath(basePath) + if basePath == "" || basePath == "/" { + basePath = DefaultChatNCBasePath + } + basePath = strings.TrimSuffix(basePath, "/") + id := strings.TrimSpace(chatID) + if id == "" { + return basePath + "/" + } + return basePath + "/" + id + UltichatSidecarSuffix +} + +// IsUltichatSidecarPath reports whether path ends with .ultichat.json. +func IsUltichatSidecarPath(path string) bool { + return strings.HasSuffix(strings.ToLower(strings.TrimSpace(path)), UltichatSidecarSuffix) +} diff --git a/internal/nextcloud/ultichat_paths_test.go b/internal/nextcloud/ultichat_paths_test.go new file mode 100644 index 0000000..e6e6df0 --- /dev/null +++ b/internal/nextcloud/ultichat_paths_test.go @@ -0,0 +1,20 @@ +package nextcloud + +import "testing" + +func TestChatSidecarPath(t *testing.T) { + got := ChatSidecarPath("/.ultimail/ai/chats", "abc-123") + want := "/.ultimail/ai/chats/abc-123.ultichat.json" + if got != want { + t.Fatalf("ChatSidecarPath() = %q, want %q", got, want) + } +} + +func TestIsUltichatSidecarPath(t *testing.T) { + if !IsUltichatSidecarPath("/.ultimail/ai/chats/foo.ultichat.json") { + t.Fatal("expected ultichat sidecar path") + } + if IsUltichatSidecarPath("/docs/report.ultidoc.json") { + t.Fatal("ultidoc path should not match ultichat") + } +} diff --git a/internal/nextcloud/ultidoc_paths.go b/internal/nextcloud/ultidoc_paths.go new file mode 100644 index 0000000..13c6404 --- /dev/null +++ b/internal/nextcloud/ultidoc_paths.go @@ -0,0 +1,29 @@ +package nextcloud + +import "strings" + +// SidecarPathForSource maps a source document to its TipTap sidecar path, +// e.g. /docs/report.docx → /docs/report.ultidoc.json. +func SidecarPathForSource(sourcePath string) string { + sourcePath = NormalizeClientPath(sourcePath) + dir := "/" + name := strings.TrimPrefix(sourcePath, "/") + if i := strings.LastIndex(name, "/"); i >= 0 { + dir = "/" + name[:i] + name = name[i+1:] + } + base := name + if dot := strings.LastIndex(name, "."); dot > 0 { + base = name[:dot] + } + sidecar := base + ultidocSidecarSuffix + if dir == "/" { + return "/" + sidecar + } + return dir + "/" + sidecar +} + +// IsUltidocSidecarPath reports whether path ends with .ultidoc.json. +func IsUltidocSidecarPath(path string) bool { + return IsUltidocSidecarName(fileNameFromPath(path)) +} diff --git a/internal/nextcloud/ultidoc_paths_test.go b/internal/nextcloud/ultidoc_paths_test.go new file mode 100644 index 0000000..306ed6f --- /dev/null +++ b/internal/nextcloud/ultidoc_paths_test.go @@ -0,0 +1,28 @@ +package nextcloud + +import "testing" + +func TestSidecarPathForSource(t *testing.T) { + tests := []struct { + source string + sidecar string + }{ + {"/docs/report.docx", "/docs/report.ultidoc.json"}, + {"docs/report.docx", "/docs/report.ultidoc.json"}, + {"/report.docx", "/report.ultidoc.json"}, + } + for _, tt := range tests { + if got := SidecarPathForSource(tt.source); got != tt.sidecar { + t.Fatalf("SidecarPathForSource(%q) = %q, want %q", tt.source, got, tt.sidecar) + } + } +} + +func TestIsUltidocSidecarPath(t *testing.T) { + if !IsUltidocSidecarPath("/docs/report.ultidoc.json") { + t.Fatal("expected sidecar path") + } + if IsUltidocSidecarPath("/docs/report.docx") { + t.Fatal("docx is not a sidecar path") + } +} diff --git a/internal/server/bootstrap.go b/internal/server/bootstrap.go index 250ceb1..2875109 100644 --- a/internal/server/bootstrap.go +++ b/internal/server/bootstrap.go @@ -15,6 +15,7 @@ import ( "github.com/prometheus/client_golang/prometheus/promhttp" "github.com/redis/go-redis/v9" + aiapi "github.com/ultisuite/ulti-backend/internal/api/ai" "github.com/ultisuite/ulti-backend/internal/api/admin" "github.com/ultisuite/ulti-backend/internal/api/calendar" "github.com/ultisuite/ulti-backend/internal/api/contacts" @@ -27,6 +28,7 @@ import ( "github.com/ultisuite/ulti-backend/internal/api/middleware" "github.com/ultisuite/ulti-backend/internal/api/office" "github.com/ultisuite/ulti-backend/internal/api/richtext" + "github.com/ultisuite/ulti-backend/internal/api/ultidraw" photosapi "github.com/ultisuite/ulti-backend/internal/api/photos" usersapi "github.com/ultisuite/ulti-backend/internal/api/users" "github.com/ultisuite/ulti-backend/internal/automation" @@ -300,11 +302,24 @@ func New(ctx context.Context, cfg *config.Config, opts Options) (*App, error) { rtHandler := richtext.NewHandler(rtSvc, driveSvc) r.Mount("/api/v1/richtext", rtHandler.Routes(middleware.Auth(verifierHolder, pool, auditLogger, orgPolicyLoader))) driveHandler.SetPublicRichText(rtHandler) + + udSvc := ultidraw.NewService(ncClient, ultidraw.Config{ + Enabled: true, + HocuspocusPublicURL: cfg.HocuspocusPublicURL, + HocuspocusSecret: cfg.HocuspocusSecret, + APIInternalURL: cfg.OnlyOfficeAPIInternalURL, + }, driveSvc) + udHandler := ultidraw.NewHandler(udSvc) + r.Mount("/api/v1/ultidraw", udHandler.Routes(middleware.Auth(verifierHolder, pool, auditLogger, orgPolicyLoader))) + driveHandler.SetPublicUltidraw(udHandler) } if driveHandler != nil { r.Mount("/api/v1/drive/public", driveHandler.PublicRoutes()) } + aiHandler := aiapi.NewHandler(pool, cfg, ncClient, verifierHolder) + r.Mount("/api/v1/ai", aiHandler.Routes(middleware.Auth(verifierHolder, pool, auditLogger, orgPolicyLoader))) + r.Group(func(r chi.Router) { r.Use(middleware.Auth(verifierHolder, pool, auditLogger, orgPolicyLoader)) r.Use(middleware.EnforceApiTokenPolicy()) diff --git a/migrations/000035_ai_assistant.down.sql b/migrations/000035_ai_assistant.down.sql new file mode 100644 index 0000000..14ea863 --- /dev/null +++ b/migrations/000035_ai_assistant.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS ai_usage_monthly; +DROP TABLE IF EXISTS ai_usage_daily; diff --git a/migrations/000035_ai_assistant.up.sql b/migrations/000035_ai_assistant.up.sql new file mode 100644 index 0000000..b980694 --- /dev/null +++ b/migrations/000035_ai_assistant.up.sql @@ -0,0 +1,16 @@ +CREATE TABLE IF NOT EXISTS ai_usage_daily ( + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + usage_date DATE NOT NULL DEFAULT CURRENT_DATE, + requests INT NOT NULL DEFAULT 0, + tokens BIGINT NOT NULL DEFAULT 0, + PRIMARY KEY (user_id, usage_date) +); + +CREATE TABLE IF NOT EXISTS ai_usage_monthly ( + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + usage_month DATE NOT NULL, + tokens BIGINT NOT NULL DEFAULT 0, + PRIMARY KEY (user_id, usage_month) +); + +CREATE INDEX IF NOT EXISTS ai_usage_daily_date_idx ON ai_usage_daily(usage_date); diff --git a/services/hocuspocus/package.json b/services/hocuspocus/package.json index 763bb85..e484fb7 100644 --- a/services/hocuspocus/package.json +++ b/services/hocuspocus/package.json @@ -22,6 +22,7 @@ "@tiptap/extension-text-style": "^3.23.2", "@tiptap/extension-underline": "^3.23.2", "@tiptap/starter-kit": "^3.23.2", + "fractional-indexing": "^3.2.0", "yjs": "^13.6.27" } } diff --git a/services/hocuspocus/pnpm-lock.yaml b/services/hocuspocus/pnpm-lock.yaml index 756b383..578148f 100644 --- a/services/hocuspocus/pnpm-lock.yaml +++ b/services/hocuspocus/pnpm-lock.yaml @@ -50,6 +50,9 @@ importers: '@tiptap/starter-kit': specifier: ^3.23.2 version: 3.26.0 + fractional-indexing: + specifier: ^3.2.0 + version: 3.2.0 yjs: specifier: ^13.6.27 version: 13.6.31 @@ -252,6 +255,10 @@ packages: srvx: optional: true + fractional-indexing@3.2.0: + resolution: {integrity: sha512-PcOxmqwYCW7O2ovKRU8OoQQj2yqTfEB/yeTYk4gPid6dN5ODRfU1hXd9tTVZzax/0NkO7AxpHykvZnT1aYp/BQ==} + engines: {node: ^14.13.1 || >=16.0.0} + isomorphic.js@0.2.5: resolution: {integrity: sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==} @@ -544,6 +551,8 @@ snapshots: crossws@0.4.5: {} + fractional-indexing@3.2.0: {} + isomorphic.js@0.2.5: {} kleur@4.1.5: {} diff --git a/services/hocuspocus/server.mjs b/services/hocuspocus/server.mjs index f2ac19f..f7172e9 100644 --- a/services/hocuspocus/server.mjs +++ b/services/hocuspocus/server.mjs @@ -15,6 +15,7 @@ const TextAlign = require("@tiptap/extension-text-align").default const { Table, TableRow, TableCell, TableHeader } = require("@tiptap/extension-table") const Image = require("@tiptap/extension-image").default +/** Keep in sync with lib/drive/extensions/docs-graphic.ts graphicAttributes */ const graphicAttributes = { graphicType: { default: "image" }, src: { default: null }, @@ -34,6 +35,12 @@ const graphicAttributes = { floatSide: { default: "left" }, x: { default: 0 }, y: { default: 0 }, + positionMode: { default: "move-with-text" }, + anchorPos: { default: -1 }, + pageIndex: { default: 0 }, + pageX: { default: 0 }, + pageY: { default: 0 }, + wrapMarginMm: { default: 3 }, rotationDeg: { default: 0 }, zIndex: { default: 0 }, cropX: { default: 0 }, @@ -41,9 +48,18 @@ const graphicAttributes = { cropWidth: { default: 1 }, cropHeight: { default: 1 }, cropShape: { default: "rect" }, + lockAspectRatio: { default: true }, + imageFit: { default: "contain" }, + imageFitAnchorH: { default: 0.5 }, + imageFitAnchorV: { default: 0.5 }, assetId: { default: null }, opacity: { default: 1 }, shadow: { default: "" }, + brightness: { default: 0 }, + contrast: { default: 0 }, + recolor: { default: "" }, + altTitle: { default: "" }, + drawScene: { default: null }, } const DocsGraphic = Node.create({ @@ -131,6 +147,115 @@ function tipTapContentHasBody(content) { return walk(content) } +function yjsToExcalidrawElements(yArray) { + if (!yArray || yArray.length === 0) return [] + return yArray + .toArray() + .sort((a, b) => { + const key1 = a.get("pos") + const key2 = b.get("pos") + return key1 > key2 ? 1 : key1 < key2 ? -1 : 0 + }) + .map((x) => x.get("el")) + .filter((el) => el && typeof el.id === "string" && typeof el.type === "string") +} + +function exportUltidrawScene(ydoc) { + const yElements = ydoc.getArray("elements") + const yAssets = ydoc.getMap("assets") + const elements = yjsToExcalidrawElements(yElements) + const files = {} + yAssets.forEach((value, key) => { + files[key] = value + }) + return { + elements, + appState: { gridSize: null, viewBackgroundColor: "#ffffff" }, + files, + } +} + +function seedYdocFromJson(ydoc, elements, files, generateNKeysBetween) { + const yElements = ydoc.getArray("elements") + const yAssets = ydoc.getMap("assets") + if (!Array.isArray(elements) || elements.length === 0 || yElements.length > 0) return + const keys = generateNKeysBetween(null, null, elements.length) + ydoc.transact(() => { + for (let i = 0; i < elements.length; i++) { + const el = elements[i] + if (!el || typeof el.id !== "string") continue + yElements.push([ + new Y.Map(Object.entries({ pos: keys[i], el: { ...el } })), + ]) + } + if (files && typeof files === "object") { + for (const [id, asset] of Object.entries(files)) { + yAssets.set(id, asset) + } + } + }) +} + +async function loadFromUltidraw(context) { + if (!context?.path || !context?.user) return null + const params = new URLSearchParams({ user: context.user, path: context.path }) + const res = await fetch(`${ULTID_URL}/api/v1/ultidraw/internal/document?${params}`, { + headers: SECRET ? { "X-Hocuspocus-Secret": SECRET } : {}, + }) + if (res.status === 404) return null + if (!res.ok) throw new Error(`ultidraw load failed: ${res.status}`) + const raw = await res.text() + if (!raw.trim()) return null + try { + const doc = JSON.parse(raw) + const ydoc = new Y.Doc() + if (doc.yjsState) { + Y.applyUpdate(ydoc, Buffer.from(doc.yjsState, "base64")) + } + const { generateNKeysBetween } = await import("fractional-indexing") + seedYdocFromJson(ydoc, doc.elements, doc.files, generateNKeysBetween) + if (ydoc.getArray("elements").length === 0) { + return null + } + return Buffer.from(Y.encodeStateAsUpdate(ydoc)) + } catch (err) { + console.error("[onLoadDocument] ultidraw parse", err) + } + return null +} + +async function storeToUltidraw(context, document) { + if (!context?.path || !context?.user) { + throw new Error("ultidraw store missing path or user in context") + } + const state = Buffer.from(Y.encodeStateAsUpdate(document)).toString("base64") + const scene = exportUltidrawScene(document) + const body = { + room: context.room ?? context.path, + path: context.path, + user: context.user, + sub: context.sub ?? "", + yjsState: state, + document: scene, + } + const res = await fetch(`${ULTID_URL}/api/v1/ultidraw/hooks/store`, { + method: "POST", + headers: { + "Content-Type": "application/json", + ...(SECRET ? { "X-Hocuspocus-Secret": SECRET } : {}), + }, + body: JSON.stringify(body), + }) + if (!res.ok) { + const detail = await res.text().catch(() => "") + throw new Error(`ultidraw store failed: ${res.status}${detail ? ` ${detail}` : ""}`) + } +} + +function isDrawRoom(name) { + return typeof name === "string" && name.startsWith("draw:") +} + async function loadFromUltid(context) { if (!context?.path || !context?.user) return null const params = new URLSearchParams({ user: context.user, path: context.path }) @@ -219,6 +344,9 @@ const server = new Server({ async onLoadDocument(data) { const ctx = hookContext(data) + if (isDrawRoom(data.documentName) || isDrawRoom(ctx.room)) { + return await loadFromUltidraw(ctx) + } return await loadFromUltid(ctx) }, @@ -226,7 +354,11 @@ const server = new Server({ const ctx = hookContext(data) if (ctx.mode === "view") return try { - await storeToUltid(ctx, data.document) + if (isDrawRoom(data.documentName) || isDrawRoom(ctx.room)) { + await storeToUltidraw(ctx, data.document) + } else { + await storeToUltid(ctx, data.document) + } } catch (err) { console.error("[onStoreDocument]", err) } diff --git a/services/openwebui/pipelines/ulti-nc-sync.py b/services/openwebui/pipelines/ulti-nc-sync.py new file mode 100644 index 0000000..a5a8bab --- /dev/null +++ b/services/openwebui/pipelines/ulti-nc-sync.py @@ -0,0 +1,59 @@ +""" +title: UltiAI NC Sync +author: ulti-suite +version: 0.1.0 +description: Sync completed chats to Nextcloud via ulti-backend. +""" + +from typing import Optional +import os +import json +import urllib.request + + +class Pipeline: + def __init__(self): + self.ultid_api = os.environ.get("ULTID_API_URL", "http://ultid:8080/api/v1").rstrip("/") + self.sync_token = os.environ.get("ULTI_AI_SYNC_TOKEN", "") + + async def on_shutdown(self): + pass + + async def inlet(self, body: dict, user: Optional[dict] = None) -> dict: + return body + + async def outlet(self, body: dict, user: Optional[dict] = None) -> dict: + chat = body.get("chat") or body.get("messages") + if not chat: + return body + chat_id = body.get("chat_id") or body.get("id") + if not chat_id: + return body + record = { + "id": chat_id, + "title": body.get("title") or "Conversation", + "source": "openwebui", + "openwebui_chat_id": chat_id, + "messages": body.get("messages") or [], + "meta": { + "model": body.get("model"), + "context": body.get("context") or "standalone", + }, + } + try: + payload = json.dumps(record).encode("utf-8") + req = urllib.request.Request( + f"{self.ultid_api}/ai/chats/sync", + data=payload, + headers={ + "Content-Type": "application/json", + "Authorization": f"Bearer {self.sync_token}" if self.sync_token else "", + }, + method="POST", + ) + with urllib.request.urlopen(req, timeout=15) as resp: + if resp.status >= 400: + print(f"[ulti-nc-sync] sync failed: {resp.status}") + except Exception as exc: + print(f"[ulti-nc-sync] sync error: {exc}") + return body diff --git a/services/openwebui/skills/docs-context.md b/services/openwebui/skills/docs-context.md new file mode 100644 index 0000000..528ab6f --- /dev/null +++ b/services/openwebui/skills/docs-context.md @@ -0,0 +1,29 @@ +# UltiDocs (éditeur texte) + +Tu aides l'utilisateur dans un document UltiDocs (TipTap / ProseMirror). + +## Lecture + +- Le contexte embarqué contient titre, chemin sidecar, extrait texte, sélection et JSON tronqué. +- Pour un document non chargé dans le volet, utilise `docs_read` avec le chemin `.ultidoc`. + +## Modification + +Deux modes : + +1. **Volet intégré (Gemini)** — renvoie un bloc fenced pour appliquer côté éditeur : + ` ```ulti-docs-apply\n{ "action": "insert_text"|"replace_selection"|"append_paragraph"|"set_content", ... }\n``` ` + - `insert_text` / `replace_selection` : texte TipTap/HTML simple (paragraphes, gras, etc.) + - `append_paragraph` : texte brut découpé en paragraphes + - `set_content` : document JSON TipTap complet `{ type: "doc", content: [...] }` + +2. **API / MCP** — `docs_save` avec `{ path, document }` où `document` est le nœud `content` TipTap ou l'objet doc complet selon l'API. + +## Syntaxe TipTap + +- Racine : `{ type: "doc", content: [blocs] }` +- Blocs : `paragraph`, `heading` (level 1-6), `bulletList`, `orderedList`, `blockquote`, `codeBlock` +- Inline : `{ type: "text", text: "...", marks?: [{ type: "bold"|"italic"|"link", attrs? }] }` +- Toujours produire du JSON valide ; ne pas inventer de nœuds custom (`docsGraphic`, etc.) sans preuve dans le document source. + +Réponds en français par défaut. diff --git a/services/openwebui/skills/ultimail-assistant.md b/services/openwebui/skills/ultimail-assistant.md new file mode 100644 index 0000000..8c0f77b --- /dev/null +++ b/services/openwebui/skills/ultimail-assistant.md @@ -0,0 +1,8 @@ +# Ultimail Assistant + +Tu es UltiAI, l'assistant de la suite souveraine Ultimail. + +- Utilise les tools pour lire/agir sur mail, drive, contacts et documents UltiDocs quand c'est pertinent. +- Cite les sources (sujet mail, chemin fichier, nom contact). +- Ne fabrique pas de données : interroge l'API via les tools. +- Réponds en français par défaut. diff --git a/services/ultimail-mcp/Dockerfile b/services/ultimail-mcp/Dockerfile new file mode 100644 index 0000000..e337c33 --- /dev/null +++ b/services/ultimail-mcp/Dockerfile @@ -0,0 +1,15 @@ +FROM node:22-alpine AS build +WORKDIR /app +COPY package.json tsconfig.json ./ +RUN npm install +COPY src ./src +RUN npm run build + +FROM node:22-alpine +WORKDIR /app +COPY package.json ./ +RUN npm install --omit=dev +COPY --from=build /app/dist ./dist +ENV MCP_PORT=3100 +EXPOSE 3100 +CMD ["node", "dist/index.js"] diff --git a/services/ultimail-mcp/package.json b/services/ultimail-mcp/package.json new file mode 100644 index 0000000..0db7479 --- /dev/null +++ b/services/ultimail-mcp/package.json @@ -0,0 +1,22 @@ +{ + "name": "ultimail-mcp", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "start": "node dist/index.js", + "build": "tsc", + "dev": "tsx src/index.ts" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.12.1", + "express": "^5.1.0", + "zod": "^3.24.4" + }, + "devDependencies": { + "@types/express": "^5.0.3", + "@types/node": "^22.15.0", + "tsx": "^4.19.4", + "typescript": "^5.8.3" + } +} diff --git a/services/ultimail-mcp/src/index.ts b/services/ultimail-mcp/src/index.ts new file mode 100644 index 0000000..0da80e6 --- /dev/null +++ b/services/ultimail-mcp/src/index.ts @@ -0,0 +1,167 @@ +import express from "express" +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js" +import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js" +import { z } from "zod" + +const PORT = Number(process.env.MCP_PORT ?? 3100) +const API_BASE = (process.env.ULTID_API_URL ?? "http://localhost:8080/api/v1").replace(/\/$/, "") + +async function ultiFetch( + token: string, + path: string, + init?: RequestInit +): Promise { + const headers = new Headers(init?.headers) + headers.set("Accept", "application/json") + if (token) headers.set("Authorization", `Bearer ${token}`) + const res = await fetch(`${API_BASE}${path}`, { ...init, headers }) + const text = await res.text() + if (!res.ok) { + throw new Error(`ulti ${path} failed (${res.status}): ${text.slice(0, 500)}`) + } + try { + return JSON.parse(text) + } catch { + return text + } +} + +function createServer(getToken: () => string) { + const server = new McpServer({ + name: "ultimail-mcp", + version: "0.1.0", + }) + + server.tool( + "mail_search", + "Search mail messages", + { query: z.string(), account_id: z.string().optional() }, + async ({ query, account_id }) => { + const qs = new URLSearchParams({ q: query }) + if (account_id) qs.set("account_id", account_id) + const data = await ultiFetch(getToken(), `/mail/search?${qs}`) + return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] } + } + ) + + server.tool( + "mail_read_message", + "Read a mail message by id", + { message_id: z.string(), account_id: z.string().optional() }, + async ({ message_id, account_id }) => { + const qs = account_id ? `?account_id=${encodeURIComponent(account_id)}` : "" + const data = await ultiFetch(getToken(), `/mail/messages/${message_id}${qs}`) + return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] } + } + ) + + server.tool( + "drive_list", + "List drive files in a folder", + { path: z.string().optional() }, + async ({ path }) => { + const qs = path ? `?path=${encodeURIComponent(path)}` : "" + const data = await ultiFetch(getToken(), `/drive/list${qs}`) + return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] } + } + ) + + server.tool( + "contacts_search", + "Search contacts", + { query: z.string() }, + async ({ query }) => { + const qs = new URLSearchParams({ q: query }) + const data = await ultiFetch(getToken(), `/contacts/search?${qs}`) + return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] } + } + ) + + server.tool( + "suite_search", + "Unified search across mail, drive, contacts", + { query: z.string(), types: z.string().optional() }, + async ({ query, types }) => { + const qs = new URLSearchParams({ q: query }) + if (types) qs.set("types", types) + const data = await ultiFetch(getToken(), `/search?${qs}`) + return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] } + } + ) + + server.tool( + "docs_read", + "Read UltiDocs document JSON (.ultidoc sidecar path)", + { path: z.string() }, + async ({ path }) => { + const encoded = path + .replace(/^\/+/, "") + .split("/") + .filter(Boolean) + .map((seg) => encodeURIComponent(seg)) + .join("/") + const data = await ultiFetch(getToken(), `/drive/download/${encoded}`) + return { + content: [ + { + type: "text", + text: typeof data === "string" ? data : JSON.stringify(data, null, 2), + }, + ], + } + } + ) + + server.tool( + "docs_save", + "Save UltiDocs TipTap content to sidecar path", + { + path: z.string(), + document: z.record(z.string(), z.unknown()), + }, + async ({ path, document }) => { + const data = await ultiFetch(getToken(), "/richtext/save", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ path, document }), + }) + return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] } + } + ) + + return server +} + +const app = express() +const transports = new Map() + +app.get("/health", (_req, res) => { + res.json({ ok: true }) +}) + +app.get("/mcp", async (req, res) => { + const token = + String(req.headers["x-ulti-token"] ?? req.headers.authorization ?? "").replace( + /^Bearer\s+/i, + "" + ) || "" + const transport = new SSEServerTransport("/mcp/messages", res) + transports.set(transport.sessionId, transport) + res.on("close", () => transports.delete(transport.sessionId)) + const server = createServer(() => token) + await server.connect(transport) +}) + +app.post("/mcp/messages", express.json(), async (req, res) => { + const sessionId = String(req.query.sessionId ?? "") + const transport = transports.get(sessionId) + if (!transport) { + res.status(404).json({ error: "session not found" }) + return + } + await transport.handlePostMessage(req, res, req.body) +}) + +app.listen(PORT, () => { + console.log(`ultimail-mcp listening on :${PORT}`) +}) diff --git a/services/ultimail-mcp/tsconfig.json b/services/ultimail-mcp/tsconfig.json new file mode 100644 index 0000000..e5157b4 --- /dev/null +++ b/services/ultimail-mcp/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "dist", + "rootDir": "src", + "strict": true, + "skipLibCheck": true, + "esModuleInterop": true + }, + "include": ["src/**/*"] +}