wow
Some checks are pending
CI / Go tests (push) Waiting to run
CI / Integration tests (push) Waiting to run
CI / DB migrations (push) Waiting to run

This commit is contained in:
R3D347HR4Y 2026-06-11 01:22:52 +02:00
parent 2bdd16fa37
commit 0466a1c169
72 changed files with 3356 additions and 47 deletions

View File

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

View File

@ -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 `<link rel="icon">` 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`

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 693 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 675 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 684 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

@ -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[@]}" "$@"

View File

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

View File

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

View File

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

View File

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

101
internal/ai/chat_sync.go Normal file
View File

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

253
internal/ai/gateway.go Normal file
View File

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

View File

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

187
internal/ai/providers.go Normal file
View File

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

124
internal/ai/quota.go Normal file
View File

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

26
internal/ai/quota_test.go Normal file
View File

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

78
internal/ai/types.go Normal file
View File

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

View File

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

View File

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

286
internal/api/ai/handlers.go Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1 @@
{"type":"excalidraw","version":2,"source":"https://ultidrive","elements":[],"appState":{"gridSize":null,"viewBackgroundColor":"#ffffff"},"files":{}}

View File

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

View File

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

View File

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

View File

@ -16,6 +16,7 @@ type UltiDoc struct {
Source *UltiDocSource `json:"source,omitempty"`
Content json.RawMessage `json:"content"`
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
}
@ -179,6 +183,7 @@ type ultiDocPatch struct {
Content json.RawMessage `json:"content"`
Document json.RawMessage `json:"document"`
PageSetup *UltiDocPageSetup `json:"pageSetup"`
ParagraphStyles *UltiDocParagraphStyles `json:"paragraphStyles"`
YjsState string `json:"yjsState"`
}
@ -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
}

View File

@ -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)
})
@ -136,6 +139,7 @@ type saveRequest struct {
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"`
}
@ -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, &paragraphStyles); err == nil {
doc.ParagraphStyles = &paragraphStyles
}
}
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)
}

View File

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

View File

@ -45,6 +45,7 @@ type SessionResult struct {
ImportRequired bool `json:"importRequired"`
Collaboration bool `json:"collaboration"`
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
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,2 @@
DROP TABLE IF EXISTS ai_usage_monthly;
DROP TABLE IF EXISTS ai_usage_daily;

View File

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

View File

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

View File

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

View File

@ -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 {
if (isDrawRoom(data.documentName) || isDrawRoom(ctx.room)) {
await storeToUltidraw(ctx, data.document)
} else {
await storeToUltid(ctx, data.document)
}
} catch (err) {
console.error("[onStoreDocument]", err)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<unknown> {
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<string, SSEServerTransport>()
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}`)
})

View File

@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true
},
"include": ["src/**/*"]
}