wow
@ -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
|
||||
|
||||
@ -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`
|
||||
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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,
|
||||
|
||||
|
Before Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 62 KiB |
BIN
deploy/authentik/branding/ultisuite-favicon-dark.png
Normal file
|
After Width: | Height: | Size: 693 B |
BIN
deploy/authentik/branding/ultisuite-favicon-light.png
Normal file
|
After Width: | Height: | Size: 675 B |
BIN
deploy/authentik/branding/ultisuite-favicon.png
Normal file
|
After Width: | Height: | Size: 684 B |
BIN
deploy/authentik/branding/ultisuite-logo-dark.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
deploy/authentik/branding/ultisuite-logo-light.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
@ -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[@]}" "$@"
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 / {
|
||||
|
||||
44
deploy/openwebui/docker-compose.openwebui.yml
Normal 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
@ -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
@ -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()
|
||||
}
|
||||
16
internal/ai/gateway_test.go
Normal 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
@ -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
@ -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
@ -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
@ -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"`
|
||||
}
|
||||
@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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
@ -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
|
||||
}
|
||||
@ -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, ""
|
||||
}
|
||||
|
||||
23
internal/api/drive/blank_office_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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) {
|
||||
|
||||
1
internal/api/drive/testdata/blank.excalidraw
vendored
Normal file
@ -0,0 +1 @@
|
||||
{"type":"excalidraw","version":2,"source":"https://ultidrive","elements":[],"appState":{"gridSize":null,"viewBackgroundColor":"#ffffff"},"files":{}}
|
||||
106
internal/api/drive/ultidoc_sidecar.go
Normal 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
|
||||
}
|
||||
31
internal/api/drive/ultidoc_sidecar_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -15,8 +15,9 @@ type UltiDoc struct {
|
||||
Editor string `json:"editor"`
|
||||
Source *UltiDocSource `json:"source,omitempty"`
|
||||
Content json.RawMessage `json:"content"`
|
||||
PageSetup *UltiDocPageSetup `json:"pageSetup,omitempty"`
|
||||
YjsState string `json:"yjsState,omitempty"`
|
||||
PageSetup *UltiDocPageSetup `json:"pageSetup,omitempty"`
|
||||
ParagraphStyles *UltiDocParagraphStyles `json:"paragraphStyles,omitempty"`
|
||||
YjsState string `json:"yjsState,omitempty"`
|
||||
UpdatedAt string `json:"updatedAt,omitempty"`
|
||||
}
|
||||
|
||||
@ -168,6 +169,9 @@ func preserveUltiDocMetadata(dst *UltiDoc, existing UltiDoc) {
|
||||
dst.Content = existing.Content
|
||||
}
|
||||
}
|
||||
if dst.ParagraphStyles == nil && existing.ParagraphStyles != nil {
|
||||
dst.ParagraphStyles = existing.ParagraphStyles
|
||||
}
|
||||
if dst.YjsState == "" && existing.YjsState != "" {
|
||||
dst.YjsState = existing.YjsState
|
||||
}
|
||||
@ -178,8 +182,9 @@ type ultiDocPatch struct {
|
||||
Editor string `json:"editor"`
|
||||
Content json.RawMessage `json:"content"`
|
||||
Document json.RawMessage `json:"document"`
|
||||
PageSetup *UltiDocPageSetup `json:"pageSetup"`
|
||||
YjsState string `json:"yjsState"`
|
||||
PageSetup *UltiDocPageSetup `json:"pageSetup"`
|
||||
ParagraphStyles *UltiDocParagraphStyles `json:"paragraphStyles"`
|
||||
YjsState string `json:"yjsState"`
|
||||
}
|
||||
|
||||
// ApplyUltiDocPatch merges a partial JSON payload into an existing UltiDoc.
|
||||
@ -207,6 +212,9 @@ func ApplyUltiDocPatch(existing UltiDoc, raw json.RawMessage) (UltiDoc, error) {
|
||||
if patch.PageSetup != nil {
|
||||
doc.PageSetup = patch.PageSetup
|
||||
}
|
||||
if patch.ParagraphStyles != nil {
|
||||
doc.ParagraphStyles = patch.ParagraphStyles
|
||||
}
|
||||
if patch.YjsState != "" {
|
||||
doc.YjsState = patch.YjsState
|
||||
}
|
||||
|
||||
@ -44,6 +44,9 @@ func (h *Handler) Routes(authMiddleware func(http.Handler) http.Handler) chi.Rou
|
||||
pr.With(read).Post("/session", h.CreateSession)
|
||||
pr.With(read).Post("/import", h.Import)
|
||||
pr.With(read).Post("/export", h.Export)
|
||||
pr.With(read).Get("/fonts", h.ListFonts)
|
||||
pr.With(read).Get("/user-paragraph-styles", h.GetUserParagraphStyles)
|
||||
pr.With(write).Put("/user-paragraph-styles", h.PutUserParagraphStyles)
|
||||
pr.With(write).Post("/assets", h.UploadAsset)
|
||||
pr.With(write).Put("/save", h.Save)
|
||||
})
|
||||
@ -133,10 +136,11 @@ func (h *Handler) Export(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
type saveRequest struct {
|
||||
Path string `json:"path"`
|
||||
Document json.RawMessage `json:"document"`
|
||||
PageSetup json.RawMessage `json:"pageSetup,omitempty"`
|
||||
YjsState string `json:"yjsState,omitempty"`
|
||||
Path string `json:"path"`
|
||||
Document json.RawMessage `json:"document"`
|
||||
PageSetup json.RawMessage `json:"pageSetup,omitempty"`
|
||||
ParagraphStyles json.RawMessage `json:"paragraphStyles,omitempty"`
|
||||
YjsState string `json:"yjsState,omitempty"`
|
||||
}
|
||||
|
||||
func (h *Handler) Save(w http.ResponseWriter, r *http.Request) {
|
||||
@ -169,9 +173,15 @@ func (h *Handler) Save(w http.ResponseWriter, r *http.Request) {
|
||||
} else {
|
||||
doc = NewUltiDoc(nil, nil)
|
||||
}
|
||||
case len(req.ParagraphStyles) > 0:
|
||||
if existing.SchemaVersion > 0 {
|
||||
doc = existing
|
||||
} else {
|
||||
doc = NewUltiDoc(nil, nil)
|
||||
}
|
||||
default:
|
||||
apivalidate.WriteValidationError(w, r, apivalidate.NewValidationError(
|
||||
apivalidate.FieldDetail{Field: "document", Message: "document or pageSetup required"},
|
||||
apivalidate.FieldDetail{Field: "document", Message: "document, pageSetup or paragraphStyles required"},
|
||||
))
|
||||
return
|
||||
}
|
||||
@ -182,6 +192,12 @@ func (h *Handler) Save(w http.ResponseWriter, r *http.Request) {
|
||||
doc.PageSetup = &pageSetup
|
||||
}
|
||||
}
|
||||
if len(req.ParagraphStyles) > 0 {
|
||||
var paragraphStyles UltiDocParagraphStyles
|
||||
if err := json.Unmarshal(req.ParagraphStyles, ¶graphStyles); err == nil {
|
||||
doc.ParagraphStyles = ¶graphStyles
|
||||
}
|
||||
}
|
||||
if req.YjsState != "" {
|
||||
doc.YjsState = req.YjsState
|
||||
}
|
||||
@ -375,3 +391,41 @@ func (h *Handler) UploadAsset(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
apiresponse.WriteJSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
func (h *Handler) ListFonts(w http.ResponseWriter, r *http.Request) {
|
||||
fonts := h.svc.ListFonts(r.Context())
|
||||
apiresponse.WriteJSON(w, http.StatusOK, map[string]any{"fonts": fonts})
|
||||
}
|
||||
|
||||
func (h *Handler) GetUserParagraphStyles(w http.ResponseWriter, r *http.Request) {
|
||||
claims := middleware.ClaimsFromContext(r.Context())
|
||||
ncUser, err := h.svc.EnsureNextcloudUser(r.Context(), claims)
|
||||
if err != nil {
|
||||
apivalidate.WriteInternal(w, r)
|
||||
return
|
||||
}
|
||||
styles, err := h.svc.LoadUserParagraphStyles(r.Context(), ncUser)
|
||||
if err != nil {
|
||||
apivalidate.WriteInternal(w, r)
|
||||
return
|
||||
}
|
||||
apiresponse.WriteJSON(w, http.StatusOK, styles)
|
||||
}
|
||||
|
||||
func (h *Handler) PutUserParagraphStyles(w http.ResponseWriter, r *http.Request) {
|
||||
claims := middleware.ClaimsFromContext(r.Context())
|
||||
ncUser, err := h.svc.EnsureNextcloudUser(r.Context(), claims)
|
||||
if err != nil {
|
||||
apivalidate.WriteInternal(w, r)
|
||||
return
|
||||
}
|
||||
var styles UltiDocParagraphStyles
|
||||
if err := apivalidate.DecodeJSON(w, r, 512<<10, &styles); err != nil {
|
||||
return
|
||||
}
|
||||
if err := h.svc.SaveUserParagraphStyles(r.Context(), ncUser, styles); err != nil {
|
||||
apivalidate.WriteInternal(w, r)
|
||||
return
|
||||
}
|
||||
apiresponse.WriteJSON(w, http.StatusOK, styles)
|
||||
}
|
||||
|
||||
114
internal/api/richtext/paragraph_styles.go
Normal 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)
|
||||
}
|
||||
@ -44,7 +44,8 @@ type SessionResult struct {
|
||||
Mode string `json:"mode"`
|
||||
ImportRequired bool `json:"importRequired"`
|
||||
Collaboration bool `json:"collaboration"`
|
||||
PageSetup json.RawMessage `json:"pageSetup,omitempty"`
|
||||
PageSetup json.RawMessage `json:"pageSetup,omitempty"`
|
||||
ParagraphStyles json.RawMessage `json:"paragraphStyles,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Service) CreateSession(ctx context.Context, ncUser, filePath, mode, editorUserID, editorName string) (*SessionResult, error) {
|
||||
@ -83,6 +84,7 @@ func (s *Service) CreateSession(ctx context.Context, ncUser, filePath, mode, edi
|
||||
collab := wsURL != "" && s.Cfg.HocuspocusSecret != ""
|
||||
|
||||
pageSetup, _ := s.loadPageSetupFromSidecar(ctx, ncUser, canonical)
|
||||
paragraphStyles, _ := s.loadParagraphStylesFromSidecar(ctx, ncUser, canonical)
|
||||
|
||||
return &SessionResult{
|
||||
RoomID: roomID,
|
||||
@ -94,6 +96,7 @@ func (s *Service) CreateSession(ctx context.Context, ncUser, filePath, mode, edi
|
||||
ImportRequired: importRequired,
|
||||
Collaboration: collab,
|
||||
PageSetup: pageSetup,
|
||||
ParagraphStyles: paragraphStyles,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
27
internal/api/ultidraw/collab_room.go
Normal 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
|
||||
}
|
||||
90
internal/api/ultidraw/document.go
Normal 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"
|
||||
}
|
||||
178
internal/api/ultidraw/handlers.go
Normal 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)
|
||||
}
|
||||
135
internal/api/ultidraw/jwt.go
Normal 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)
|
||||
}
|
||||
40
internal/api/ultidraw/paths.go
Normal 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")
|
||||
}
|
||||
140
internal/api/ultidraw/public_handlers.go
Normal 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)
|
||||
}
|
||||
162
internal/api/ultidraw/public_share.go
Normal 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)
|
||||
}
|
||||
127
internal/api/ultidraw/service.go
Normal 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
|
||||
}
|
||||
88
internal/apitokens/chat_session.go
Normal 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
|
||||
}
|
||||
}
|
||||
54
internal/apitokens/chat_session_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
|
||||
26
internal/apitokens/policy_ai_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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"),
|
||||
|
||||
27
internal/nextcloud/ultichat_paths.go
Normal 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)
|
||||
}
|
||||
20
internal/nextcloud/ultichat_paths_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
29
internal/nextcloud/ultidoc_paths.go
Normal 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))
|
||||
}
|
||||
28
internal/nextcloud/ultidoc_paths_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@ -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())
|
||||
|
||||
2
migrations/000035_ai_assistant.down.sql
Normal file
@ -0,0 +1,2 @@
|
||||
DROP TABLE IF EXISTS ai_usage_monthly;
|
||||
DROP TABLE IF EXISTS ai_usage_daily;
|
||||
16
migrations/000035_ai_assistant.up.sql
Normal 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);
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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: {}
|
||||
|
||||
@ -15,6 +15,7 @@ const TextAlign = require("@tiptap/extension-text-align").default
|
||||
const { Table, TableRow, TableCell, TableHeader } = require("@tiptap/extension-table")
|
||||
const Image = require("@tiptap/extension-image").default
|
||||
|
||||
/** Keep in sync with lib/drive/extensions/docs-graphic.ts graphicAttributes */
|
||||
const graphicAttributes = {
|
||||
graphicType: { default: "image" },
|
||||
src: { default: null },
|
||||
@ -34,6 +35,12 @@ const graphicAttributes = {
|
||||
floatSide: { default: "left" },
|
||||
x: { default: 0 },
|
||||
y: { default: 0 },
|
||||
positionMode: { default: "move-with-text" },
|
||||
anchorPos: { default: -1 },
|
||||
pageIndex: { default: 0 },
|
||||
pageX: { default: 0 },
|
||||
pageY: { default: 0 },
|
||||
wrapMarginMm: { default: 3 },
|
||||
rotationDeg: { default: 0 },
|
||||
zIndex: { default: 0 },
|
||||
cropX: { default: 0 },
|
||||
@ -41,9 +48,18 @@ const graphicAttributes = {
|
||||
cropWidth: { default: 1 },
|
||||
cropHeight: { default: 1 },
|
||||
cropShape: { default: "rect" },
|
||||
lockAspectRatio: { default: true },
|
||||
imageFit: { default: "contain" },
|
||||
imageFitAnchorH: { default: 0.5 },
|
||||
imageFitAnchorV: { default: 0.5 },
|
||||
assetId: { default: null },
|
||||
opacity: { default: 1 },
|
||||
shadow: { default: "" },
|
||||
brightness: { default: 0 },
|
||||
contrast: { default: 0 },
|
||||
recolor: { default: "" },
|
||||
altTitle: { default: "" },
|
||||
drawScene: { default: null },
|
||||
}
|
||||
|
||||
const DocsGraphic = Node.create({
|
||||
@ -131,6 +147,115 @@ function tipTapContentHasBody(content) {
|
||||
return walk(content)
|
||||
}
|
||||
|
||||
function yjsToExcalidrawElements(yArray) {
|
||||
if (!yArray || yArray.length === 0) return []
|
||||
return yArray
|
||||
.toArray()
|
||||
.sort((a, b) => {
|
||||
const key1 = a.get("pos")
|
||||
const key2 = b.get("pos")
|
||||
return key1 > key2 ? 1 : key1 < key2 ? -1 : 0
|
||||
})
|
||||
.map((x) => x.get("el"))
|
||||
.filter((el) => el && typeof el.id === "string" && typeof el.type === "string")
|
||||
}
|
||||
|
||||
function exportUltidrawScene(ydoc) {
|
||||
const yElements = ydoc.getArray("elements")
|
||||
const yAssets = ydoc.getMap("assets")
|
||||
const elements = yjsToExcalidrawElements(yElements)
|
||||
const files = {}
|
||||
yAssets.forEach((value, key) => {
|
||||
files[key] = value
|
||||
})
|
||||
return {
|
||||
elements,
|
||||
appState: { gridSize: null, viewBackgroundColor: "#ffffff" },
|
||||
files,
|
||||
}
|
||||
}
|
||||
|
||||
function seedYdocFromJson(ydoc, elements, files, generateNKeysBetween) {
|
||||
const yElements = ydoc.getArray("elements")
|
||||
const yAssets = ydoc.getMap("assets")
|
||||
if (!Array.isArray(elements) || elements.length === 0 || yElements.length > 0) return
|
||||
const keys = generateNKeysBetween(null, null, elements.length)
|
||||
ydoc.transact(() => {
|
||||
for (let i = 0; i < elements.length; i++) {
|
||||
const el = elements[i]
|
||||
if (!el || typeof el.id !== "string") continue
|
||||
yElements.push([
|
||||
new Y.Map(Object.entries({ pos: keys[i], el: { ...el } })),
|
||||
])
|
||||
}
|
||||
if (files && typeof files === "object") {
|
||||
for (const [id, asset] of Object.entries(files)) {
|
||||
yAssets.set(id, asset)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function loadFromUltidraw(context) {
|
||||
if (!context?.path || !context?.user) return null
|
||||
const params = new URLSearchParams({ user: context.user, path: context.path })
|
||||
const res = await fetch(`${ULTID_URL}/api/v1/ultidraw/internal/document?${params}`, {
|
||||
headers: SECRET ? { "X-Hocuspocus-Secret": SECRET } : {},
|
||||
})
|
||||
if (res.status === 404) return null
|
||||
if (!res.ok) throw new Error(`ultidraw load failed: ${res.status}`)
|
||||
const raw = await res.text()
|
||||
if (!raw.trim()) return null
|
||||
try {
|
||||
const doc = JSON.parse(raw)
|
||||
const ydoc = new Y.Doc()
|
||||
if (doc.yjsState) {
|
||||
Y.applyUpdate(ydoc, Buffer.from(doc.yjsState, "base64"))
|
||||
}
|
||||
const { generateNKeysBetween } = await import("fractional-indexing")
|
||||
seedYdocFromJson(ydoc, doc.elements, doc.files, generateNKeysBetween)
|
||||
if (ydoc.getArray("elements").length === 0) {
|
||||
return null
|
||||
}
|
||||
return Buffer.from(Y.encodeStateAsUpdate(ydoc))
|
||||
} catch (err) {
|
||||
console.error("[onLoadDocument] ultidraw parse", err)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async function storeToUltidraw(context, document) {
|
||||
if (!context?.path || !context?.user) {
|
||||
throw new Error("ultidraw store missing path or user in context")
|
||||
}
|
||||
const state = Buffer.from(Y.encodeStateAsUpdate(document)).toString("base64")
|
||||
const scene = exportUltidrawScene(document)
|
||||
const body = {
|
||||
room: context.room ?? context.path,
|
||||
path: context.path,
|
||||
user: context.user,
|
||||
sub: context.sub ?? "",
|
||||
yjsState: state,
|
||||
document: scene,
|
||||
}
|
||||
const res = await fetch(`${ULTID_URL}/api/v1/ultidraw/hooks/store`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(SECRET ? { "X-Hocuspocus-Secret": SECRET } : {}),
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const detail = await res.text().catch(() => "")
|
||||
throw new Error(`ultidraw store failed: ${res.status}${detail ? ` ${detail}` : ""}`)
|
||||
}
|
||||
}
|
||||
|
||||
function isDrawRoom(name) {
|
||||
return typeof name === "string" && name.startsWith("draw:")
|
||||
}
|
||||
|
||||
async function loadFromUltid(context) {
|
||||
if (!context?.path || !context?.user) return null
|
||||
const params = new URLSearchParams({ user: context.user, path: context.path })
|
||||
@ -219,6 +344,9 @@ const server = new Server({
|
||||
|
||||
async onLoadDocument(data) {
|
||||
const ctx = hookContext(data)
|
||||
if (isDrawRoom(data.documentName) || isDrawRoom(ctx.room)) {
|
||||
return await loadFromUltidraw(ctx)
|
||||
}
|
||||
return await loadFromUltid(ctx)
|
||||
},
|
||||
|
||||
@ -226,7 +354,11 @@ const server = new Server({
|
||||
const ctx = hookContext(data)
|
||||
if (ctx.mode === "view") return
|
||||
try {
|
||||
await storeToUltid(ctx, data.document)
|
||||
if (isDrawRoom(data.documentName) || isDrawRoom(ctx.room)) {
|
||||
await storeToUltidraw(ctx, data.document)
|
||||
} else {
|
||||
await storeToUltid(ctx, data.document)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[onStoreDocument]", err)
|
||||
}
|
||||
|
||||
59
services/openwebui/pipelines/ulti-nc-sync.py
Normal 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
|
||||
29
services/openwebui/skills/docs-context.md
Normal 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 }` où `document` est le nœud `content` TipTap ou l'objet doc complet selon l'API.
|
||||
|
||||
## Syntaxe TipTap
|
||||
|
||||
- Racine : `{ type: "doc", content: [blocs] }`
|
||||
- Blocs : `paragraph`, `heading` (level 1-6), `bulletList`, `orderedList`, `blockquote`, `codeBlock`
|
||||
- Inline : `{ type: "text", text: "...", marks?: [{ type: "bold"|"italic"|"link", attrs? }] }`
|
||||
- Toujours produire du JSON valide ; ne pas inventer de nœuds custom (`docsGraphic`, etc.) sans preuve dans le document source.
|
||||
|
||||
Réponds en français par défaut.
|
||||
8
services/openwebui/skills/ultimail-assistant.md
Normal 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.
|
||||
15
services/ultimail-mcp/Dockerfile
Normal 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"]
|
||||
22
services/ultimail-mcp/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
167
services/ultimail-mcp/src/index.ts
Normal 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}`)
|
||||
})
|
||||
13
services/ultimail-mcp/tsconfig.json
Normal file
@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||