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/**/*"]
+}