From 0466a1c169dd23e4c6a304c19a3ce2f5f6602fdf Mon Sep 17 00:00:00 2001 From: R3D347HR4Y Date: Thu, 11 Jun 2026 01:22:52 +0200 Subject: [PATCH] wow --- .env.example | 9 + deploy/authentik/README.md | 20 +- .../authentik/blueprints/02-ulti-brand.yaml | 20 +- deploy/authentik/branding/ulti-authentik.css | 2 +- .../branding/ultimail-favicon-dark.png | Bin 1350 -> 0 bytes .../branding/ultimail-favicon-light.png | Bin 1300 -> 0 bytes .../authentik/branding/ultimail-favicon.png | Bin 1533 -> 0 bytes .../authentik/branding/ultimail-logo-dark.png | Bin 39761 -> 0 bytes .../branding/ultimail-logo-light.png | Bin 63492 -> 0 bytes .../branding/ultisuite-favicon-dark.png | Bin 0 -> 693 bytes .../branding/ultisuite-favicon-light.png | Bin 0 -> 675 bytes .../authentik/branding/ultisuite-favicon.png | Bin 0 -> 684 bytes .../branding/ultisuite-logo-dark.png | Bin 0 -> 14205 bytes .../branding/ultisuite-logo-light.png | Bin 0 -> 13951 bytes deploy/compose-up.sh | 4 + deploy/docker-compose.yml | 20 +- deploy/init-db.sh | 1 + deploy/nginx/default.conf.template | 95 +++++- deploy/openwebui/docker-compose.openwebui.yml | 44 +++ internal/ai/chat_sync.go | 101 +++++++ internal/ai/gateway.go | 253 ++++++++++++++++ internal/ai/gateway_test.go | 16 + internal/ai/providers.go | 187 ++++++++++++ internal/ai/quota.go | 124 ++++++++ internal/ai/quota_test.go | 26 ++ internal/ai/types.go | 78 +++++ internal/api/admin/org_settings.go | 16 + internal/api/admin/org_settings_env.go | 10 + internal/api/ai/handlers.go | 286 ++++++++++++++++++ internal/api/drive/blank_office.go | 5 + internal/api/drive/blank_office_test.go | 23 ++ internal/api/drive/handlers.go | 9 + internal/api/drive/public_handlers.go | 3 + internal/api/drive/service.go | 10 +- internal/api/drive/testdata/blank.excalidraw | 1 + internal/api/drive/ultidoc_sidecar.go | 106 +++++++ internal/api/drive/ultidoc_sidecar_test.go | 31 ++ internal/api/drive/validate.go | 2 +- internal/api/richtext/document.go | 16 +- internal/api/richtext/handlers.go | 64 +++- internal/api/richtext/paragraph_styles.go | 114 +++++++ internal/api/richtext/service.go | 5 +- internal/api/ultidraw/collab_room.go | 27 ++ internal/api/ultidraw/document.go | 90 ++++++ internal/api/ultidraw/handlers.go | 178 +++++++++++ internal/api/ultidraw/jwt.go | 135 +++++++++ internal/api/ultidraw/paths.go | 40 +++ internal/api/ultidraw/public_handlers.go | 140 +++++++++ internal/api/ultidraw/public_share.go | 162 ++++++++++ internal/api/ultidraw/service.go | 127 ++++++++ internal/apitokens/chat_session.go | 88 ++++++ internal/apitokens/chat_session_test.go | 54 ++++ internal/apitokens/policy.go | 30 ++ internal/apitokens/policy_ai_test.go | 26 ++ internal/config/config.go | 11 + internal/nextcloud/ultichat_paths.go | 27 ++ internal/nextcloud/ultichat_paths_test.go | 20 ++ internal/nextcloud/ultidoc_paths.go | 29 ++ internal/nextcloud/ultidoc_paths_test.go | 28 ++ internal/server/bootstrap.go | 15 + migrations/000035_ai_assistant.down.sql | 2 + migrations/000035_ai_assistant.up.sql | 16 + services/hocuspocus/package.json | 1 + services/hocuspocus/pnpm-lock.yaml | 9 + services/hocuspocus/server.mjs | 134 +++++++- services/openwebui/pipelines/ulti-nc-sync.py | 59 ++++ services/openwebui/skills/docs-context.md | 29 ++ .../openwebui/skills/ultimail-assistant.md | 8 + services/ultimail-mcp/Dockerfile | 15 + services/ultimail-mcp/package.json | 22 ++ services/ultimail-mcp/src/index.ts | 167 ++++++++++ services/ultimail-mcp/tsconfig.json | 13 + 72 files changed, 3356 insertions(+), 47 deletions(-) delete mode 100644 deploy/authentik/branding/ultimail-favicon-dark.png delete mode 100644 deploy/authentik/branding/ultimail-favicon-light.png delete mode 100644 deploy/authentik/branding/ultimail-favicon.png delete mode 100644 deploy/authentik/branding/ultimail-logo-dark.png delete mode 100644 deploy/authentik/branding/ultimail-logo-light.png create mode 100644 deploy/authentik/branding/ultisuite-favicon-dark.png create mode 100644 deploy/authentik/branding/ultisuite-favicon-light.png create mode 100644 deploy/authentik/branding/ultisuite-favicon.png create mode 100644 deploy/authentik/branding/ultisuite-logo-dark.png create mode 100644 deploy/authentik/branding/ultisuite-logo-light.png create mode 100644 deploy/openwebui/docker-compose.openwebui.yml create mode 100644 internal/ai/chat_sync.go create mode 100644 internal/ai/gateway.go create mode 100644 internal/ai/gateway_test.go create mode 100644 internal/ai/providers.go create mode 100644 internal/ai/quota.go create mode 100644 internal/ai/quota_test.go create mode 100644 internal/ai/types.go create mode 100644 internal/api/ai/handlers.go create mode 100644 internal/api/drive/blank_office_test.go create mode 100644 internal/api/drive/testdata/blank.excalidraw create mode 100644 internal/api/drive/ultidoc_sidecar.go create mode 100644 internal/api/drive/ultidoc_sidecar_test.go create mode 100644 internal/api/richtext/paragraph_styles.go create mode 100644 internal/api/ultidraw/collab_room.go create mode 100644 internal/api/ultidraw/document.go create mode 100644 internal/api/ultidraw/handlers.go create mode 100644 internal/api/ultidraw/jwt.go create mode 100644 internal/api/ultidraw/paths.go create mode 100644 internal/api/ultidraw/public_handlers.go create mode 100644 internal/api/ultidraw/public_share.go create mode 100644 internal/api/ultidraw/service.go create mode 100644 internal/apitokens/chat_session.go create mode 100644 internal/apitokens/chat_session_test.go create mode 100644 internal/apitokens/policy_ai_test.go create mode 100644 internal/nextcloud/ultichat_paths.go create mode 100644 internal/nextcloud/ultichat_paths_test.go create mode 100644 internal/nextcloud/ultidoc_paths.go create mode 100644 internal/nextcloud/ultidoc_paths_test.go create mode 100644 migrations/000035_ai_assistant.down.sql create mode 100644 migrations/000035_ai_assistant.up.sql create mode 100644 services/openwebui/pipelines/ulti-nc-sync.py create mode 100644 services/openwebui/skills/docs-context.md create mode 100644 services/openwebui/skills/ultimail-assistant.md create mode 100644 services/ultimail-mcp/Dockerfile create mode 100644 services/ultimail-mcp/package.json create mode 100644 services/ultimail-mcp/src/index.ts create mode 100644 services/ultimail-mcp/tsconfig.json diff --git a/.env.example b/.env.example index 5da0d44..0b3a5b4 100644 --- a/.env.example +++ b/.env.example @@ -169,6 +169,15 @@ HOCUSPOCUS_SECRET=changeme-hocuspocus-secret RICHTEXT_STORAGE_MODE=sidecar # RICHTEXT_EXPORT_MIRROR=docx +# ----------------------------------------------------------------------------- +# UltiAI (OpenWebUI + gateway LLM) +# ----------------------------------------------------------------------------- +AI_ASSISTANT_ENABLED=false +OPENWEBUI_URL=http://openwebui:8080 +AI_ASSISTANT_PUBLIC_PATH=/ai +ULTIMAIL_MCP_URL=http://ultimail-mcp:3100 +OPENWEBUI_DB_PASSWORD=changeme-openwebui + # ----------------------------------------------------------------------------- # Jitsi Meet (Visioconference) # Mode local : Jitsi deploye dans la stack diff --git a/deploy/authentik/README.md b/deploy/authentik/README.md index c67527c..0cc56e0 100644 --- a/deploy/authentik/README.md +++ b/deploy/authentik/README.md @@ -5,21 +5,21 @@ Blueprints in `blueprints/` are mounted into Authentik at `/blueprints/custom` a | Fichier | Rôle | |---------|------| | `01-ulti-enrollment.yaml` | Inscription self-service (`ulti-enrollment`) | -| `02-ulti-brand.yaml` | Branding Ultimail + lien « Créer un compte » sur login | +| `02-ulti-brand.yaml` | Branding UltiSuite + lien « Créer un compte » sur login | | `03-ulti-suite-groups.yaml` | Claim OIDC `groups` (RBAC contacts/calendar/drive/photos) | | `ulti-oidc.yaml` | App OIDC Ultimail | | `nextcloud-oidc.yaml` | App OIDC Nextcloud | | `onlyoffice-oidc.yaml` | App OIDC OnlyOffice | -Assets branding : générés depuis le frontend (`pnpm run brand:authentik` dans `gmail-interface-clone`) : +Assets branding : générés depuis le frontend (`pnpm run brand:authentik` dans `gmail-interface-clone`, source : `public/ultisuite-mark.svg`) : | Fichier Authentik | Thème | Description | |-------------------|-------|-------------| -| `ultimail-logo-light.png` | clair | Picto + wordmark sur fond blanc | -| `ultimail-logo-dark.png` | sombre | Picto + texte clair, fond transparent | -| `ultimail-favicon.png` | — | Mark 32×32 transparent (favicon onglet, URL **sans** `%(theme)s`) | -| `ultimail-favicon-light.png` | clair | Variante archive (fond blanc) | -| `ultimail-favicon-dark.png` | sombre | Variante archive (fond sombre) | +| `ultisuite-logo-light.png` | clair | Mark + wordmark UltiSuite, texte sombre, fond transparent | +| `ultisuite-logo-dark.png` | sombre | Mark + texte clair, fond transparent | +| `ultisuite-favicon.png` | — | Mark 32×32 transparent (favicon onglet, URL **sans** `%(theme)s`) | +| `ultisuite-favicon-light.png` | clair | Variante archive (fond blanc) | +| `ultisuite-favicon-dark.png` | sombre | Variante archive (fond sombre) | Logo : placeholder Authentik `%(theme)s` + fallback CSS `prefers-color-scheme`. Favicon onglet : **chemin statique** — Authentik ne substitue pas `%(theme)s` dans le `` SSR (erreur 400). @@ -28,7 +28,7 @@ Regénérer après MAJ du master brand : ```bash cd ../gmail-interface-clone -pnpm run brand:build && pnpm run brand:authentik +pnpm run brand:authentik cd ../ulti-backend ./deploy/compose-up.sh up -d authentik-server authentik-worker docker exec deploy-authentik-server-1 ak apply_blueprint /blueprints/custom/02-ulti-brand.yaml @@ -48,8 +48,8 @@ Sur la page de connexion Authentik, lien **« Besoin d'un compte ? S'inscrire » ## Branding -- Titre navigateur : **Ultimail** -- Logo / favicon : marque Ultimail, variantes **light** et **dark** (thème Authentik) +- Titre navigateur : **UltiSuite** +- Logo / favicon : marque UltiSuite (grille 4 couleurs plates), variantes **light** et **dark** (thème Authentik) - CSS custom : masque « Powered by authentik » et liens goauthentik.io - Locale par défaut : `fr` diff --git a/deploy/authentik/blueprints/02-ulti-brand.yaml b/deploy/authentik/blueprints/02-ulti-brand.yaml index c826b31..9cf0362 100644 --- a/deploy/authentik/blueprints/02-ulti-brand.yaml +++ b/deploy/authentik/blueprints/02-ulti-brand.yaml @@ -1,7 +1,7 @@ -# Ultimail — branding + lien inscription sur le flow de connexion +# UltiSuite — branding + lien inscription sur le flow de connexion version: 1 metadata: - name: Ultimail brand and authentication + name: UltiSuite brand and authentication labels: blueprints.goauthentik.io/instantiate: "true" entries: @@ -9,8 +9,8 @@ entries: identifiers: slug: default-authentication-flow attrs: - name: Connexion Ultimail - title: Connexion Ultimail + name: Connexion UltiSuite + title: Connexion UltiSuite - model: authentik_stages_identification.identificationstage identifiers: @@ -25,11 +25,11 @@ entries: identifiers: domain: authentik-default attrs: - branding_title: Ultimail - branding_logo: /static/dist/assets/branding/ultimail-logo-%(theme)s.png - branding_favicon: /static/dist/assets/branding/ultimail-favicon.png + branding_title: UltiSuite + branding_logo: /static/dist/assets/branding/ultisuite-logo-%(theme)s.png + branding_favicon: /static/dist/assets/branding/ultisuite-favicon.png branding_custom_css: | - /* Ultimail — masquer le branding Authentik */ + /* UltiSuite — masquer le branding Authentik */ ak-branding-footer, .pf-c-login__footer, .pf-c-login__footer-text, @@ -45,12 +45,12 @@ entries: max-height: 48px; width: auto; max-width: min(280px, 80vw); - content: url("/auth/static/dist/assets/branding/ultimail-logo-light.png"); + content: url("/auth/static/dist/assets/branding/ultisuite-logo-light.png"); } @media (prefers-color-scheme: dark) { ak-brand-logo img, .pf-c-brand img { - content: url("/auth/static/dist/assets/branding/ultimail-logo-dark.png"); + content: url("/auth/static/dist/assets/branding/ultisuite-logo-dark.png"); } } ak-flow-executor::part(footer) { diff --git a/deploy/authentik/branding/ulti-authentik.css b/deploy/authentik/branding/ulti-authentik.css index 5280eb4..a69b23c 100644 --- a/deploy/authentik/branding/ulti-authentik.css +++ b/deploy/authentik/branding/ulti-authentik.css @@ -1,4 +1,4 @@ -/* Ultimail — masquer le branding Authentik sur flows et portail utilisateur */ +/* UltiSuite — masquer le branding Authentik sur flows et portail utilisateur */ ak-branding-footer, .pf-c-login__footer, diff --git a/deploy/authentik/branding/ultimail-favicon-dark.png b/deploy/authentik/branding/ultimail-favicon-dark.png deleted file mode 100644 index 45e1ca9ffabbeeb0a6b76db2235892589288c5a8..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1350 zcmV-M1-bf(P)YU(o(mIsigu^<`)$vM#EH^XuVU6pn;>eP|0OkmV%wT{K!FKC(p5ogo* z03Ah6SR(Rgok-ATjAvpZ4ya>Sn;}AdppSJvs19_D573tDfQ5GcsJ0Ta8Xt#pf&ii} zhnm2BRs^Bk-#$J-dyeG5d?)=?R>C$@?3qgWbkiW)pBDRVHRQ1<_&ZC(P!deSfsO%y za;YSMi$yL%7DMd0Ai0o?6N=c#HL>4jb2hWW-OQKfzcL$UhdYx1$d$w^ojhD9g>iv{ z-xfO99`b zR@h{KovGa2YhuehYixwEa1OW;cSNaFBj#^qTKzq|b&yp9{`|AJGHb~$zDHge%qU0( zphOZ}Wu$+Nfsjp}|IPU#cXpWIpqjsqX!&TD6_496^dFro6VJvoOl^LIdmCid-@!Fw zOYEK26d_d6M^+UCXD0)&K(c(jp8iHXVTW<(x!CuJ3O0u_bi zA32Th*ljXeqPXkMqZR#+;ly0LOQQH*2H(6SRzZ8UG}3Ka`nTu^#sSpBY_2aC`Ras~ z8(mJ`>x!_pb3J7jJ|yeiHmpUj3=5>Z47Jt>>2*;&^)Rg>8Gu?N?9tG_JC%^jFw}?! zN>IwdOLyvdvpJok-9>D@xSE1pYtY%;abb0=2`S=@D9skZ)o`Emjo_(H24JNg4yfpV zODzHDM|DDF9AY4V(+l1BW@Qc=wR%)1)H-r-Z@!CrC#2V$A0Oa@6;k343H;rvVoqr6 zZ-IO-3~N4ejnxx$zG7U>QJl?STYO}EfLF^Tfa6LyspO4i@eHWOZaf1nD1|39B|*-B zrQ$WLt^L>!K0=(mVSIpT4k_iwHE?chlmoJWE-j6&mQ=Vhg@>0WbNh2Ohu=1`X@iUUbr~#ca8uFXp>(Z}nQOyjHDu#m zlSgJ_DUNlOSoSqxxbO+7pIt`Rb_>g%6KFKL5r7G{WHN-sr047bJ&&#{h~9XSpT804 z{Z_}d@2#BwVJgRN1=-h|OJi>_i+Zcb>s^KS<_;X+wP5`EB-*RzFkHHb`Sty%w5Bn| z6Ku=n4e3DRSypc3>|p~pIwkRL9c`B_oV@Ddo$Eff_vEm$r--ti`9ykZ@my=fet9>x zj{R6#cVjFre)1|A|6CvpWg@n-F>472%>+UQyi#R^O}Ii9>>(?bkPTzVjy~+h7|bBe zY#G|XBoa^RU?O+F32aJ7HKu=kG7{uSbg!~YY&Kl{iq7laMqRR91007*qo IM6N<$f^^e=1ONa4 diff --git a/deploy/authentik/branding/ultimail-favicon-light.png b/deploy/authentik/branding/ultimail-favicon-light.png deleted file mode 100644 index e3ad7cc5a083bddeb8c12715e6abae43d8b5336e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1300 zcmV+v1?&2WP)Z_rQBcfA8qCZ@f3Y+(U&$;P zLk(Xb5YuoN<=I%Xv&UwDi_j$h0Jtv6&_zHRmpvPVzK-2oo$UiL=6mYcX*-s$4E~$@! z#yV(g=?BAJi)}8AMw`&dz{PK2c^G2?(t>%FN)Kv-MqXltM@ag+P2Y`9;Sdvw*e>(H>JNjeiwVyV z|FGIx!PfPHc{2VBUx3}4buq9;j+Wjp84dGgGJ3eXoJGe}Eieetg`~4Nf`#*8&J5Y6 z3tpAakGJRnuyeK7`&dsN$jn(50&7Mg4#grG%>mi)zy8r6NZ6?|Z2?SwMH*iqcxjek z=1fT2rU$^TcxiMbV)a-=#Wlpi1V|nZn}^^|ACJ5EM3-0#>2>F#SyvRMrU)j!4U^x3 zZF>cV>w=eG7evp6EuZL7LB=~$f+G+KQi?tq3PTp^u4iEH6DWs=V!yb8HfLSOoZ2QA zYfoE=NK+ZNNqW9X<=ih`VAXPo=r;wEV<0tM4}b$Q@e+q2l0(oO=Fa}R7M$5DY5p=8 z$LU~9xzA&+%cZcUg!$j+5t(V`={-#(e&eFyZs&Sc66cgSmB?7wxKB?Aj>N&Hp@>u& zhDJ-L41HHi)fp-RoMfCxw7ff2KGC>l%;h-J> zU&#P&eNswL#{Hh9yS6m%s*0-tQ&9?^7tCbirFb$7IXGI{d`i%|NwHEU<+B?U#wW>C z%2ajKf$t=M-GdS7!4iP^zDlTR{zU}09Y)ISH@e7ByH6FBRtexk7vp3ZPWjrc13>tgAIa8wO03hWhPK8NP4@8`gM6}v{|ETTFW3X%MetUZr9~%u$ zH&e4LJax#<nXzwV)oS3kB6EE61+`aTg5Wj$t(R-;=P`AH`xlNu@oD68kBN>}M&k=a6SV z&kweXoU&cvSiO<;rG-RX&SywYA;G!jtSB(cjfv>dg`hw_2*pWT$qH1%V>BBd#acXw ziW|XPy&1}nr4eM7N3o+~8VMD%SzHlAbj2zrmZcI_vWF4Zj}v_L9K$bPBGFie)2#;N zFDM|9{{5UI568_VR9+iT(Up;0FobdPw+ZYsPT~X84B}0*S!i0!bW0000< KMNUMnLSTXgeqtQ} diff --git a/deploy/authentik/branding/ultimail-favicon.png b/deploy/authentik/branding/ultimail-favicon.png deleted file mode 100644 index b1aa9823873a3e678cbf85145dd29c2b22015c4c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1533 zcmVN0TmQQz|qa&a5^=*;KYHC_!!p3$!Lp;f<&gs6mg*KUXq z&mSlX#bWbnDU)SLbOqA6v}UW=#mEVX^-#@ABrxb~NQC=xAEL_k0t#oN+N>gp7RnIk z4wB@i(X5YiHIedV0px{gc?*3=&*G`TvROoC9>Bawa!@?z6Wnx~O=34A0RQ1bO9O@p zg&4uU<;TJ^-j}G8e3_sI6S;X+7Qj3nAm)N3Og@dZlrasIm5oJ_U_7TN*s!nJ*;@Cw zU{X&p`H!6rq>6~bd<3Y;3dHh2#4b8SGh|Hk>_*=7<@LaD0}v@?^G~{z)M%KgB%A0I z{mSq;=!uvGgar~^B=d}qumpfus4#e+%+ZnrelJp8nB=*F> zp*={UBdsa#Z$yiPBTQkwf6RB`tsmSUwAAykzac=MQ;qmyU3e zu?$5Lt%oo}uM%bg0Xdcc_{>#~&uu}9$I%SqU7p^KtVpd4`TqTUjD771->Th>A`~=a zA*s6b`z6P(+8QfCYGY_Z{f#JSz1BmJqwAfJ3;5^j&65zDIMvuP7G$$617NPp;7%y* z2LVW|%YRA|6*pvyOkT6b2Y15Y&03ydty;w|Vu-m;^P_nHWv;uia}2O!Y#*wEe4cj~ zu)l!;oss=MXpgBU%-Gp8&9me&cqI%uztVhT7J$A|kb%7)0*4r>666~UUNmxG@M6nQ zisv^bVVRKw-sKMqxTPAnZ`WgbncDnqsFsNwiPY{Pj=TpVv%B-8IoZ}2a;Et(!WtYX z)H5PAh<|;(p=(CUZjW=@cc*jww-4Z6_SgLc=@UCM!-z50q=s99Q||*OMnQYluIW*0 z45$CQB@>NdPso|`TsPU3OOBOFxE%Qt-Usfj>kpgFivSqMv5RGI>+0-l(exhZ#!-FU zO}5>g&D?|6e+ekM&=$Jpc>CYMUchfW z{GIce+HSj=b8t9u6COvEHTCtszBLe_SrFZAMenN5a;o${e}LZ0qxIo&LyVan8JME{1RmG6!THJ|I5f-poX$3YS94A(RwzeWWPBh9 z!%)gJp6-iNs6Q@n>TX?d>}qLpq)#`y(uXhc>8~$)(?zX;bVh3^O|{0*0j&$@Ni8dB zkLF_9qiIJMud(u^Ut?{Qp+rqGq=}9N(a#C?kh?9(qdRZJdbTJ<{L_q7-j3#PSElgS zDYN)l${c>Yay36pxls^*wSvF2MKSv60K=B&P>nwWL~3CF&<|h?Q!gUikbxK?!Zsj@ jwZx44E015V|9brm^_OXG1N~pk00000NkvXXu0mjf?;Y#u diff --git a/deploy/authentik/branding/ultimail-logo-dark.png b/deploy/authentik/branding/ultimail-logo-dark.png deleted file mode 100644 index e2fc127814a414c1d39bbcbbe7517709357594ea..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 39761 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 z.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 JPDHLkV1h-8H|78U literal 0 HcmV?d00001 diff --git a/deploy/authentik/branding/ultisuite-favicon.png b/deploy/authentik/branding/ultisuite-favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..7756e92e6c7ccb7715854eb2269ba2fbdd4119bb GIT binary patch literal 684 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 0 { + p := settings.Providers[0] + model := strings.TrimSpace(p.DefaultModel) + if model != "" { + out = append(out, map[string]any{ + "id": model, + "object": "model", + "owned_by": p.Name, + }) + } + } + return out, nil +} + +func (g *Gateway) ProxyChatCompletions(ctx context.Context, externalUserID string, body []byte, w http.ResponseWriter) error { + if err := g.quota.AssertAvailable(ctx, externalUserID); err != nil { + return err + } + + var req chatCompletionRequest + if err := json.Unmarshal(body, &req); err != nil { + return fmt.Errorf("invalid chat completion request: %w", err) + } + + settings, err := LoadEffectiveLLMSettings(ctx, g.db, externalUserID) + if err != nil { + return err + } + provider, model, err := resolveProviderForModel(settings, req.Model) + if err != nil { + return err + } + if strings.TrimSpace(req.Model) == "" { + req.Model = model + } + + upstreamBody, err := json.Marshal(req) + if err != nil { + return err + } + baseURL := strings.TrimRight(strings.TrimSpace(provider.BaseURL), "/") + upstreamReq, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+"/chat/completions", bytes.NewReader(upstreamBody)) + if err != nil { + return err + } + upstreamReq.Header.Set("Content-Type", "application/json") + if strings.TrimSpace(provider.APIKey) != "" { + upstreamReq.Header.Set("Authorization", "Bearer "+strings.TrimSpace(provider.APIKey)) + } + + resp, err := g.client.Do(upstreamReq) + if err != nil { + return err + } + defer resp.Body.Close() + + if req.Stream { + return g.proxyStream(ctx, externalUserID, w, resp) + } + payload, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20)) + if err != nil { + return err + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(resp.StatusCode) + _, _ = w.Write(payload) + if resp.StatusCode >= 400 { + return nil + } + tokens := extractUsageTokens(payload) + _ = g.quota.Record(ctx, externalUserID, tokens) + return nil +} + +func (g *Gateway) proxyStream(ctx context.Context, externalUserID string, w http.ResponseWriter, resp *http.Response) error { + flusher, ok := w.(http.Flusher) + if !ok { + return fmt.Errorf("streaming not supported") + } + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + w.WriteHeader(resp.StatusCode) + + reader := bufio.NewReader(resp.Body) + var totalTokens int64 + for { + line, err := reader.ReadString('\n') + if len(line) > 0 { + _, _ = w.Write([]byte(line)) + flusher.Flush() + if strings.HasPrefix(line, "data: ") && !strings.Contains(line, "[DONE]") { + totalTokens += extractStreamUsageTokens([]byte(strings.TrimPrefix(strings.TrimSpace(line), "data: "))) + } + } + if err != nil { + if err == io.EOF { + break + } + return err + } + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + } + if resp.StatusCode < 400 { + if totalTokens == 0 { + totalTokens = 1 + } + _ = g.quota.Record(ctx, externalUserID, totalTokens) + } + return nil +} + +func resolveProviderForModel(settings llm.Settings, model string) (llm.Provider, string, error) { + model = strings.TrimSpace(model) + if model != "" { + for _, p := range settings.Providers { + if p.ID == model { + return p, strings.TrimSpace(p.DefaultModel), nil + } + } + } + provider, resolvedModel, err := llm.ResolveProvider(settings, settings.DefaultProviderID) + if err != nil { + return llm.Provider{}, "", err + } + if model != "" { + resolvedModel = model + } + return provider, resolvedModel, nil +} + +func extractUsageTokens(payload []byte) int64 { + var parsed chatCompletionResponse + if err := json.Unmarshal(payload, &parsed); err != nil { + return 1 + } + if parsed.Usage != nil && parsed.Usage.TotalTokens > 0 { + return int64(parsed.Usage.TotalTokens) + } + if parsed.Usage != nil && parsed.Usage.CompletionTokens > 0 { + return int64(parsed.Usage.CompletionTokens) + } + return 1 +} + +func extractStreamUsageTokens(payload []byte) int64 { + var parsed chatCompletionResponse + if err := json.Unmarshal(payload, &parsed); err != nil { + return 0 + } + if parsed.Usage != nil && parsed.Usage.TotalTokens > 0 { + return int64(parsed.Usage.TotalTokens) + } + return 0 +} + +func NowUnix() int64 { + return time.Now().Unix() +} diff --git a/internal/ai/gateway_test.go b/internal/ai/gateway_test.go new file mode 100644 index 0000000..b5a04d9 --- /dev/null +++ b/internal/ai/gateway_test.go @@ -0,0 +1,16 @@ +package ai + +import "testing" + +func TestExtractUsageTokens(t *testing.T) { + payload := []byte(`{"usage":{"total_tokens":42,"completion_tokens":10}}`) + if got := extractUsageTokens(payload); got != 42 { + t.Fatalf("extractUsageTokens() = %d, want 42", got) + } +} + +func TestExtractUsageTokensFallback(t *testing.T) { + if got := extractUsageTokens([]byte(`{"choices":[]}`)); got != 1 { + t.Fatalf("expected fallback token count 1, got %d", got) + } +} diff --git a/internal/ai/providers.go b/internal/ai/providers.go new file mode 100644 index 0000000..c8d9cbe --- /dev/null +++ b/internal/ai/providers.go @@ -0,0 +1,187 @@ +package ai + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/ultisuite/ulti-backend/internal/llm" +) + +const orgSettingsSingletonID = 1 + +type orgLLMPolicy struct { + DefaultProviderID string `json:"default_provider_id"` + Providers []llm.Provider `json:"providers"` + EnforceOrgProviders bool `json:"enforce_org_providers"` + AllowUserOverride bool `json:"allow_user_override"` + ContactDiscoveryModel string `json:"contact_discovery_model,omitempty"` +} + +func LoadEffectiveLLMSettings(ctx context.Context, db *pgxpool.Pool, externalUserID string) (llm.Settings, error) { + if db == nil { + return llm.Settings{}, fmt.Errorf("database unavailable") + } + org, err := loadOrgLLMPolicy(ctx, db) + if err != nil { + return llm.Settings{}, err + } + user, err := loadUserLLMSettings(ctx, db, externalUserID) + if err != nil { + return llm.Settings{}, err + } + + if org.EnforceOrgProviders && len(org.Providers) > 0 { + if !org.AllowUserOverride { + return orgToSettings(org), nil + } + merged := orgToSettings(org) + if strings.TrimSpace(user.DefaultProviderID) != "" { + merged.DefaultProviderID = user.DefaultProviderID + } + if strings.TrimSpace(user.ContactDiscoveryModel) != "" { + merged.ContactDiscoveryModel = user.ContactDiscoveryModel + } + if strings.TrimSpace(user.ContactDiscoveryProvider) != "" { + merged.ContactDiscoveryProvider = user.ContactDiscoveryProvider + } + return merged, nil + } + + if len(user.Providers) > 0 { + return user, nil + } + if len(org.Providers) > 0 { + return orgToSettings(org), nil + } + return user, nil +} + +func orgToSettings(org orgLLMPolicy) llm.Settings { + return llm.Settings{ + DefaultProviderID: org.DefaultProviderID, + Providers: org.Providers, + ContactDiscoveryModel: org.ContactDiscoveryModel, + ContactDiscoveryProvider: org.DefaultProviderID, + } +} + +func loadOrgLLMPolicy(ctx context.Context, db *pgxpool.Pool) (orgLLMPolicy, error) { + var raw []byte + err := db.QueryRow(ctx, ` + SELECT settings->'llm' FROM org_settings WHERE id = $1 + `, orgSettingsSingletonID).Scan(&raw) + if err != nil { + if err == pgx.ErrNoRows { + return orgLLMPolicy{}, nil + } + return orgLLMPolicy{}, err + } + if len(raw) == 0 || string(raw) == "null" { + return orgLLMPolicy{}, nil + } + var out orgLLMPolicy + if err := json.Unmarshal(raw, &out); err != nil { + return orgLLMPolicy{}, err + } + return out, nil +} + +func loadUserLLMSettings(ctx context.Context, db *pgxpool.Pool, externalUserID string) (llm.Settings, error) { + var raw []byte + err := db.QueryRow(ctx, ` + SELECT COALESCE(s.preferences->'llm', '{}'::jsonb) + FROM users u + LEFT JOIN settings s ON s.user_id = u.id + WHERE u.external_id = $1 + `, externalUserID).Scan(&raw) + if err != nil { + if err == pgx.ErrNoRows { + return llm.Settings{}, nil + } + return llm.Settings{}, err + } + var out llm.Settings + if len(raw) > 0 { + if err := json.Unmarshal(raw, &out); err != nil { + return llm.Settings{}, err + } + } + return out, nil +} + +func LoadAssistantPolicy(ctx context.Context, db *pgxpool.Pool) (AssistantPolicy, error) { + defaults := AssistantPolicy{ + Enabled: false, + PublicPath: "/ai", + EmbedDefaultTemporary: true, + EnabledTools: []string{"mail", "drive", "contacts", "search"}, + ChatSyncEnabled: true, + ChatNCPath: "/.ultimail/ai/chats", + } + if db == nil { + return defaults, nil + } + var raw []byte + err := db.QueryRow(ctx, ` + SELECT settings->'ai_assistant' FROM org_settings WHERE id = $1 + `, orgSettingsSingletonID).Scan(&raw) + if err != nil { + if err == pgx.ErrNoRows { + return defaults, nil + } + return defaults, err + } + if len(raw) == 0 || string(raw) == "null" { + return defaults, nil + } + var stored AssistantPolicy + if err := json.Unmarshal(raw, &stored); err != nil { + return defaults, err + } + if stored.PublicPath == "" { + stored.PublicPath = defaults.PublicPath + } + if stored.ChatNCPath == "" { + stored.ChatNCPath = defaults.ChatNCPath + } + if len(stored.EnabledTools) == 0 { + stored.EnabledTools = defaults.EnabledTools + } + return stored, nil +} + +func LoadQuotaLimits(ctx context.Context, db *pgxpool.Pool) (QuotaLimits, error) { + defaults := QuotaLimits{RequestsPerDay: 100, TokensPerMonth: 500_000} + if db == nil { + return defaults, nil + } + var raw []byte + err := db.QueryRow(ctx, ` + SELECT settings->'usage_quotas' FROM org_settings WHERE id = $1 + `, orgSettingsSingletonID).Scan(&raw) + if err != nil { + if err == pgx.ErrNoRows { + return defaults, nil + } + return defaults, err + } + if len(raw) == 0 || string(raw) == "null" { + return defaults, nil + } + var stored map[string]any + if err := json.Unmarshal(raw, &stored); err != nil { + return defaults, err + } + if v, ok := stored["llm_requests_per_day"].(float64); ok && v > 0 { + defaults.RequestsPerDay = int(v) + } + if v, ok := stored["llm_tokens_per_month"].(float64); ok && v > 0 { + defaults.TokensPerMonth = int64(v) + } + return defaults, nil +} diff --git a/internal/ai/quota.go b/internal/ai/quota.go new file mode 100644 index 0000000..2729a9a --- /dev/null +++ b/internal/ai/quota.go @@ -0,0 +1,124 @@ +package ai + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +var ErrQuotaExceeded = errors.New("llm quota exceeded") + +type QuotaService struct { + db *pgxpool.Pool +} + +func NewQuotaService(db *pgxpool.Pool) *QuotaService { + return &QuotaService{db: db} +} + +func (s *QuotaService) Check(ctx context.Context, externalUserID string) (QuotaStatus, error) { + limits, err := LoadQuotaLimits(ctx, s.db) + if err != nil { + return QuotaStatus{}, err + } + userID, err := s.resolveUserID(ctx, externalUserID) + if err != nil { + return QuotaStatus{}, err + } + + today := time.Now().UTC().Truncate(24 * time.Hour) + month := time.Date(today.Year(), today.Month(), 1, 0, 0, 0, 0, time.UTC) + + var requestsToday int + var tokensMonth int64 + _ = s.db.QueryRow(ctx, ` + SELECT COALESCE(requests, 0) FROM ai_usage_daily + WHERE user_id = $1 AND usage_date = $2 + `, userID, today).Scan(&requestsToday) + _ = s.db.QueryRow(ctx, ` + SELECT COALESCE(tokens, 0) FROM ai_usage_monthly + WHERE user_id = $1 AND usage_month = $2 + `, userID, month).Scan(&tokensMonth) + + status := QuotaStatus{ + RequestsUsedToday: requestsToday, + RequestsLimit: limits.RequestsPerDay, + TokensUsedMonth: tokensMonth, + TokensLimit: limits.TokensPerMonth, + } + if limits.RequestsPerDay > 0 { + status.RequestsRemaining = limits.RequestsPerDay - requestsToday + if status.RequestsRemaining < 0 { + status.RequestsRemaining = 0 + } + } + if limits.TokensPerMonth > 0 { + status.TokensRemaining = limits.TokensPerMonth - tokensMonth + if status.TokensRemaining < 0 { + status.TokensRemaining = 0 + } + } + return status, nil +} + +func (s *QuotaService) AssertAvailable(ctx context.Context, externalUserID string) error { + status, err := s.Check(ctx, externalUserID) + if err != nil { + return err + } + if status.RequestsLimit > 0 && status.RequestsUsedToday >= status.RequestsLimit { + return fmt.Errorf("%w: daily request limit reached", ErrQuotaExceeded) + } + if status.TokensLimit > 0 && status.TokensUsedMonth >= status.TokensLimit { + return fmt.Errorf("%w: monthly token limit reached", ErrQuotaExceeded) + } + return nil +} + +func (s *QuotaService) Record(ctx context.Context, externalUserID string, tokens int64) error { + if tokens < 0 { + tokens = 0 + } + userID, err := s.resolveUserID(ctx, externalUserID) + if err != nil { + return err + } + today := time.Now().UTC().Truncate(24 * time.Hour) + month := time.Date(today.Year(), today.Month(), 1, 0, 0, 0, 0, time.UTC) + + _, err = s.db.Exec(ctx, ` + INSERT INTO ai_usage_daily (user_id, usage_date, requests, tokens) + VALUES ($1, $2, 1, $3) + ON CONFLICT (user_id, usage_date) DO UPDATE SET + requests = ai_usage_daily.requests + 1, + tokens = ai_usage_daily.tokens + EXCLUDED.tokens + `, userID, today, tokens) + if err != nil { + return err + } + _, err = s.db.Exec(ctx, ` + INSERT INTO ai_usage_monthly (user_id, usage_month, tokens) + VALUES ($1, $2, $3) + ON CONFLICT (user_id, usage_month) DO UPDATE SET + tokens = ai_usage_monthly.tokens + EXCLUDED.tokens + `, userID, month, tokens) + return err +} + +func (s *QuotaService) resolveUserID(ctx context.Context, externalUserID string) (string, error) { + var userID string + err := s.db.QueryRow(ctx, ` + SELECT id::text FROM users WHERE external_id = $1 + `, externalUserID).Scan(&userID) + if err != nil { + if err == pgx.ErrNoRows { + return "", fmt.Errorf("user not found") + } + return "", err + } + return userID, nil +} diff --git a/internal/ai/quota_test.go b/internal/ai/quota_test.go new file mode 100644 index 0000000..dd79692 --- /dev/null +++ b/internal/ai/quota_test.go @@ -0,0 +1,26 @@ +package ai + +import "testing" + +func TestQuotaStatusRemaining(t *testing.T) { + status := QuotaStatus{ + RequestsUsedToday: 40, + RequestsLimit: 100, + TokensUsedMonth: 100_000, + TokensLimit: 500_000, + } + status.RequestsRemaining = status.RequestsLimit - status.RequestsUsedToday + status.TokensRemaining = status.TokensLimit - status.TokensUsedMonth + if status.RequestsRemaining != 60 { + t.Fatalf("requests remaining = %d", status.RequestsRemaining) + } + if status.TokensRemaining != 400_000 { + t.Fatalf("tokens remaining = %d", status.TokensRemaining) + } +} + +func TestErrQuotaExceeded(t *testing.T) { + if ErrQuotaExceeded.Error() == "" { + t.Fatal("expected error message") + } +} diff --git a/internal/ai/types.go b/internal/ai/types.go new file mode 100644 index 0000000..44e2e75 --- /dev/null +++ b/internal/ai/types.go @@ -0,0 +1,78 @@ +package ai + +import "time" + +type AssistantPolicy struct { + Enabled bool `json:"enabled"` + OpenWebUIInternalURL string `json:"openwebui_internal_url"` + PublicPath string `json:"public_path"` + EmbedDefaultTemporary bool `json:"embed_default_temporary"` + DefaultModel string `json:"default_model"` + EnabledTools []string `json:"enabled_tools"` + ChatSyncEnabled bool `json:"chat_sync_enabled"` + ChatNCPath string `json:"chat_nc_path"` +} + +type QuotaLimits struct { + RequestsPerDay int `json:"llm_requests_per_day"` + TokensPerMonth int64 `json:"llm_tokens_per_month"` +} + +type QuotaStatus struct { + RequestsUsedToday int `json:"requests_used_today"` + RequestsLimit int `json:"requests_limit"` + TokensUsedMonth int64 `json:"tokens_used_month"` + TokensLimit int64 `json:"tokens_limit"` + RequestsRemaining int `json:"requests_remaining"` + TokensRemaining int64 `json:"tokens_remaining"` +} + +type ChatMessage struct { + Role string `json:"role"` + Content string `json:"content,omitempty"` + ToolCalls []any `json:"tool_calls,omitempty"` + Attachments []any `json:"attachments,omitempty"` +} + +type ChatMeta struct { + Model string `json:"model,omitempty"` + TokensUsed int64 `json:"tokens_used,omitempty"` + Context string `json:"context,omitempty"` +} + +type ChatRecord struct { + ID string `json:"id"` + Title string `json:"title"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Source string `json:"source"` + OpenWebUIChatID string `json:"openwebui_chat_id,omitempty"` + Messages []ChatMessage `json:"messages"` + Meta ChatMeta `json:"meta"` +} + +type ChatListItem struct { + ID string `json:"id"` + Title string `json:"title"` + UpdatedAt time.Time `json:"updated_at"` + Source string `json:"source"` +} + +type SessionContext struct { + App string `json:"app"` + Temporary bool `json:"temporary"` + MessageID string `json:"message_id,omitempty"` + AccountID string `json:"account_id,omitempty"` + DrivePath string `json:"drive_path,omitempty"` + FileID string `json:"file_id,omitempty"` + ContactID string `json:"contact_id,omitempty"` + Subject string `json:"subject,omitempty"` + Snippet string `json:"snippet,omitempty"` +} + +type SessionResponse struct { + SessionID string `json:"session_id"` + EmbedURL string `json:"embed_url"` + TokenSecret string `json:"token_secret,omitempty"` + Temporary bool `json:"temporary"` +} diff --git a/internal/api/admin/org_settings.go b/internal/api/admin/org_settings.go index 42cc372..63c9ed3 100644 --- a/internal/api/admin/org_settings.go +++ b/internal/api/admin/org_settings.go @@ -111,11 +111,22 @@ func defaultOrgPolicy() map[string]any { "export_mirror_format": "", "hocuspocus_url": "", }, + "ai_assistant": map[string]any{ + "enabled": false, + "openwebui_internal_url": "", + "public_path": "/ai", + "embed_default_temporary": true, + "default_model": "", + "enabled_tools": []any{"mail", "drive", "contacts", "search"}, + "chat_sync_enabled": true, + "chat_nc_path": "/.ultimail/ai/chats", + }, "plugins": []any{ map[string]any{"id": "mail-automation", "name": "Automatisations mail", "description": "Règles, webhooks et tri IA sur la réception.", "enabled": true, "version": "1.0.0"}, map[string]any{"id": "contact-discovery", "name": "Découverte contacts", "description": "Enrichissement IA et signatures détectées.", "enabled": true, "version": "1.0.0"}, map[string]any{"id": "public-share", "name": "Partage public Drive", "description": "Liens publics et partages externes.", "enabled": true, "version": "1.0.0"}, map[string]any{"id": "office-editor", "name": "Édition OnlyOffice", "description": "Édition collaborative de documents.", "enabled": false, "version": "1.0.0"}, + map[string]any{"id": "ai-assistant", "name": "UltiAI", "description": "Assistant IA intégré (chat, tools mail/drive/contacts).", "enabled": false, "version": "1.0.0"}, }, "integrations": []any{ map[string]any{"id": "authentik", "name": "Authentik", "description": "SSO, groupes et provisionnement des comptes.", "enabled": true, "configured": false}, @@ -571,6 +582,11 @@ func buildOrgEffective(cfg *config.Config) map[string]any { "enabled": cfg.JitsiEnabled, "public_url": cfg.JitsiPublicURL, }, + "ai_assistant": map[string]any{ + "enabled": cfg.AIAssistantEnabled, + "openwebui_internal_url": cfg.OpenWebUIInternalURL, + "public_path": cfg.AIAssistantPublicPath, + }, } } diff --git a/internal/api/admin/org_settings_env.go b/internal/api/admin/org_settings_env.go index d2fa50e..4bdb16d 100644 --- a/internal/api/admin/org_settings_env.go +++ b/internal/api/admin/org_settings_env.go @@ -31,6 +31,11 @@ var orgEnvVarSpecs = []envVarSpec{ {Name: "ONLYOFFICE_URL", Group: "onlyoffice", Secret: false}, {Name: "ONLYOFFICE_PUBLIC_URL", Group: "onlyoffice", Secret: false}, {Name: "ONLYOFFICE_JWT_SECRET", Group: "onlyoffice", Secret: true}, + // AI assistant + {Name: "AI_ASSISTANT_ENABLED", Group: "ai_assistant", Secret: false}, + {Name: "OPENWEBUI_URL", Group: "ai_assistant", Secret: false}, + {Name: "AI_ASSISTANT_PUBLIC_PATH", Group: "ai_assistant", Secret: false}, + {Name: "ULTIMAIL_MCP_URL", Group: "ai_assistant", Secret: false}, // Search {Name: "SEARCH_ENGINE", Group: "search", Secret: false}, {Name: "MEILISEARCH_URL", Group: "search", Secret: false}, @@ -81,6 +86,11 @@ func buildOrgDeployLocked(cfg *config.Config) map[string]any { "reason": "docker_compose", "fields": []string{"enabled", "document_server_url", "jwt_secret", "jwt_header"}, }, + "ai_assistant": map[string]any{ + "locked": true, + "reason": "docker_compose", + "fields": []string{"enabled", "openwebui_internal_url", "public_path"}, + }, "search": map[string]any{ "locked": true, "reason": "docker_compose", diff --git a/internal/api/ai/handlers.go b/internal/api/ai/handlers.go new file mode 100644 index 0000000..763d254 --- /dev/null +++ b/internal/api/ai/handlers.go @@ -0,0 +1,286 @@ +package aiapi + +import ( + "encoding/json" + "errors" + "io" + "net/http" + "net/url" + "strings" + + "github.com/go-chi/chi/v5" + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/ultisuite/ulti-backend/internal/ai" + "github.com/ultisuite/ulti-backend/internal/api/apiresponse" + "github.com/ultisuite/ulti-backend/internal/api/middleware" + "github.com/ultisuite/ulti-backend/internal/apitokens" + "github.com/ultisuite/ulti-backend/internal/auth" + "github.com/ultisuite/ulti-backend/internal/config" + "github.com/ultisuite/ulti-backend/internal/nextcloud" +) + +const sessionAccessCookie = "ulti_access_token" + +type Handler struct { + db *pgxpool.Pool + cfg *config.Config + gateway *ai.Gateway + quota *ai.QuotaService + chats *ai.ChatStore + verify *auth.Holder +} + +func NewHandler(db *pgxpool.Pool, cfg *config.Config, nc *nextcloud.Client, verifier *auth.Holder) *Handler { + return &Handler{ + db: db, + cfg: cfg, + gateway: ai.NewGateway(db), + quota: ai.NewQuotaService(db), + chats: ai.NewChatStore(nc, db), + verify: verifier, + } +} + +func (h *Handler) Routes(authMiddleware func(http.Handler) http.Handler) chi.Router { + r := chi.NewRouter() + r.Get("/config", h.GetConfig) + r.Get("/embed-auth", h.EmbedAuth) + r.Group(func(r chi.Router) { + r.Use(authMiddleware) + r.Get("/quota", h.GetQuota) + r.Get("/models", h.ListModels) + r.Post("/chat/completions", h.ChatCompletions) + r.Post("/v1/chat/completions", h.ChatCompletions) + r.Post("/sessions", h.CreateSession) + r.Get("/chats/{chatID}", h.GetChat) + r.Delete("/chats/{chatID}", h.DeleteChat) + r.Post("/chats/sync", h.SyncChat) + }) + return r +} + +func (h *Handler) GetConfig(w http.ResponseWriter, r *http.Request) { + policy, err := ai.LoadAssistantPolicy(r.Context(), h.db) + if err != nil { + apiresponse.WriteError(w, r, http.StatusInternalServerError, apiresponse.CodeInternal, "failed to load ai config", nil) + return + } + publicPath := policy.PublicPath + if strings.TrimSpace(publicPath) == "" { + publicPath = "/ai" + } + if h.cfg != nil && strings.TrimSpace(h.cfg.AIAssistantPublicPath) != "" { + publicPath = h.cfg.AIAssistantPublicPath + } + apiresponse.WriteJSON(w, http.StatusOK, map[string]any{ + "enabled": policy.Enabled || (h.cfg != nil && h.cfg.AIAssistantEnabled), + "public_path": publicPath, + "embed_default_temporary": policy.EmbedDefaultTemporary, + "default_model": policy.DefaultModel, + "enabled_tools": policy.EnabledTools, + "chat_sync_enabled": policy.ChatSyncEnabled, + }) +} + +func (h *Handler) EmbedAuth(w http.ResponseWriter, r *http.Request) { + claims, ok := h.resolveClaims(r) + if !ok { + w.WriteHeader(http.StatusUnauthorized) + return + } + w.Header().Set("X-Ulti-User-Email", claims.Email) + if strings.TrimSpace(claims.Name) != "" { + w.Header().Set("X-Ulti-User-Name", claims.Name) + } else { + w.Header().Set("X-Ulti-User-Name", claims.Email) + } + w.Header().Set("X-Ulti-User-Role", "user") + w.WriteHeader(http.StatusOK) +} + +func (h *Handler) GetQuota(w http.ResponseWriter, r *http.Request) { + claims := middleware.ClaimsFromContext(r.Context()) + if claims == nil { + apiresponse.WriteError(w, r, http.StatusUnauthorized, apiresponse.CodeAuthUnauthorized, "unauthorized", nil) + return + } + status, err := h.quota.Check(r.Context(), claims.Sub) + if err != nil { + apiresponse.WriteError(w, r, http.StatusInternalServerError, apiresponse.CodeInternal, err.Error(), nil) + return + } + apiresponse.WriteJSON(w, http.StatusOK, status) +} + +func (h *Handler) ListModels(w http.ResponseWriter, r *http.Request) { + claims := middleware.ClaimsFromContext(r.Context()) + if claims == nil { + apiresponse.WriteError(w, r, http.StatusUnauthorized, apiresponse.CodeAuthUnauthorized, "unauthorized", nil) + return + } + models, err := h.gateway.ListModels(r.Context(), claims.Sub) + if err != nil { + apiresponse.WriteError(w, r, http.StatusBadRequest, apiresponse.CodeInvalidRequest, err.Error(), nil) + return + } + apiresponse.WriteJSON(w, http.StatusOK, map[string]any{ + "object": "list", + "data": models, + }) +} + +func (h *Handler) ChatCompletions(w http.ResponseWriter, r *http.Request) { + claims := middleware.ClaimsFromContext(r.Context()) + if claims == nil { + apiresponse.WriteError(w, r, http.StatusUnauthorized, apiresponse.CodeAuthUnauthorized, "unauthorized", nil) + return + } + body, err := io.ReadAll(io.LimitReader(r.Body, 8<<20)) + if err != nil { + apiresponse.WriteError(w, r, http.StatusBadRequest, apiresponse.CodeInvalidRequest, "invalid body", nil) + return + } + if err := h.gateway.ProxyChatCompletions(r.Context(), claims.Sub, body, w); err != nil { + if errors.Is(err, ai.ErrQuotaExceeded) { + apiresponse.WriteError(w, r, http.StatusTooManyRequests, apiresponse.CodeRateLimited, err.Error(), nil) + return + } + apiresponse.WriteError(w, r, http.StatusBadGateway, apiresponse.CodeInternal, err.Error(), nil) + return + } +} + +func (h *Handler) CreateSession(w http.ResponseWriter, r *http.Request) { + claims := middleware.ClaimsFromContext(r.Context()) + if claims == nil { + apiresponse.WriteError(w, r, http.StatusUnauthorized, apiresponse.CodeAuthUnauthorized, "unauthorized", nil) + return + } + var req ai.SessionContext + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + apiresponse.WriteError(w, r, http.StatusBadRequest, apiresponse.CodeInvalidRequest, "invalid json", nil) + return + } + preset := apitokens.ChatSessionStandalone + switch strings.ToLower(strings.TrimSpace(req.App)) { + case "mail": + preset = apitokens.ChatSessionMail + case "drive": + preset = apitokens.ChatSessionDrive + case "contacts": + preset = apitokens.ChatSessionContacts + case "docs": + preset = apitokens.ChatSessionDocs + } + allowWrite := preset == apitokens.ChatSessionDocs + created, err := apitokens.CreateChatSession(r.Context(), h.db, claims.Sub, claims.Email, apitokens.ChatSessionInput{ + Preset: preset, + DrivePath: req.DrivePath, + AllowWrite: allowWrite, + }) + if err != nil { + apiresponse.WriteError(w, r, http.StatusInternalServerError, apiresponse.CodeInternal, err.Error(), nil) + return + } + policy, _ := ai.LoadAssistantPolicy(r.Context(), h.db) + publicPath := policy.PublicPath + if strings.TrimSpace(publicPath) == "" { + publicPath = "/ai" + } + if h.cfg != nil && strings.TrimSpace(h.cfg.AIAssistantPublicPath) != "" { + publicPath = h.cfg.AIAssistantPublicPath + } + temporary := req.Temporary || policy.EmbedDefaultTemporary + q := url.Values{} + if temporary { + q.Set("temporary-chat", "true") + } + if strings.TrimSpace(req.App) != "" { + q.Set("app", req.App) + } + embedURL := strings.TrimRight(publicPath, "/") + "/" + if enc := q.Encode(); enc != "" { + embedURL += "?" + enc + } + apiresponse.WriteJSON(w, http.StatusOK, ai.SessionResponse{ + SessionID: created.ID, + EmbedURL: embedURL, + TokenSecret: created.TokenSecret, + Temporary: temporary, + }) +} + +func (h *Handler) SyncChat(w http.ResponseWriter, r *http.Request) { + claims := middleware.ClaimsFromContext(r.Context()) + if claims == nil { + apiresponse.WriteError(w, r, http.StatusUnauthorized, apiresponse.CodeAuthUnauthorized, "unauthorized", nil) + return + } + policy, _ := ai.LoadAssistantPolicy(r.Context(), h.db) + if !policy.ChatSyncEnabled { + apiresponse.WriteError(w, r, http.StatusForbidden, apiresponse.CodeAuthForbidden, "chat sync disabled", nil) + return + } + var record ai.ChatRecord + if err := json.NewDecoder(r.Body).Decode(&record); err != nil { + apiresponse.WriteError(w, r, http.StatusBadRequest, apiresponse.CodeInvalidRequest, "invalid json", nil) + return + } + if err := h.chats.Sync(r.Context(), claims.Email, record); err != nil { + apiresponse.WriteError(w, r, http.StatusBadGateway, apiresponse.CodeInternal, err.Error(), nil) + return + } + apiresponse.WriteJSON(w, http.StatusOK, map[string]any{"ok": true}) +} + +func (h *Handler) GetChat(w http.ResponseWriter, r *http.Request) { + claims := middleware.ClaimsFromContext(r.Context()) + if claims == nil { + apiresponse.WriteError(w, r, http.StatusUnauthorized, apiresponse.CodeAuthUnauthorized, "unauthorized", nil) + return + } + chatID := chi.URLParam(r, "chatID") + record, err := h.chats.Get(r.Context(), claims.Email, chatID) + if err != nil { + apiresponse.WriteError(w, r, http.StatusNotFound, apiresponse.CodeNotFound, "chat not found", nil) + return + } + apiresponse.WriteJSON(w, http.StatusOK, record) +} + +func (h *Handler) DeleteChat(w http.ResponseWriter, r *http.Request) { + claims := middleware.ClaimsFromContext(r.Context()) + if claims == nil { + apiresponse.WriteError(w, r, http.StatusUnauthorized, apiresponse.CodeAuthUnauthorized, "unauthorized", nil) + return + } + chatID := chi.URLParam(r, "chatID") + if err := h.chats.Delete(r.Context(), claims.Email, chatID); err != nil { + apiresponse.WriteError(w, r, http.StatusNotFound, apiresponse.CodeNotFound, "chat not found", nil) + return + } + apiresponse.WriteJSON(w, http.StatusOK, map[string]any{"ok": true}) +} + +func (h *Handler) resolveClaims(r *http.Request) (*auth.Claims, bool) { + if header := strings.TrimSpace(r.Header.Get("Authorization")); strings.HasPrefix(header, "Bearer ") { + token := strings.TrimSpace(strings.TrimPrefix(header, "Bearer ")) + if h.verify != nil && h.verify.Ready() { + claims, err := h.verify.Verify(r.Context(), token) + if err == nil { + return claims, true + } + } + } + if cookie, err := r.Cookie(sessionAccessCookie); err == nil { + token := strings.TrimSpace(cookie.Value) + if token != "" && h.verify != nil && h.verify.Ready() { + claims, err := h.verify.Verify(r.Context(), token) + if err == nil { + return claims, true + } + } + } + return nil, false +} diff --git a/internal/api/drive/blank_office.go b/internal/api/drive/blank_office.go index ce4403c..11108c4 100644 --- a/internal/api/drive/blank_office.go +++ b/internal/api/drive/blank_office.go @@ -11,6 +11,9 @@ var blankXlsx []byte //go:embed testdata/blank.pptx var blankPptx []byte +//go:embed testdata/blank.excalidraw +var blankExcalidraw []byte + func blankOfficeFile(kind NewFileKind) ([]byte, string) { switch kind { case NewFileDocument: @@ -19,6 +22,8 @@ func blankOfficeFile(kind NewFileKind) ([]byte, string) { return blankXlsx, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" case NewFilePresentation: return blankPptx, "application/vnd.openxmlformats-officedocument.presentationml.presentation" + case NewFileDrawing: + return blankExcalidraw, "application/json" default: return nil, "" } diff --git a/internal/api/drive/blank_office_test.go b/internal/api/drive/blank_office_test.go new file mode 100644 index 0000000..65ce779 --- /dev/null +++ b/internal/api/drive/blank_office_test.go @@ -0,0 +1,23 @@ +package drive + +import "testing" + +func TestBlankOfficeFileDrawing(t *testing.T) { + content, ct := blankOfficeFile(NewFileDrawing) + if content == nil || len(content) == 0 { + t.Fatal("expected blank excalidraw content") + } + if ct != "application/json" { + t.Fatalf("content type = %q, want application/json", ct) + } +} + +func TestBlankOfficeFileUnknownKind(t *testing.T) { + content, ct := blankOfficeFile(NewFileKind("drawing")) + if content == nil { + t.Fatal("NewFileKind drawing should produce content") + } + if ct == "" { + t.Fatal("expected content type") + } +} diff --git a/internal/api/drive/handlers.go b/internal/api/drive/handlers.go index f6c8775..1d33abc 100644 --- a/internal/api/drive/handlers.go +++ b/internal/api/drive/handlers.go @@ -26,6 +26,7 @@ type Handler struct { svc *Service publicOffice PublicOfficeAPI publicRichText PublicRichTextAPI + publicUltidraw PublicUltidrawAPI logger *slog.Logger } @@ -33,6 +34,10 @@ type PublicRichTextAPI interface { RegisterPublicShareRoutes(r chi.Router) } +type PublicUltidrawAPI interface { + RegisterPublicShareRoutes(r chi.Router) +} + func NewHandler(nc *nextcloud.Client, hub *realtime.Hub, db *pgxpool.Pool) *Handler { return NewHandlerWithService(NewService(nc, hub, db)) } @@ -52,6 +57,10 @@ func (h *Handler) SetPublicRichText(api PublicRichTextAPI) { h.publicRichText = api } +func (h *Handler) SetPublicUltidraw(api PublicUltidrawAPI) { + h.publicUltidraw = api +} + func (h *Handler) nextcloudUser(w http.ResponseWriter, r *http.Request, claims *auth.Claims) (string, bool) { userID, err := h.svc.EnsureNextcloudUser(r.Context(), claims) if err != nil { diff --git a/internal/api/drive/public_handlers.go b/internal/api/drive/public_handlers.go index 27b1034..daed6c4 100644 --- a/internal/api/drive/public_handlers.go +++ b/internal/api/drive/public_handlers.go @@ -27,6 +27,9 @@ func (h *Handler) PublicRoutes() chi.Router { if h.publicRichText != nil { h.publicRichText.RegisterPublicShareRoutes(r) } + if h.publicUltidraw != nil { + h.publicUltidraw.RegisterPublicShareRoutes(r) + } return r } diff --git a/internal/api/drive/service.go b/internal/api/drive/service.go index 904f741..c6bc4b1 100644 --- a/internal/api/drive/service.go +++ b/internal/api/drive/service.go @@ -297,13 +297,16 @@ func (s *Service) CreateFolder(ctx context.Context, userID, path string) error { func (s *Service) Move(ctx context.Context, userID, source, destination string) error { source = nextcloud.NormalizeClientFilePath(userID, nextcloud.NormalizeClientPath(source)) destination = nextcloud.NormalizeClientFilePath(userID, nextcloud.NormalizeClientPath(destination)) - return mapDriveError(s.nc.Move(ctx, userID, source, destination)) + return s.moveWithUltidocSidecar(ctx, userID, source, destination) } func (s *Service) Copy(ctx context.Context, userID, source, destination string) error { source = nextcloud.NormalizeClientFilePath(userID, nextcloud.NormalizeClientPath(source)) destination = nextcloud.NormalizeClientFilePath(userID, nextcloud.NormalizeClientPath(destination)) - return mapDriveError(s.nc.Copy(ctx, userID, source, destination)) + if err := mapDriveError(s.nc.Copy(ctx, userID, source, destination)); err != nil { + return err + } + return s.syncUltidocSidecarCopy(ctx, userID, source, destination) } func (s *Service) Rename(ctx context.Context, userID, filePath, newName string) error { @@ -313,7 +316,7 @@ func (s *Service) Rename(ctx context.Context, userID, filePath, newName string) filePath = nextcloud.NormalizeClientFilePath(userID, nextcloud.NormalizeClientPath(filePath)) dir := path.Dir("/" + strings.TrimPrefix(filePath, "/")) destination := path.Join(dir, newName) - return mapDriveError(s.nc.Move(ctx, userID, filePath, destination)) + return s.moveWithUltidocSidecar(ctx, userID, filePath, destination) } func (s *Service) CreateShare(ctx context.Context, userID, filePath string, req createShareRequest, permissions int) (*nextcloud.ShareInfo, error) { @@ -568,6 +571,7 @@ const ( NewFileDocument NewFileKind = "document" NewFileSpreadsheet NewFileKind = "spreadsheet" NewFilePresentation NewFileKind = "presentation" + NewFileDrawing NewFileKind = "drawing" ) func (s *Service) CreateNewFile(ctx context.Context, userID, parentPath, name string, kind NewFileKind) (string, int64, error) { diff --git a/internal/api/drive/testdata/blank.excalidraw b/internal/api/drive/testdata/blank.excalidraw new file mode 100644 index 0000000..e367b91 --- /dev/null +++ b/internal/api/drive/testdata/blank.excalidraw @@ -0,0 +1 @@ +{"type":"excalidraw","version":2,"source":"https://ultidrive","elements":[],"appState":{"gridSize":null,"viewBackgroundColor":"#ffffff"},"files":{}} diff --git a/internal/api/drive/ultidoc_sidecar.go b/internal/api/drive/ultidoc_sidecar.go new file mode 100644 index 0000000..88591b0 --- /dev/null +++ b/internal/api/drive/ultidoc_sidecar.go @@ -0,0 +1,106 @@ +package drive + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + + "github.com/ultisuite/ulti-backend/internal/nextcloud" +) + +func shouldSyncUltidocSidecar(path string) bool { + return !nextcloud.IsUltidocSidecarPath(path) +} + +func sidecarPathsForSourceMove(sourcePath, destinationPath string) (sidecarSource, sidecarDestination string, ok bool) { + if !shouldSyncUltidocSidecar(sourcePath) { + return "", "", false + } + return nextcloud.SidecarPathForSource(sourcePath), nextcloud.SidecarPathForSource(destinationPath), true +} + +func (s *Service) sidecarExists(ctx context.Context, userID, path string) bool { + _, err := s.nc.FileRevision(ctx, userID, path) + return err == nil +} + +func (s *Service) syncUltidocSidecarCopy( + ctx context.Context, + userID, sourcePath, destinationPath string, +) error { + sidecarSource, sidecarDestination, ok := sidecarPathsForSourceMove(sourcePath, destinationPath) + if !ok || !s.sidecarExists(ctx, userID, sidecarSource) { + return nil + } + + if err := mapDriveError(s.nc.Copy(ctx, userID, sidecarSource, sidecarDestination)); err != nil { + return err + } + + return s.patchUltidocSidecarSourcePath(ctx, userID, sidecarDestination, destinationPath) +} + +func (s *Service) patchUltidocSidecarSourcePath( + ctx context.Context, + userID, sidecarPath, newSourcePath string, +) error { + body, _, err := s.nc.Download(ctx, userID, sidecarPath) + if err != nil { + return mapDriveError(err) + } + defer body.Close() + + raw, err := io.ReadAll(body) + if err != nil { + return err + } + + var doc map[string]any + if err := json.Unmarshal(raw, &doc); err != nil { + return err + } + + source, _ := doc["source"].(map[string]any) + if source == nil { + source = map[string]any{} + doc["source"] = source + } + source["path"] = newSourcePath + + encoded, err := json.Marshal(doc) + if err != nil { + return err + } + + if err := mapDriveError(s.nc.Upload(ctx, userID, sidecarPath, bytes.NewReader(encoded), "application/json")); err != nil { + return err + } + return nil +} + +func (s *Service) moveWithUltidocSidecar(ctx context.Context, userID, source, destination string) error { + sidecarSource, sidecarDestination, ok := sidecarPathsForSourceMove(source, destination) + hasSidecar := ok && s.sidecarExists(ctx, userID, sidecarSource) + + if hasSidecar { + if err := mapDriveError(s.nc.Move(ctx, userID, sidecarSource, sidecarDestination)); err != nil { + return err + } + } + + if err := mapDriveError(s.nc.Move(ctx, userID, source, destination)); err != nil { + if hasSidecar { + _ = s.nc.Move(ctx, userID, sidecarDestination, sidecarSource) + } + return err + } + + if hasSidecar { + if err := s.patchUltidocSidecarSourcePath(ctx, userID, sidecarDestination, destination); err != nil { + return errors.Join(err, mapDriveError(s.nc.Move(ctx, userID, destination, source))) + } + } + return nil +} diff --git a/internal/api/drive/ultidoc_sidecar_test.go b/internal/api/drive/ultidoc_sidecar_test.go new file mode 100644 index 0000000..c5bc607 --- /dev/null +++ b/internal/api/drive/ultidoc_sidecar_test.go @@ -0,0 +1,31 @@ +package drive + +import ( + "testing" + + "github.com/ultisuite/ulti-backend/internal/nextcloud" +) + +func TestSidecarPathsForSourceMove(t *testing.T) { + src, dest, ok := sidecarPathsForSourceMove("/docs/a.docx", "/archive/a.docx") + if !ok { + t.Fatal("expected sidecar sync for docx") + } + if src != "/docs/a.ultidoc.json" || dest != "/archive/a.ultidoc.json" { + t.Fatalf("unexpected sidecar paths: %q -> %q", src, dest) + } + + _, _, ok = sidecarPathsForSourceMove("/docs/a.ultidoc.json", "/archive/a.ultidoc.json") + if ok { + t.Fatal("ultidoc sidecar itself should not trigger companion sync") + } +} + +func TestShouldSyncUltidocSidecar(t *testing.T) { + if !shouldSyncUltidocSidecar("/docs/report.docx") { + t.Fatal("docx should sync sidecar") + } + if shouldSyncUltidocSidecar(nextcloud.SidecarPathForSource("/docs/report.docx")) { + t.Fatal("sidecar path should not sync again") + } +} diff --git a/internal/api/drive/validate.go b/internal/api/drive/validate.go index 38fd5bd..5019a4c 100644 --- a/internal/api/drive/validate.go +++ b/internal/api/drive/validate.go @@ -177,7 +177,7 @@ func validateNewFileRequest(req *newFileRequest) *apivalidate.ValidationError { details = append(details, apivalidate.FieldDetail{Field: "name", Message: "required"}) } k := strings.TrimSpace(strings.ToLower(req.Kind)) - if k != "document" && k != "spreadsheet" && k != "presentation" { + if k != "document" && k != "spreadsheet" && k != "presentation" && k != "drawing" { details = append(details, apivalidate.FieldDetail{Field: "kind", Message: "invalid"}) } if len(details) > 0 { diff --git a/internal/api/richtext/document.go b/internal/api/richtext/document.go index c5f1409..6b47f31 100644 --- a/internal/api/richtext/document.go +++ b/internal/api/richtext/document.go @@ -15,8 +15,9 @@ type UltiDoc struct { Editor string `json:"editor"` Source *UltiDocSource `json:"source,omitempty"` Content json.RawMessage `json:"content"` - PageSetup *UltiDocPageSetup `json:"pageSetup,omitempty"` - YjsState string `json:"yjsState,omitempty"` + PageSetup *UltiDocPageSetup `json:"pageSetup,omitempty"` + ParagraphStyles *UltiDocParagraphStyles `json:"paragraphStyles,omitempty"` + YjsState string `json:"yjsState,omitempty"` UpdatedAt string `json:"updatedAt,omitempty"` } @@ -168,6 +169,9 @@ func preserveUltiDocMetadata(dst *UltiDoc, existing UltiDoc) { dst.Content = existing.Content } } + if dst.ParagraphStyles == nil && existing.ParagraphStyles != nil { + dst.ParagraphStyles = existing.ParagraphStyles + } if dst.YjsState == "" && existing.YjsState != "" { dst.YjsState = existing.YjsState } @@ -178,8 +182,9 @@ type ultiDocPatch struct { Editor string `json:"editor"` Content json.RawMessage `json:"content"` Document json.RawMessage `json:"document"` - PageSetup *UltiDocPageSetup `json:"pageSetup"` - YjsState string `json:"yjsState"` + PageSetup *UltiDocPageSetup `json:"pageSetup"` + ParagraphStyles *UltiDocParagraphStyles `json:"paragraphStyles"` + YjsState string `json:"yjsState"` } // ApplyUltiDocPatch merges a partial JSON payload into an existing UltiDoc. @@ -207,6 +212,9 @@ func ApplyUltiDocPatch(existing UltiDoc, raw json.RawMessage) (UltiDoc, error) { if patch.PageSetup != nil { doc.PageSetup = patch.PageSetup } + if patch.ParagraphStyles != nil { + doc.ParagraphStyles = patch.ParagraphStyles + } if patch.YjsState != "" { doc.YjsState = patch.YjsState } diff --git a/internal/api/richtext/handlers.go b/internal/api/richtext/handlers.go index 7cfeda4..8ea06e4 100644 --- a/internal/api/richtext/handlers.go +++ b/internal/api/richtext/handlers.go @@ -44,6 +44,9 @@ func (h *Handler) Routes(authMiddleware func(http.Handler) http.Handler) chi.Rou pr.With(read).Post("/session", h.CreateSession) pr.With(read).Post("/import", h.Import) pr.With(read).Post("/export", h.Export) + pr.With(read).Get("/fonts", h.ListFonts) + pr.With(read).Get("/user-paragraph-styles", h.GetUserParagraphStyles) + pr.With(write).Put("/user-paragraph-styles", h.PutUserParagraphStyles) pr.With(write).Post("/assets", h.UploadAsset) pr.With(write).Put("/save", h.Save) }) @@ -133,10 +136,11 @@ func (h *Handler) Export(w http.ResponseWriter, r *http.Request) { } type saveRequest struct { - Path string `json:"path"` - Document json.RawMessage `json:"document"` - PageSetup json.RawMessage `json:"pageSetup,omitempty"` - YjsState string `json:"yjsState,omitempty"` + Path string `json:"path"` + Document json.RawMessage `json:"document"` + PageSetup json.RawMessage `json:"pageSetup,omitempty"` + ParagraphStyles json.RawMessage `json:"paragraphStyles,omitempty"` + YjsState string `json:"yjsState,omitempty"` } func (h *Handler) Save(w http.ResponseWriter, r *http.Request) { @@ -169,9 +173,15 @@ func (h *Handler) Save(w http.ResponseWriter, r *http.Request) { } else { doc = NewUltiDoc(nil, nil) } + case len(req.ParagraphStyles) > 0: + if existing.SchemaVersion > 0 { + doc = existing + } else { + doc = NewUltiDoc(nil, nil) + } default: apivalidate.WriteValidationError(w, r, apivalidate.NewValidationError( - apivalidate.FieldDetail{Field: "document", Message: "document or pageSetup required"}, + apivalidate.FieldDetail{Field: "document", Message: "document, pageSetup or paragraphStyles required"}, )) return } @@ -182,6 +192,12 @@ func (h *Handler) Save(w http.ResponseWriter, r *http.Request) { doc.PageSetup = &pageSetup } } + if len(req.ParagraphStyles) > 0 { + var paragraphStyles UltiDocParagraphStyles + if err := json.Unmarshal(req.ParagraphStyles, ¶graphStyles); err == nil { + doc.ParagraphStyles = ¶graphStyles + } + } if req.YjsState != "" { doc.YjsState = req.YjsState } @@ -375,3 +391,41 @@ func (h *Handler) UploadAsset(w http.ResponseWriter, r *http.Request) { } apiresponse.WriteJSON(w, http.StatusOK, result) } + +func (h *Handler) ListFonts(w http.ResponseWriter, r *http.Request) { + fonts := h.svc.ListFonts(r.Context()) + apiresponse.WriteJSON(w, http.StatusOK, map[string]any{"fonts": fonts}) +} + +func (h *Handler) GetUserParagraphStyles(w http.ResponseWriter, r *http.Request) { + claims := middleware.ClaimsFromContext(r.Context()) + ncUser, err := h.svc.EnsureNextcloudUser(r.Context(), claims) + if err != nil { + apivalidate.WriteInternal(w, r) + return + } + styles, err := h.svc.LoadUserParagraphStyles(r.Context(), ncUser) + if err != nil { + apivalidate.WriteInternal(w, r) + return + } + apiresponse.WriteJSON(w, http.StatusOK, styles) +} + +func (h *Handler) PutUserParagraphStyles(w http.ResponseWriter, r *http.Request) { + claims := middleware.ClaimsFromContext(r.Context()) + ncUser, err := h.svc.EnsureNextcloudUser(r.Context(), claims) + if err != nil { + apivalidate.WriteInternal(w, r) + return + } + var styles UltiDocParagraphStyles + if err := apivalidate.DecodeJSON(w, r, 512<<10, &styles); err != nil { + return + } + if err := h.svc.SaveUserParagraphStyles(r.Context(), ncUser, styles); err != nil { + apivalidate.WriteInternal(w, r) + return + } + apiresponse.WriteJSON(w, http.StatusOK, styles) +} diff --git a/internal/api/richtext/paragraph_styles.go b/internal/api/richtext/paragraph_styles.go new file mode 100644 index 0000000..76dabbf --- /dev/null +++ b/internal/api/richtext/paragraph_styles.go @@ -0,0 +1,114 @@ +package richtext + +import ( + "context" + "encoding/json" + "fmt" + "strings" +) + +const userParagraphStylesPath = "/.ultidrive/docs-user-paragraph-styles.json" + +type UltiDocParagraphStyleDefinition struct { + ID string `json:"id"` + Name string `json:"name"` + Scope string `json:"scope"` + BasedOn string `json:"basedOn,omitempty"` + BlockType string `json:"blockType"` + Level *int `json:"level,omitempty"` + FontFamily string `json:"fontFamily,omitempty"` + FontSizePx float64 `json:"fontSizePx,omitempty"` + Bold *bool `json:"bold,omitempty"` + Italic *bool `json:"italic,omitempty"` + Underline *bool `json:"underline,omitempty"` + Color string `json:"color,omitempty"` + TextAlign string `json:"textAlign,omitempty"` + LineHeight float64 `json:"lineHeight,omitempty"` + SpaceBeforePt float64 `json:"spaceBeforePt,omitempty"` + SpaceAfterPt float64 `json:"spaceAfterPt,omitempty"` +} + +type UltiDocParagraphStyles struct { + Definitions map[string]UltiDocParagraphStyleDefinition `json:"definitions"` +} + +type DocsFontDefinition struct { + Name string `json:"name"` + Stack string `json:"stack"` + Source string `json:"source,omitempty"` + URL string `json:"url,omitempty"` +} + +var defaultDocsFonts = []DocsFontDefinition{ + {Name: "Arial", Stack: "Arial, Helvetica, sans-serif", Source: "system"}, + {Name: "Calibri", Stack: "Calibri, Candara, Segoe, sans-serif", Source: "system"}, + {Name: "Comic Sans MS", Stack: `"Comic Sans MS", cursive, sans-serif`, Source: "system"}, + {Name: "Courier New", Stack: `"Courier New", Courier, monospace`, Source: "system"}, + {Name: "Georgia", Stack: "Georgia, serif", Source: "system"}, + {Name: "Times New Roman", Stack: `"Times New Roman", Times, serif`, Source: "system"}, + {Name: "Trebuchet MS", Stack: `"Trebuchet MS", Helvetica, sans-serif`, Source: "system"}, + {Name: "Verdana", Stack: "Verdana, Geneva, sans-serif", Source: "system"}, +} + +func defaultUltiDocParagraphStyles() UltiDocParagraphStyles { + level := func(n int) *int { v := n; return &v } + bold := func(v bool) *bool { return &v } + italic := func(v bool) *bool { return &v } + return UltiDocParagraphStyles{ + Definitions: map[string]UltiDocParagraphStyleDefinition{ + "normal": {ID: "normal", Name: "Normal", Scope: "document", BlockType: "paragraph", FontFamily: "Arial, Helvetica, sans-serif", FontSizePx: 11, LineHeight: 1.15}, + "title": {ID: "title", Name: "Titre", Scope: "document", BlockType: "paragraph", FontFamily: "Arial, Helvetica, sans-serif", FontSizePx: 26, LineHeight: 1.15}, + "subtitle": {ID: "subtitle", Name: "Sous-titre", Scope: "document", BlockType: "paragraph", FontFamily: "Arial, Helvetica, sans-serif", FontSizePx: 15, Color: "#666666", LineHeight: 1.15}, + "heading1": {ID: "heading1", Name: "Titre 1", Scope: "document", BlockType: "heading", Level: level(1), FontFamily: "Arial, Helvetica, sans-serif", FontSizePx: 20, LineHeight: 1.15}, + "heading2": {ID: "heading2", Name: "Titre 2", Scope: "document", BlockType: "heading", Level: level(2), FontFamily: "Arial, Helvetica, sans-serif", FontSizePx: 16, LineHeight: 1.15}, + "heading3": {ID: "heading3", Name: "Titre 3", Scope: "document", BlockType: "heading", Level: level(3), FontFamily: "Arial, Helvetica, sans-serif", FontSizePx: 14, LineHeight: 1.15}, + "heading4": {ID: "heading4", Name: "Titre 4", Scope: "document", BlockType: "heading", Level: level(4), FontFamily: "Arial, Helvetica, sans-serif", FontSizePx: 12, Bold: bold(true), LineHeight: 1.15}, + "heading5": {ID: "heading5", Name: "Titre 5", Scope: "document", BlockType: "heading", Level: level(5), FontFamily: "Arial, Helvetica, sans-serif", FontSizePx: 11, Bold: bold(true), LineHeight: 1.15}, + "heading6": {ID: "heading6", Name: "Titre 6", Scope: "document", BlockType: "heading", Level: level(6), FontFamily: "Arial, Helvetica, sans-serif", FontSizePx: 11, Italic: italic(true), LineHeight: 1.15}, + }, + } +} + +func (s *Service) ListFonts(_ context.Context) []DocsFontDefinition { + out := make([]DocsFontDefinition, len(defaultDocsFonts)) + copy(out, defaultDocsFonts) + return out +} + +func (s *Service) LoadUserParagraphStyles(ctx context.Context, ncUser string) (UltiDocParagraphStyles, error) { + body, err := s.LoadDocument(ctx, ncUser, userParagraphStylesPath) + if err != nil || len(body) == 0 { + return UltiDocParagraphStyles{Definitions: map[string]UltiDocParagraphStyleDefinition{}}, nil + } + var styles UltiDocParagraphStyles + if err := json.Unmarshal(body, &styles); err != nil { + return UltiDocParagraphStyles{}, fmt.Errorf("parse user paragraph styles: %w", err) + } + if styles.Definitions == nil { + styles.Definitions = map[string]UltiDocParagraphStyleDefinition{} + } + return styles, nil +} + +func (s *Service) SaveUserParagraphStyles(ctx context.Context, ncUser string, styles UltiDocParagraphStyles) error { + if styles.Definitions == nil { + styles.Definitions = map[string]UltiDocParagraphStyleDefinition{} + } + payload, err := json.MarshalIndent(styles, "", " ") + if err != nil { + return err + } + return s.nc.Upload(ctx, ncUser, userParagraphStylesPath, strings.NewReader(string(payload)), "application/json") +} + +func (s *Service) loadParagraphStylesFromSidecar(ctx context.Context, ncUser, canonical string) (json.RawMessage, error) { + raw, err := s.LoadDocument(ctx, ncUser, canonical) + if err != nil || len(raw) == 0 { + return nil, err + } + doc, err := ParseUltiDoc(raw) + if err != nil || doc.ParagraphStyles == nil { + return nil, err + } + return json.Marshal(doc.ParagraphStyles) +} diff --git a/internal/api/richtext/service.go b/internal/api/richtext/service.go index 137d653..36d6227 100644 --- a/internal/api/richtext/service.go +++ b/internal/api/richtext/service.go @@ -44,7 +44,8 @@ type SessionResult struct { Mode string `json:"mode"` ImportRequired bool `json:"importRequired"` Collaboration bool `json:"collaboration"` - PageSetup json.RawMessage `json:"pageSetup,omitempty"` + PageSetup json.RawMessage `json:"pageSetup,omitempty"` + ParagraphStyles json.RawMessage `json:"paragraphStyles,omitempty"` } func (s *Service) CreateSession(ctx context.Context, ncUser, filePath, mode, editorUserID, editorName string) (*SessionResult, error) { @@ -83,6 +84,7 @@ func (s *Service) CreateSession(ctx context.Context, ncUser, filePath, mode, edi collab := wsURL != "" && s.Cfg.HocuspocusSecret != "" pageSetup, _ := s.loadPageSetupFromSidecar(ctx, ncUser, canonical) + paragraphStyles, _ := s.loadParagraphStylesFromSidecar(ctx, ncUser, canonical) return &SessionResult{ RoomID: roomID, @@ -94,6 +96,7 @@ func (s *Service) CreateSession(ctx context.Context, ncUser, filePath, mode, edi ImportRequired: importRequired, Collaboration: collab, PageSetup: pageSetup, + ParagraphStyles: paragraphStyles, }, nil } diff --git a/internal/api/ultidraw/collab_room.go b/internal/api/ultidraw/collab_room.go new file mode 100644 index 0000000..7e2ffca --- /dev/null +++ b/internal/api/ultidraw/collab_room.go @@ -0,0 +1,27 @@ +package ultidraw + +import ( + "context" + "fmt" + "strings" +) + +func (s *Service) resolveCollabRoomID(ctx context.Context, ownerID, filePath string) (string, error) { + ownerID = strings.TrimSpace(ownerID) + filePath = normalizePath(filePath) + if ownerID == "" || filePath == "" { + return "", fmt.Errorf("collab room: missing owner or path") + } + if rev, err := s.nc.FileRevision(ctx, ownerID, filePath); err == nil && rev.FileID > 0 { + return fmt.Sprintf("draw:%s:%d", ownerID, rev.FileID), nil + } + return fmt.Sprintf("draw:%s:%s", ownerID, hashPath(filePath)), nil +} + +func (s *Service) ownerPathForPublic(ctx context.Context, token, password, clientCanonical, displayName string) (ownerID, ownerPath string, err error) { + binding, err := s.nc.ResolvePublicShareBinding(ctx, token, password) + if err != nil { + return "", "", err + } + return binding.OwnerID, binding.OwnerPathForClient(clientCanonical, displayName), nil +} diff --git a/internal/api/ultidraw/document.go b/internal/api/ultidraw/document.go new file mode 100644 index 0000000..216f5d6 --- /dev/null +++ b/internal/api/ultidraw/document.go @@ -0,0 +1,90 @@ +package ultidraw + +import ( + "encoding/json" + "strings" + "time" +) + +// UltiDrawDoc is the on-disk format for Excalidraw drawings in UltiDrive. +type UltiDrawDoc struct { + Type string `json:"type"` + Version int `json:"version"` + Source string `json:"source,omitempty"` + Elements json.RawMessage `json:"elements"` + AppState json.RawMessage `json:"appState,omitempty"` + Files json.RawMessage `json:"files,omitempty"` + YjsState string `json:"yjsState,omitempty"` + UpdatedAt string `json:"updatedAt,omitempty"` +} + +func ParseUltiDrawDoc(raw []byte) (UltiDrawDoc, error) { + var doc UltiDrawDoc + if err := json.Unmarshal(raw, &doc); err != nil { + return UltiDrawDoc{}, err + } + if doc.Type == "" { + doc.Type = "excalidraw" + } + if doc.Version == 0 { + doc.Version = 2 + } + return doc, nil +} + +func NewUltiDrawDoc(elements, appState, files json.RawMessage) UltiDrawDoc { + if len(elements) == 0 { + elements = json.RawMessage("[]") + } + if len(appState) == 0 { + appState = json.RawMessage(`{"gridSize":null,"viewBackgroundColor":"#ffffff"}`) + } + if len(files) == 0 { + files = json.RawMessage("{}") + } + return UltiDrawDoc{ + Type: "excalidraw", + Version: 2, + Source: "https://ultidrive", + Elements: elements, + AppState: appState, + Files: files, + } +} + +func (d UltiDrawDoc) Marshal() ([]byte, error) { + d.UpdatedAt = time.Now().UTC().Format(time.RFC3339) + return json.Marshal(d) +} + +func ApplyUltiDrawPatch(existing UltiDrawDoc, raw []byte) (UltiDrawDoc, error) { + var patch map[string]json.RawMessage + if err := json.Unmarshal(raw, &patch); err != nil { + return UltiDrawDoc{}, err + } + doc := existing + if doc.Type == "" { + doc = NewUltiDrawDoc(nil, nil, nil) + } + if v, ok := patch["elements"]; ok { + doc.Elements = v + } + if v, ok := patch["appState"]; ok { + doc.AppState = v + } + if v, ok := patch["files"]; ok { + doc.Files = v + } + if v, ok := patch["yjsState"]; ok { + doc.YjsState = strings.Trim(string(v), `"`) + } + return doc, nil +} + +func isEmptyElements(elements json.RawMessage) bool { + if len(elements) == 0 { + return true + } + s := strings.TrimSpace(string(elements)) + return s == "" || s == "[]" || s == "null" +} diff --git a/internal/api/ultidraw/handlers.go b/internal/api/ultidraw/handlers.go new file mode 100644 index 0000000..9cf5bc6 --- /dev/null +++ b/internal/api/ultidraw/handlers.go @@ -0,0 +1,178 @@ +package ultidraw + +import ( + "encoding/json" + "log/slog" + "net/http" + "strings" + + "github.com/go-chi/chi/v5" + + "github.com/ultisuite/ulti-backend/internal/api/apiresponse" + "github.com/ultisuite/ulti-backend/internal/api/apivalidate" + "github.com/ultisuite/ulti-backend/internal/api/middleware" + "github.com/ultisuite/ulti-backend/internal/permission" +) + +type Handler struct { + svc *Service + logger *slog.Logger +} + +func NewHandler(svc *Service) *Handler { + return &Handler{ + svc: svc, + logger: slog.Default().With("component", "ultidraw-api"), + } +} + +func (h *Handler) Routes(authMiddleware func(http.Handler) http.Handler) chi.Router { + r := chi.NewRouter() + r.Post("/hooks/store", h.HookStore) + r.Get("/internal/document", h.InternalLoadDocument) + + r.Group(func(pr chi.Router) { + pr.Use(authMiddleware) + read := middleware.RequirePermission(permission.ResourceDrive, permission.LevelRead) + pr.With(read).Post("/session", h.CreateSession) + }) + return r +} + +func (h *Handler) RegisterPublicShareRoutes(r chi.Router) { + r.Post("/shares/{token}/ultidraw/session", h.PublicShareSession) + r.Get("/shares/{token}/ultidraw/document", h.PublicShareDocument) + r.Put("/shares/{token}/ultidraw/document", h.PublicSharePutDocument) +} + +type sessionRequest struct { + Path string `json:"path"` + Mode string `json:"mode"` +} + +func (h *Handler) CreateSession(w http.ResponseWriter, r *http.Request) { + claims := middleware.ClaimsFromContext(r.Context()) + ncUser, err := h.svc.EnsureNextcloudUser(r.Context(), claims) + if err != nil { + apivalidate.WriteInternal(w, r) + return + } + var req sessionRequest + if err := apivalidate.DecodeJSON(w, r, 32<<10, &req); err != nil { + return + } + if strings.TrimSpace(req.Path) == "" { + apivalidate.WriteValidationError(w, r, apivalidate.NewValidationError( + apivalidate.FieldDetail{Field: "path", Message: "required"}, + )) + return + } + mode := strings.TrimSpace(req.Mode) + if mode == "" { + mode = "edit" + } + result, err := h.svc.CreateSession(r.Context(), ncUser, req.Path, mode, claims.Sub, claims.Name) + if err != nil { + h.logger.Error("ultidraw session", "error", err) + apiresponse.WriteError(w, r, http.StatusBadRequest, apiresponse.CodeInvalidRequest, err.Error(), nil) + return + } + apiresponse.WriteJSON(w, http.StatusOK, result) +} + +func (h *Handler) InternalLoadDocument(w http.ResponseWriter, r *http.Request) { + secret := r.Header.Get("X-Hocuspocus-Secret") + if h.svc.Cfg.HocuspocusSecret != "" && secret != h.svc.Cfg.HocuspocusSecret { + http.Error(w, "forbidden", http.StatusForbidden) + return + } + user := strings.TrimSpace(r.URL.Query().Get("user")) + path := strings.TrimSpace(r.URL.Query().Get("path")) + body, err := h.svc.LoadDocumentForUser(r.Context(), user, path) + if err != nil { + http.Error(w, "not found", http.StatusNotFound) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(body) +} + +type hookStorePayload struct { + Room string `json:"room"` + Path string `json:"path"` + User string `json:"user"` + Sub string `json:"sub"` + YjsState string `json:"yjsState"` + Document json.RawMessage `json:"document,omitempty"` +} + +func (h *Handler) HookStore(w http.ResponseWriter, r *http.Request) { + secret := r.Header.Get("X-Hocuspocus-Secret") + if h.svc.Cfg.HocuspocusSecret != "" && secret != h.svc.Cfg.HocuspocusSecret { + http.Error(w, "forbidden", http.StatusForbidden) + return + } + var payload hookStorePayload + if err := apivalidate.DecodeJSON(w, r, 32<<20, &payload); err != nil { + return + } + path := normalizePath(payload.Path) + existingRaw, _ := h.svc.LoadDocumentForUser(r.Context(), payload.User, path) + var existing UltiDrawDoc + if len(existingRaw) > 0 { + if parsed, err := ParseUltiDrawDoc(existingRaw); err == nil { + existing = parsed + } + } + var raw []byte + if len(payload.Document) > 0 { + doc, err := ApplyUltiDrawPatch(existing, payload.Document) + if err != nil { + apivalidate.WriteValidationError(w, r, apivalidate.NewValidationError( + apivalidate.FieldDetail{Field: "document", Message: "invalid JSON"}, + )) + return + } + if !isEmptyElements(doc.Elements) { + doc.YjsState = payload.YjsState + } + if isEmptyElements(doc.Elements) && len(existingRaw) > 0 && !isEmptyElements(existing.Elements) { + w.WriteHeader(http.StatusNoContent) + return + } + raw, err = doc.Marshal() + if err != nil { + apivalidate.WriteInternal(w, r) + return + } + } else if payload.YjsState != "" { + doc := existing + if doc.Type == "" { + doc = NewUltiDrawDoc(nil, nil, nil) + } + if isEmptyElements(doc.Elements) { + doc.YjsState = payload.YjsState + } + if isEmptyElements(doc.Elements) && len(existingRaw) > 0 && !isEmptyElements(existing.Elements) { + w.WriteHeader(http.StatusNoContent) + return + } + var err error + raw, err = doc.Marshal() + if err != nil { + apivalidate.WriteInternal(w, r) + return + } + } else { + apivalidate.WriteValidationError(w, r, apivalidate.NewValidationError( + apivalidate.FieldDetail{Field: "document", Message: "required"}, + )) + return + } + if err := h.svc.SaveDocument(r.Context(), payload.User, path, raw, payload.Sub); err != nil { + h.logger.Error("hook store", "error", err, "path", path) + apivalidate.WriteInternal(w, r) + return + } + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/api/ultidraw/jwt.go b/internal/api/ultidraw/jwt.go new file mode 100644 index 0000000..02b2460 --- /dev/null +++ b/internal/api/ultidraw/jwt.go @@ -0,0 +1,135 @@ +package ultidraw + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "strings" + "time" +) + +type roomTokenPayload struct { + Room string `json:"room"` + Path string `json:"path"` + User string `json:"user"` + Sub string `json:"sub"` + Name string `json:"name"` + Mode string `json:"mode"` + Expires int64 `json:"exp"` +} + +func signRoomToken(payload roomTokenPayload, secret string) (string, error) { + if secret == "" { + return "", nil + } + return signJWT(payload, secret) +} + +func signJWT(payload any, secret string) (string, error) { + if secret == "" { + return "", nil + } + header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"HS256","typ":"JWT"}`)) + bodyBytes, err := json.Marshal(payload) + if err != nil { + return "", err + } + body := base64.RawURLEncoding.EncodeToString(bodyBytes) + mac := hmac.New(sha256.New, []byte(secret)) + _, _ = mac.Write([]byte(header + "." + body)) + sig := base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) + return header + "." + body + "." + sig, nil +} + +func verifyJWT(token, secret string) (map[string]any, error) { + if secret == "" || token == "" { + return nil, fmt.Errorf("missing token or secret") + } + parts := splitJWT(token) + if len(parts) != 3 { + return nil, fmt.Errorf("invalid token") + } + mac := hmac.New(sha256.New, []byte(secret)) + _, _ = mac.Write([]byte(parts[0] + "." + parts[1])) + expected := base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) + if !hmac.Equal([]byte(expected), []byte(parts[2])) { + return nil, fmt.Errorf("invalid signature") + } + raw, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return nil, err + } + var payload map[string]any + if err := json.Unmarshal(raw, &payload); err != nil { + return nil, err + } + return payload, nil +} + +func splitJWT(token string) []string { + var parts []string + start := 0 + for i := 0; i < len(token); i++ { + if token[i] == '.' { + parts = append(parts, token[start:i]) + start = i + 1 + } + } + parts = append(parts, token[start:]) + return parts +} + +func sha256Hex(b []byte) string { + sum := sha256.Sum256(b) + return hexEncode(sum[:]) +} + +func hexEncode(b []byte) string { + const hexdigits = "0123456789abcdef" + out := make([]byte, len(b)*2) + for i, v := range b { + out[i*2] = hexdigits[v>>4] + out[i*2+1] = hexdigits[v&0x0f] + } + return string(out) +} + +func hashPath(p string) string { + h := sha256Hex([]byte(normalizePath(p))) + if len(h) > 16 { + return h[:16] + } + return h +} + +func verifyPublicDocAccess(token, filePath, password, sig, secret string) bool { + if secret == "" { + return true + } + payload, err := verifyJWT(sig, secret) + if err != nil { + return false + } + if payload["token"] != strings.TrimSpace(token) || payload["path"] != normalizePath(filePath) { + return false + } + if pw, _ := payload["password"].(string); pw != password { + return false + } + if exp, ok := payload["exp"].(float64); ok && int64(exp) < time.Now().Unix() { + return false + } + return true +} + +func signPublicDocAccess(token, filePath, password, secret string) (string, error) { + payload := map[string]any{ + "token": strings.TrimSpace(token), + "path": normalizePath(filePath), + "password": password, + "exp": time.Now().Add(2 * time.Hour).Unix(), + } + return signJWT(payload, secret) +} diff --git a/internal/api/ultidraw/paths.go b/internal/api/ultidraw/paths.go new file mode 100644 index 0000000..317ffc7 --- /dev/null +++ b/internal/api/ultidraw/paths.go @@ -0,0 +1,40 @@ +package ultidraw + +import "strings" + +const ExcalidrawExtension = "excalidraw" + +// Config holds UltiDraw editor integration settings. +type Config struct { + Enabled bool + HocuspocusPublicURL string + HocuspocusSecret string + APIInternalURL string +} + +func normalizePath(p string) string { + p = strings.TrimSpace(p) + if p == "" { + return "/" + } + if !strings.HasPrefix(p, "/") { + p = "/" + p + } + return strings.ReplaceAll(p, "//", "/") +} + +func fileNameFromPath(p string) string { + p = normalizePath(p) + if p == "/" { + return "" + } + if i := strings.LastIndex(p, "/"); i >= 0 { + return p[i+1:] + } + return p +} + +func isExcalidrawPath(path string) bool { + lower := strings.ToLower(path) + return strings.HasSuffix(lower, "."+ExcalidrawExtension) || strings.HasSuffix(lower, ".excalidraw.json") +} diff --git a/internal/api/ultidraw/public_handlers.go b/internal/api/ultidraw/public_handlers.go new file mode 100644 index 0000000..17ee3d0 --- /dev/null +++ b/internal/api/ultidraw/public_handlers.go @@ -0,0 +1,140 @@ +package ultidraw + +import ( + "io" + "net/http" + "strings" + + "github.com/go-chi/chi/v5" + + "github.com/ultisuite/ulti-backend/internal/api/apiresponse" + "github.com/ultisuite/ulti-backend/internal/api/apivalidate" + "github.com/ultisuite/ulti-backend/internal/nextcloud" +) + +type publicSessionRequest struct { + Path string `json:"path"` + Mode string `json:"mode"` + Password string `json:"password"` + GuestID string `json:"guest_id"` + GuestName string `json:"guest_name"` + DisplayName string `json:"display_name"` +} + +func (h *Handler) PublicShareSession(w http.ResponseWriter, r *http.Request) { + token := strings.TrimSpace(chi.URLParam(r, "token")) + if token == "" { + apivalidate.WriteNotFound(w, r, "not found") + return + } + var req publicSessionRequest + if err := apivalidate.DecodeJSON(w, r, 32<<10, &req); err != nil { + return + } + if strings.TrimSpace(req.Path) == "" { + apivalidate.WriteValidationError(w, r, apivalidate.NewValidationError( + apivalidate.FieldDetail{Field: "path", Message: "required"}, + )) + return + } + password := strings.TrimSpace(req.Password) + perms, err := h.svc.EffectivePublicSharePermissions(r.Context(), token, req.Path, password) + if err != nil { + apivalidate.WriteInternal(w, r) + return + } + if !nextcloud.PublicShareCanRead(perms) { + http.Error(w, "forbidden", http.StatusForbidden) + return + } + mode := strings.TrimSpace(req.Mode) + if mode == "" { + mode = "edit" + } + if mode == "edit" && !nextcloud.PublicShareCanUpdate(perms) { + mode = "view" + } + + guestID := strings.TrimSpace(req.GuestID) + if guestID == "" { + guestID = "public-guest" + } else { + guestID = "public:" + guestID + } + guestName := strings.TrimSpace(req.GuestName) + if guestName == "" { + guestName = "Invité" + } + + result, err := h.svc.CreatePublicSession(r.Context(), token, req.Path, mode, password, guestID, guestName, strings.TrimSpace(req.DisplayName)) + if err != nil { + h.logger.Error("public ultidraw session", "error", err) + apivalidate.WriteInternal(w, r) + return + } + result.Mode = mode + apiresponse.WriteJSON(w, http.StatusOK, result) +} + +func (h *Handler) PublicShareDocument(w http.ResponseWriter, r *http.Request) { + token := strings.TrimSpace(chi.URLParam(r, "token")) + path := strings.TrimSpace(r.URL.Query().Get("path")) + password := strings.TrimSpace(r.URL.Query().Get("password")) + sig := strings.TrimSpace(r.URL.Query().Get("sig")) + if h.svc.Cfg.HocuspocusSecret != "" && !verifyPublicDocAccess(token, path, password, sig, h.svc.Cfg.HocuspocusSecret) { + http.Error(w, "forbidden", http.StatusForbidden) + return + } + body, err := h.svc.LoadPublicDocumentLegacy(r.Context(), token, path, password) + if err != nil { + http.Error(w, "not found", http.StatusNotFound) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(body) +} + +func (h *Handler) PublicSharePutDocument(w http.ResponseWriter, r *http.Request) { + token := strings.TrimSpace(chi.URLParam(r, "token")) + path := strings.TrimSpace(r.URL.Query().Get("path")) + password := strings.TrimSpace(r.URL.Query().Get("password")) + sig := strings.TrimSpace(r.URL.Query().Get("sig")) + if h.svc.Cfg.HocuspocusSecret != "" && !verifyPublicDocAccess(token, path, password, sig, h.svc.Cfg.HocuspocusSecret) { + http.Error(w, "forbidden", http.StatusForbidden) + return + } + perms, err := h.svc.EffectivePublicSharePermissions(r.Context(), token, path, password) + if err != nil || !nextcloud.PublicShareCanUpdate(perms) { + http.Error(w, "forbidden", http.StatusForbidden) + return + } + raw, err := io.ReadAll(r.Body) + if err != nil { + apivalidate.WriteInternal(w, r) + return + } + existingRaw, loadErr := h.svc.LoadPublicDocumentLegacy(r.Context(), token, path, password) + var existing UltiDrawDoc + if loadErr == nil && len(existingRaw) > 0 { + if parsed, parseErr := ParseUltiDrawDoc(existingRaw); parseErr == nil { + existing = parsed + } + } + doc, err := ApplyUltiDrawPatch(existing, raw) + if err != nil { + apivalidate.WriteValidationError(w, r, apivalidate.NewValidationError( + apivalidate.FieldDetail{Field: "document", Message: "invalid JSON"}, + )) + return + } + payload, err := doc.Marshal() + if err != nil { + apivalidate.WriteInternal(w, r) + return + } + if err := h.svc.SavePublicDocumentLegacy(r.Context(), token, path, password, payload); err != nil { + http.Error(w, "save failed", http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/api/ultidraw/public_share.go b/internal/api/ultidraw/public_share.go new file mode 100644 index 0000000..0247066 --- /dev/null +++ b/internal/api/ultidraw/public_share.go @@ -0,0 +1,162 @@ +package ultidraw + +import ( + "context" + "fmt" + "io" + "net/url" + "strings" + "time" + + "github.com/ultisuite/ulti-backend/internal/nextcloud" +) + +func (s *Service) CreatePublicSession(ctx context.Context, token, filePath, mode, password, guestID, guestName, displayName string) (*PublicSessionResult, error) { + if !s.Cfg.Enabled { + return nil, fmt.Errorf("ultidraw editor disabled") + } + resolvedPath, err := s.resolvePublicFilePath(ctx, token, filePath, password, displayName) + if err != nil { + return nil, err + } + filePath = resolvedPath + if mode == "" { + mode = "edit" + } + + ownerID, ownerPath, err := s.ownerPathForPublic(ctx, token, password, filePath, displayName) + if err != nil { + return nil, err + } + roomID, err := s.resolveCollabRoomID(ctx, ownerID, ownerPath) + if err != nil { + return nil, err + } + + tokenJWT, err := signRoomToken(roomTokenPayload{ + Room: roomID, + Path: filePath, + User: "public:" + token, + Sub: guestID, + Name: guestName, + Mode: mode, + Expires: time.Now().Add(8 * time.Hour).Unix(), + }, s.Cfg.HocuspocusSecret) + if err != nil { + return nil, err + } + + apiBase := strings.TrimRight(s.Cfg.APIInternalURL, "/") + sig, _ := signPublicDocAccess(token, filePath, password, s.Cfg.HocuspocusSecret) + docURL := fmt.Sprintf("%s/api/v1/drive/public/shares/%s/ultidraw/document?path=%s&password=%s&sig=%s", + apiBase, url.PathEscape(token), url.QueryEscape(filePath), url.QueryEscape(password), url.QueryEscape(sig)) + saveURL := docURL + + wsURL := strings.TrimSpace(s.Cfg.HocuspocusPublicURL) + collab := wsURL != "" && s.Cfg.HocuspocusSecret != "" + + return &PublicSessionResult{ + SessionResult: SessionResult{ + RoomID: roomID, + CanonicalPath: filePath, + WsURL: wsURL, + Token: tokenJWT, + Mode: mode, + Collaboration: collab, + }, + DocumentURL: docURL, + SaveURL: saveURL, + }, nil +} + +func (s *Service) resolvePublicFilePath(ctx context.Context, token, filePath, password, displayName string) (string, error) { + filePath = normalizePath(filePath) + if filePath == "/" { + filePath = s.publicClientSourcePath(ctx, token, password, filePath, displayName) + } + checkPath := filePath + if !isExcalidrawPath(checkPath) && strings.TrimSpace(displayName) != "" { + checkPath = normalizePath("/" + strings.TrimSpace(displayName)) + } + if !isExcalidrawPath(checkPath) { + return "", fmt.Errorf("not an excalidraw file") + } + if !isExcalidrawPath(filePath) { + filePath = checkPath + } + if _, err := s.publicFileExists(ctx, token, filePath, password); err != nil { + if checkPath != filePath { + if _, err2 := s.publicFileExists(ctx, token, checkPath, password); err2 != nil { + return "", fmt.Errorf("file not found") + } + filePath = checkPath + } else { + return "", fmt.Errorf("file not found") + } + } + return filePath, nil +} + +func (s *Service) publicClientSourcePath(ctx context.Context, token, password, clientPath, displayName string) string { + binding, err := s.nc.ResolvePublicShareBinding(ctx, token, password) + if err == nil { + return binding.ClientSourcePath(clientPath, displayName) + } + if name := strings.TrimSpace(displayName); name != "" { + return normalizePath("/" + name) + } + return clientPath +} + +func (s *Service) publicFileExists(ctx context.Context, token, path, password string) (bool, error) { + _, err := s.nc.PublicShareFileRevision(ctx, token, path, password) + if err != nil { + return false, err + } + return true, nil +} + +func (s *Service) LoadPublicDocument(ctx context.Context, token, clientPath, password, displayName string) ([]byte, error) { + if binding, err := s.nc.ResolvePublicShareBinding(ctx, token, password); err == nil { + ownerPath := binding.OwnerPathForClient(clientPath, displayName) + body, _, err := s.nc.Download(ctx, binding.OwnerID, ownerPath) + if err == nil { + defer body.Close() + return io.ReadAll(body) + } + } + body, _, err := s.nc.DownloadPublicShare(ctx, token, clientPath, password) + if err != nil { + return nil, err + } + defer body.Close() + return io.ReadAll(body) +} + +func (s *Service) SavePublicDocument(ctx context.Context, token, clientPath, password, displayName string, raw []byte) error { + if binding, err := s.nc.ResolvePublicShareBinding(ctx, token, password); err == nil { + ownerPath := binding.OwnerPathForClient(clientPath, displayName) + reader := strings.NewReader(string(raw)) + if err := s.nc.Upload(ctx, binding.OwnerID, ownerPath, reader, "application/json"); err == nil { + return nil + } + } + reader := strings.NewReader(string(raw)) + return s.nc.UploadPublicShare(ctx, token, clientPath, password, reader, "application/json") +} + +func (s *Service) LoadPublicDocumentLegacy(ctx context.Context, token, path, password string) ([]byte, error) { + return s.LoadPublicDocument(ctx, token, path, password, "") +} + +func (s *Service) SavePublicDocumentLegacy(ctx context.Context, token, path, password string, raw []byte) error { + return s.SavePublicDocument(ctx, token, path, password, "", raw) +} + +func (s *Service) EffectivePublicSharePermissions(ctx context.Context, token, path, password string) (int, error) { + return s.nc.EffectivePublicSharePermissions(ctx, token, path, password) +} + +func (s *Service) PublicShareCanUpdate(perms int) bool { + return nextcloud.PublicShareCanUpdate(perms) +} diff --git a/internal/api/ultidraw/service.go b/internal/api/ultidraw/service.go new file mode 100644 index 0000000..d7bf764 --- /dev/null +++ b/internal/api/ultidraw/service.go @@ -0,0 +1,127 @@ +package ultidraw + +import ( + "bytes" + "context" + "fmt" + "io" + "strings" + "time" + + "github.com/ultisuite/ulti-backend/internal/auth" + "github.com/ultisuite/ulti-backend/internal/nextcloud" +) + +type Service struct { + nc *nextcloud.Client + Cfg Config + hub fileChangePublisher +} + +type fileChangePublisher interface { + PublishFileChanged(platformUserID, path string) +} + +func NewService(nc *nextcloud.Client, cfg Config, hub fileChangePublisher) *Service { + return &Service{nc: nc, Cfg: cfg, hub: hub} +} + +func (s *Service) EnsureNextcloudUser(ctx context.Context, claims *auth.Claims) (string, error) { + return s.nc.EnsurePrincipal(ctx, claims.Email, claims.Sub, claims.Name) +} + +type SessionResult struct { + RoomID string `json:"roomId"` + CanonicalPath string `json:"canonicalPath"` + WsURL string `json:"wsUrl"` + Token string `json:"token"` + Mode string `json:"mode"` + Collaboration bool `json:"collaboration"` +} + +type PublicSessionResult struct { + SessionResult + DocumentURL string `json:"documentUrl,omitempty"` + SaveURL string `json:"saveUrl,omitempty"` +} + +func (s *Service) CreateSession(ctx context.Context, ncUser, filePath, mode, editorUserID, editorName string) (*SessionResult, error) { + if !s.Cfg.Enabled { + return nil, fmt.Errorf("ultidraw editor disabled") + } + filePath = normalizePath(filePath) + if !isExcalidrawPath(filePath) { + return nil, fmt.Errorf("not an excalidraw file: %s", filePath) + } + if _, err := s.nc.FileRevision(ctx, ncUser, filePath); err != nil { + return nil, fmt.Errorf("file not found: %s", filePath) + } + if mode == "" { + mode = "edit" + } + + roomID, err := s.resolveCollabRoomID(ctx, ncUser, filePath) + if err != nil { + return nil, err + } + + token, err := signRoomToken(roomTokenPayload{ + Room: roomID, + Path: filePath, + User: ncUser, + Sub: editorUserID, + Name: editorName, + Mode: mode, + Expires: time.Now().Add(8 * time.Hour).Unix(), + }, s.Cfg.HocuspocusSecret) + if err != nil { + return nil, err + } + + wsURL := strings.TrimSpace(s.Cfg.HocuspocusPublicURL) + collab := wsURL != "" && s.Cfg.HocuspocusSecret != "" + + return &SessionResult{ + RoomID: roomID, + CanonicalPath: filePath, + WsURL: wsURL, + Token: token, + Mode: mode, + Collaboration: collab, + }, nil +} + +func (s *Service) LoadDocument(ctx context.Context, ncUser, path string) ([]byte, error) { + path = normalizePath(path) + body, _, err := s.nc.Download(ctx, ncUser, path) + if err != nil { + return nil, err + } + defer body.Close() + return io.ReadAll(body) +} + +func (s *Service) LoadDocumentForUser(ctx context.Context, ncUser, path string) ([]byte, error) { + path = normalizePath(path) + if strings.HasPrefix(ncUser, "public:") { + token := strings.TrimPrefix(ncUser, "public:") + return s.LoadPublicDocumentLegacy(ctx, token, path, "") + } + return s.LoadDocument(ctx, ncUser, path) +} + +func (s *Service) SaveDocument(ctx context.Context, ncUser, path string, raw []byte, platformUserID string) error { + path = normalizePath(path) + reader := bytes.NewReader(raw) + if strings.HasPrefix(ncUser, "public:") { + token := strings.TrimPrefix(ncUser, "public:") + return s.SavePublicDocumentLegacy(ctx, token, path, "", raw) + } + if err := s.nc.Upload(ctx, ncUser, path, reader, "application/json"); err != nil { + return err + } + if s.hub != nil && platformUserID != "" { + s.hub.PublishFileChanged(platformUserID, path) + } + return nil +} diff --git a/internal/apitokens/chat_session.go b/internal/apitokens/chat_session.go new file mode 100644 index 0000000..baf0b58 --- /dev/null +++ b/internal/apitokens/chat_session.go @@ -0,0 +1,88 @@ +package apitokens + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +type ChatSessionPreset string + +const ( + ChatSessionMail ChatSessionPreset = "mail" + ChatSessionDrive ChatSessionPreset = "drive" + ChatSessionContacts ChatSessionPreset = "contacts" + ChatSessionDocs ChatSessionPreset = "docs" + ChatSessionStandalone ChatSessionPreset = "standalone" +) + +type ChatSessionInput struct { + Preset ChatSessionPreset + DrivePath string + AllowWrite bool + TTL time.Duration +} + +func CreateChatSession(ctx context.Context, db *pgxpool.Pool, externalID, email string, in ChatSessionInput) (CreatedToken, error) { + if in.TTL <= 0 { + in.TTL = 8 * time.Hour + } + expiresAt := time.Now().UTC().Add(in.TTL) + perms, mailScope, driveScope := chatSessionGrants(in) + name := fmt.Sprintf("UltiAI session %s", time.Now().UTC().Format("2006-01-02 15:04")) + return Create(ctx, db, externalID, name, perms, mailScope, driveScope, &expiresAt) +} + +func chatSessionGrants(in ChatSessionInput) ([]PermissionGrant, MailScope, DriveScope) { + mailScope := MailScope{AllAccounts: true} + driveScope := DriveScope{AllFolders: true} + if strings.TrimSpace(in.DrivePath) != "" { + driveScope = DriveScope{ + AllFolders: false, + FolderPaths: []string{in.DrivePath}, + } + } + + switch in.Preset { + case ChatSessionMail: + return []PermissionGrant{ + {Resource: "mail.messages", Read: true}, + {Resource: "mail.search", Read: true}, + {Resource: "contacts.read", Read: true}, + {Resource: "automation.chat", Read: true}, + }, mailScope, driveScope + case ChatSessionDrive: + return []PermissionGrant{ + {Resource: "drive.files", Read: true, Write: in.AllowWrite}, + {Resource: "automation.chat", Read: true}, + }, mailScope, driveScope + case ChatSessionContacts: + return []PermissionGrant{ + {Resource: "contacts.read", Read: true}, + {Resource: "contacts.search", Read: true}, + {Resource: "mail.search", Read: true}, + {Resource: "automation.chat", Read: true}, + }, mailScope, driveScope + case ChatSessionDocs: + return []PermissionGrant{ + {Resource: "drive.files", Read: true, Write: in.AllowWrite}, + {Resource: "drive.download", Read: true}, + {Resource: "automation.chat", Read: true}, + }, mailScope, driveScope + default: + return []PermissionGrant{ + {Resource: "mail.messages", Read: true}, + {Resource: "mail.search", Read: true}, + {Resource: "mail.send", Write: true}, + {Resource: "mail.labels", Read: true, Write: true}, + {Resource: "drive.files", Read: true, Write: true}, + {Resource: "contacts.read", Read: true}, + {Resource: "contacts.search", Read: true}, + {Resource: "automation.search", Read: true}, + {Resource: "automation.chat", Read: true}, + }, mailScope, driveScope + } +} diff --git a/internal/apitokens/chat_session_test.go b/internal/apitokens/chat_session_test.go new file mode 100644 index 0000000..dc6a71d --- /dev/null +++ b/internal/apitokens/chat_session_test.go @@ -0,0 +1,54 @@ +package apitokens + +import "testing" + +func TestChatSessionGrantsMail(t *testing.T) { + perms, _, _ := chatSessionGrants(ChatSessionInput{Preset: ChatSessionMail}) + if len(perms) == 0 { + t.Fatal("expected grants") + } + found := false + for _, p := range perms { + if p.Resource == "mail.messages" && p.Read { + found = true + } + } + if !found { + t.Fatal("expected mail.messages read grant") + } +} + +func TestChatSessionGrantsDocs(t *testing.T) { + perms, _, drive := chatSessionGrants(ChatSessionInput{ + Preset: ChatSessionDocs, + DrivePath: "/Docs/note.ultidoc", + AllowWrite: true, + }) + foundRead := false + foundWrite := false + for _, p := range perms { + if p.Resource == "drive.files" && p.Read { + foundRead = true + foundWrite = p.Write + } + } + if !foundRead || !foundWrite { + t.Fatalf("expected drive.files read+write: %+v", perms) + } + if drive.AllFolders || len(drive.FolderPaths) != 1 { + t.Fatalf("unexpected drive scope: %+v", drive) + } +} + +func TestChatSessionGrantsDriveScoped(t *testing.T) { + _, _, drive := chatSessionGrants(ChatSessionInput{ + Preset: ChatSessionDrive, + DrivePath: "/docs", + }) + if drive.AllFolders { + t.Fatal("expected folder scope") + } + if len(drive.FolderPaths) != 1 || drive.FolderPaths[0] != "/docs" { + t.Fatalf("unexpected drive scope: %+v", drive) + } +} diff --git a/internal/apitokens/policy.go b/internal/apitokens/policy.go index 74e0cfc..23b075d 100644 --- a/internal/apitokens/policy.go +++ b/internal/apitokens/policy.go @@ -31,6 +31,21 @@ func RequirementForRequest(method, fullPath, typesQuery string) (Requirement, bo write := method != http.MethodGet && method != http.MethodHead switch { + case strings.HasPrefix(path, "/api/v1/ai/chat/completions"), + strings.HasPrefix(path, "/api/v1/ai/v1/chat/completions"): + return Requirement{Resource: "automation.chat", Write: true}, true + case strings.HasPrefix(path, "/api/v1/ai/sessions"), + strings.HasPrefix(path, "/api/v1/ai/chats/sync"): + return Requirement{Resource: "automation.chat", Write: true}, true + case strings.HasPrefix(path, "/api/v1/ai/chats/"): + if write || method == http.MethodDelete { + return Requirement{Resource: "automation.chat", Write: true}, true + } + return Requirement{Resource: "automation.chat", Write: false}, true + case strings.HasPrefix(path, "/api/v1/ai/quota"), + strings.HasPrefix(path, "/api/v1/ai/models"): + return Requirement{Resource: "automation.chat", Write: false}, true + case strings.HasPrefix(path, "/api/v1/mail/api-tokens"): return Requirement{Resource: "automation.api_tokens", Write: write || method == http.MethodDelete}, true case strings.HasPrefix(path, "/api/v1/mail/webhooks"): @@ -67,6 +82,9 @@ func RequirementForRequest(method, fullPath, typesQuery string) (Requirement, bo case strings.HasPrefix(path, "/api/v1/drive/"): return driveRequirement(method, path) + case strings.HasPrefix(path, "/api/v1/richtext/"): + return richtextRequirement(method, path) + case strings.HasPrefix(path, "/api/v1/search"): return searchRequirement(typesQuery) @@ -130,6 +148,18 @@ func mailRequirement(method, path string) (Requirement, bool) { } } +func richtextRequirement(method, path string) (Requirement, bool) { + write := method != http.MethodGet && method != http.MethodHead + switch { + case strings.HasSuffix(path, "/save"), + strings.HasSuffix(path, "/assets"), + strings.HasSuffix(path, "/user-paragraph-styles"): + return Requirement{Resource: "drive.files", Write: true}, true + default: + return Requirement{Resource: "drive.files", Write: write}, true + } +} + func driveRequirement(method, path string) (Requirement, bool) { write := method != http.MethodGet && method != http.MethodHead diff --git a/internal/apitokens/policy_ai_test.go b/internal/apitokens/policy_ai_test.go new file mode 100644 index 0000000..d35d211 --- /dev/null +++ b/internal/apitokens/policy_ai_test.go @@ -0,0 +1,26 @@ +package apitokens + +import ( + "net/http" + "testing" +) + +func TestRequirementForAIChatCompletions(t *testing.T) { + req, ok := RequirementForRequest(http.MethodPost, "/api/v1/ai/chat/completions", "") + if !ok { + t.Fatal("expected requirement") + } + if req.Resource != "automation.chat" || !req.Write { + t.Fatalf("unexpected requirement: %+v", req) + } +} + +func TestRequirementForAIQuotaRead(t *testing.T) { + req, ok := RequirementForRequest(http.MethodGet, "/api/v1/ai/quota", "") + if !ok { + t.Fatal("expected requirement") + } + if req.Resource != "automation.chat" || req.Write { + t.Fatalf("unexpected requirement: %+v", req) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 702d5bd..a258e50 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -76,6 +76,12 @@ type Config struct { RichTextStorageMode string RichTextExportMirror string + // AI assistant (OpenWebUI + Ulti gateway) + AIAssistantEnabled bool + OpenWebUIInternalURL string + AIAssistantPublicPath string + UltimailMCPURL string + // Jitsi JitsiEnabled bool JitsiDomain string @@ -194,6 +200,11 @@ func Load() (*Config, error) { RichTextStorageMode: envOrDefault("RICHTEXT_STORAGE_MODE", "sidecar"), RichTextExportMirror: envOrDefault("RICHTEXT_EXPORT_MIRROR", ""), + AIAssistantEnabled: envBool("AI_ASSISTANT_ENABLED", false), + OpenWebUIInternalURL: envOrDefault("OPENWEBUI_URL", "http://openwebui:8080"), + AIAssistantPublicPath: envOrDefault("AI_ASSISTANT_PUBLIC_PATH", "/ai"), + UltimailMCPURL: envOrDefault("ULTIMAIL_MCP_URL", "http://ultimail-mcp:3100"), + JitsiEnabled: envBool("JITSI_ENABLED", true), JitsiDomain: envOrDefault("JITSI_DOMAIN", "meet.jitsi"), JitsiAppID: envOrDefault("JITSI_APP_ID", "ulti"), diff --git a/internal/nextcloud/ultichat_paths.go b/internal/nextcloud/ultichat_paths.go new file mode 100644 index 0000000..93d08e7 --- /dev/null +++ b/internal/nextcloud/ultichat_paths.go @@ -0,0 +1,27 @@ +package nextcloud + +import "strings" + +const ( + UltichatSidecarSuffix = ".ultichat.json" + DefaultChatNCBasePath = "/.ultimail/ai/chats" +) + +// ChatSidecarPath returns the WebDAV path for a chat history sidecar. +func ChatSidecarPath(basePath, chatID string) string { + basePath = NormalizeClientPath(basePath) + if basePath == "" || basePath == "/" { + basePath = DefaultChatNCBasePath + } + basePath = strings.TrimSuffix(basePath, "/") + id := strings.TrimSpace(chatID) + if id == "" { + return basePath + "/" + } + return basePath + "/" + id + UltichatSidecarSuffix +} + +// IsUltichatSidecarPath reports whether path ends with .ultichat.json. +func IsUltichatSidecarPath(path string) bool { + return strings.HasSuffix(strings.ToLower(strings.TrimSpace(path)), UltichatSidecarSuffix) +} diff --git a/internal/nextcloud/ultichat_paths_test.go b/internal/nextcloud/ultichat_paths_test.go new file mode 100644 index 0000000..e6e6df0 --- /dev/null +++ b/internal/nextcloud/ultichat_paths_test.go @@ -0,0 +1,20 @@ +package nextcloud + +import "testing" + +func TestChatSidecarPath(t *testing.T) { + got := ChatSidecarPath("/.ultimail/ai/chats", "abc-123") + want := "/.ultimail/ai/chats/abc-123.ultichat.json" + if got != want { + t.Fatalf("ChatSidecarPath() = %q, want %q", got, want) + } +} + +func TestIsUltichatSidecarPath(t *testing.T) { + if !IsUltichatSidecarPath("/.ultimail/ai/chats/foo.ultichat.json") { + t.Fatal("expected ultichat sidecar path") + } + if IsUltichatSidecarPath("/docs/report.ultidoc.json") { + t.Fatal("ultidoc path should not match ultichat") + } +} diff --git a/internal/nextcloud/ultidoc_paths.go b/internal/nextcloud/ultidoc_paths.go new file mode 100644 index 0000000..13c6404 --- /dev/null +++ b/internal/nextcloud/ultidoc_paths.go @@ -0,0 +1,29 @@ +package nextcloud + +import "strings" + +// SidecarPathForSource maps a source document to its TipTap sidecar path, +// e.g. /docs/report.docx → /docs/report.ultidoc.json. +func SidecarPathForSource(sourcePath string) string { + sourcePath = NormalizeClientPath(sourcePath) + dir := "/" + name := strings.TrimPrefix(sourcePath, "/") + if i := strings.LastIndex(name, "/"); i >= 0 { + dir = "/" + name[:i] + name = name[i+1:] + } + base := name + if dot := strings.LastIndex(name, "."); dot > 0 { + base = name[:dot] + } + sidecar := base + ultidocSidecarSuffix + if dir == "/" { + return "/" + sidecar + } + return dir + "/" + sidecar +} + +// IsUltidocSidecarPath reports whether path ends with .ultidoc.json. +func IsUltidocSidecarPath(path string) bool { + return IsUltidocSidecarName(fileNameFromPath(path)) +} diff --git a/internal/nextcloud/ultidoc_paths_test.go b/internal/nextcloud/ultidoc_paths_test.go new file mode 100644 index 0000000..306ed6f --- /dev/null +++ b/internal/nextcloud/ultidoc_paths_test.go @@ -0,0 +1,28 @@ +package nextcloud + +import "testing" + +func TestSidecarPathForSource(t *testing.T) { + tests := []struct { + source string + sidecar string + }{ + {"/docs/report.docx", "/docs/report.ultidoc.json"}, + {"docs/report.docx", "/docs/report.ultidoc.json"}, + {"/report.docx", "/report.ultidoc.json"}, + } + for _, tt := range tests { + if got := SidecarPathForSource(tt.source); got != tt.sidecar { + t.Fatalf("SidecarPathForSource(%q) = %q, want %q", tt.source, got, tt.sidecar) + } + } +} + +func TestIsUltidocSidecarPath(t *testing.T) { + if !IsUltidocSidecarPath("/docs/report.ultidoc.json") { + t.Fatal("expected sidecar path") + } + if IsUltidocSidecarPath("/docs/report.docx") { + t.Fatal("docx is not a sidecar path") + } +} diff --git a/internal/server/bootstrap.go b/internal/server/bootstrap.go index 250ceb1..2875109 100644 --- a/internal/server/bootstrap.go +++ b/internal/server/bootstrap.go @@ -15,6 +15,7 @@ import ( "github.com/prometheus/client_golang/prometheus/promhttp" "github.com/redis/go-redis/v9" + aiapi "github.com/ultisuite/ulti-backend/internal/api/ai" "github.com/ultisuite/ulti-backend/internal/api/admin" "github.com/ultisuite/ulti-backend/internal/api/calendar" "github.com/ultisuite/ulti-backend/internal/api/contacts" @@ -27,6 +28,7 @@ import ( "github.com/ultisuite/ulti-backend/internal/api/middleware" "github.com/ultisuite/ulti-backend/internal/api/office" "github.com/ultisuite/ulti-backend/internal/api/richtext" + "github.com/ultisuite/ulti-backend/internal/api/ultidraw" photosapi "github.com/ultisuite/ulti-backend/internal/api/photos" usersapi "github.com/ultisuite/ulti-backend/internal/api/users" "github.com/ultisuite/ulti-backend/internal/automation" @@ -300,11 +302,24 @@ func New(ctx context.Context, cfg *config.Config, opts Options) (*App, error) { rtHandler := richtext.NewHandler(rtSvc, driveSvc) r.Mount("/api/v1/richtext", rtHandler.Routes(middleware.Auth(verifierHolder, pool, auditLogger, orgPolicyLoader))) driveHandler.SetPublicRichText(rtHandler) + + udSvc := ultidraw.NewService(ncClient, ultidraw.Config{ + Enabled: true, + HocuspocusPublicURL: cfg.HocuspocusPublicURL, + HocuspocusSecret: cfg.HocuspocusSecret, + APIInternalURL: cfg.OnlyOfficeAPIInternalURL, + }, driveSvc) + udHandler := ultidraw.NewHandler(udSvc) + r.Mount("/api/v1/ultidraw", udHandler.Routes(middleware.Auth(verifierHolder, pool, auditLogger, orgPolicyLoader))) + driveHandler.SetPublicUltidraw(udHandler) } if driveHandler != nil { r.Mount("/api/v1/drive/public", driveHandler.PublicRoutes()) } + aiHandler := aiapi.NewHandler(pool, cfg, ncClient, verifierHolder) + r.Mount("/api/v1/ai", aiHandler.Routes(middleware.Auth(verifierHolder, pool, auditLogger, orgPolicyLoader))) + r.Group(func(r chi.Router) { r.Use(middleware.Auth(verifierHolder, pool, auditLogger, orgPolicyLoader)) r.Use(middleware.EnforceApiTokenPolicy()) diff --git a/migrations/000035_ai_assistant.down.sql b/migrations/000035_ai_assistant.down.sql new file mode 100644 index 0000000..14ea863 --- /dev/null +++ b/migrations/000035_ai_assistant.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS ai_usage_monthly; +DROP TABLE IF EXISTS ai_usage_daily; diff --git a/migrations/000035_ai_assistant.up.sql b/migrations/000035_ai_assistant.up.sql new file mode 100644 index 0000000..b980694 --- /dev/null +++ b/migrations/000035_ai_assistant.up.sql @@ -0,0 +1,16 @@ +CREATE TABLE IF NOT EXISTS ai_usage_daily ( + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + usage_date DATE NOT NULL DEFAULT CURRENT_DATE, + requests INT NOT NULL DEFAULT 0, + tokens BIGINT NOT NULL DEFAULT 0, + PRIMARY KEY (user_id, usage_date) +); + +CREATE TABLE IF NOT EXISTS ai_usage_monthly ( + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + usage_month DATE NOT NULL, + tokens BIGINT NOT NULL DEFAULT 0, + PRIMARY KEY (user_id, usage_month) +); + +CREATE INDEX IF NOT EXISTS ai_usage_daily_date_idx ON ai_usage_daily(usage_date); diff --git a/services/hocuspocus/package.json b/services/hocuspocus/package.json index 763bb85..e484fb7 100644 --- a/services/hocuspocus/package.json +++ b/services/hocuspocus/package.json @@ -22,6 +22,7 @@ "@tiptap/extension-text-style": "^3.23.2", "@tiptap/extension-underline": "^3.23.2", "@tiptap/starter-kit": "^3.23.2", + "fractional-indexing": "^3.2.0", "yjs": "^13.6.27" } } diff --git a/services/hocuspocus/pnpm-lock.yaml b/services/hocuspocus/pnpm-lock.yaml index 756b383..578148f 100644 --- a/services/hocuspocus/pnpm-lock.yaml +++ b/services/hocuspocus/pnpm-lock.yaml @@ -50,6 +50,9 @@ importers: '@tiptap/starter-kit': specifier: ^3.23.2 version: 3.26.0 + fractional-indexing: + specifier: ^3.2.0 + version: 3.2.0 yjs: specifier: ^13.6.27 version: 13.6.31 @@ -252,6 +255,10 @@ packages: srvx: optional: true + fractional-indexing@3.2.0: + resolution: {integrity: sha512-PcOxmqwYCW7O2ovKRU8OoQQj2yqTfEB/yeTYk4gPid6dN5ODRfU1hXd9tTVZzax/0NkO7AxpHykvZnT1aYp/BQ==} + engines: {node: ^14.13.1 || >=16.0.0} + isomorphic.js@0.2.5: resolution: {integrity: sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==} @@ -544,6 +551,8 @@ snapshots: crossws@0.4.5: {} + fractional-indexing@3.2.0: {} + isomorphic.js@0.2.5: {} kleur@4.1.5: {} diff --git a/services/hocuspocus/server.mjs b/services/hocuspocus/server.mjs index f2ac19f..f7172e9 100644 --- a/services/hocuspocus/server.mjs +++ b/services/hocuspocus/server.mjs @@ -15,6 +15,7 @@ const TextAlign = require("@tiptap/extension-text-align").default const { Table, TableRow, TableCell, TableHeader } = require("@tiptap/extension-table") const Image = require("@tiptap/extension-image").default +/** Keep in sync with lib/drive/extensions/docs-graphic.ts graphicAttributes */ const graphicAttributes = { graphicType: { default: "image" }, src: { default: null }, @@ -34,6 +35,12 @@ const graphicAttributes = { floatSide: { default: "left" }, x: { default: 0 }, y: { default: 0 }, + positionMode: { default: "move-with-text" }, + anchorPos: { default: -1 }, + pageIndex: { default: 0 }, + pageX: { default: 0 }, + pageY: { default: 0 }, + wrapMarginMm: { default: 3 }, rotationDeg: { default: 0 }, zIndex: { default: 0 }, cropX: { default: 0 }, @@ -41,9 +48,18 @@ const graphicAttributes = { cropWidth: { default: 1 }, cropHeight: { default: 1 }, cropShape: { default: "rect" }, + lockAspectRatio: { default: true }, + imageFit: { default: "contain" }, + imageFitAnchorH: { default: 0.5 }, + imageFitAnchorV: { default: 0.5 }, assetId: { default: null }, opacity: { default: 1 }, shadow: { default: "" }, + brightness: { default: 0 }, + contrast: { default: 0 }, + recolor: { default: "" }, + altTitle: { default: "" }, + drawScene: { default: null }, } const DocsGraphic = Node.create({ @@ -131,6 +147,115 @@ function tipTapContentHasBody(content) { return walk(content) } +function yjsToExcalidrawElements(yArray) { + if (!yArray || yArray.length === 0) return [] + return yArray + .toArray() + .sort((a, b) => { + const key1 = a.get("pos") + const key2 = b.get("pos") + return key1 > key2 ? 1 : key1 < key2 ? -1 : 0 + }) + .map((x) => x.get("el")) + .filter((el) => el && typeof el.id === "string" && typeof el.type === "string") +} + +function exportUltidrawScene(ydoc) { + const yElements = ydoc.getArray("elements") + const yAssets = ydoc.getMap("assets") + const elements = yjsToExcalidrawElements(yElements) + const files = {} + yAssets.forEach((value, key) => { + files[key] = value + }) + return { + elements, + appState: { gridSize: null, viewBackgroundColor: "#ffffff" }, + files, + } +} + +function seedYdocFromJson(ydoc, elements, files, generateNKeysBetween) { + const yElements = ydoc.getArray("elements") + const yAssets = ydoc.getMap("assets") + if (!Array.isArray(elements) || elements.length === 0 || yElements.length > 0) return + const keys = generateNKeysBetween(null, null, elements.length) + ydoc.transact(() => { + for (let i = 0; i < elements.length; i++) { + const el = elements[i] + if (!el || typeof el.id !== "string") continue + yElements.push([ + new Y.Map(Object.entries({ pos: keys[i], el: { ...el } })), + ]) + } + if (files && typeof files === "object") { + for (const [id, asset] of Object.entries(files)) { + yAssets.set(id, asset) + } + } + }) +} + +async function loadFromUltidraw(context) { + if (!context?.path || !context?.user) return null + const params = new URLSearchParams({ user: context.user, path: context.path }) + const res = await fetch(`${ULTID_URL}/api/v1/ultidraw/internal/document?${params}`, { + headers: SECRET ? { "X-Hocuspocus-Secret": SECRET } : {}, + }) + if (res.status === 404) return null + if (!res.ok) throw new Error(`ultidraw load failed: ${res.status}`) + const raw = await res.text() + if (!raw.trim()) return null + try { + const doc = JSON.parse(raw) + const ydoc = new Y.Doc() + if (doc.yjsState) { + Y.applyUpdate(ydoc, Buffer.from(doc.yjsState, "base64")) + } + const { generateNKeysBetween } = await import("fractional-indexing") + seedYdocFromJson(ydoc, doc.elements, doc.files, generateNKeysBetween) + if (ydoc.getArray("elements").length === 0) { + return null + } + return Buffer.from(Y.encodeStateAsUpdate(ydoc)) + } catch (err) { + console.error("[onLoadDocument] ultidraw parse", err) + } + return null +} + +async function storeToUltidraw(context, document) { + if (!context?.path || !context?.user) { + throw new Error("ultidraw store missing path or user in context") + } + const state = Buffer.from(Y.encodeStateAsUpdate(document)).toString("base64") + const scene = exportUltidrawScene(document) + const body = { + room: context.room ?? context.path, + path: context.path, + user: context.user, + sub: context.sub ?? "", + yjsState: state, + document: scene, + } + const res = await fetch(`${ULTID_URL}/api/v1/ultidraw/hooks/store`, { + method: "POST", + headers: { + "Content-Type": "application/json", + ...(SECRET ? { "X-Hocuspocus-Secret": SECRET } : {}), + }, + body: JSON.stringify(body), + }) + if (!res.ok) { + const detail = await res.text().catch(() => "") + throw new Error(`ultidraw store failed: ${res.status}${detail ? ` ${detail}` : ""}`) + } +} + +function isDrawRoom(name) { + return typeof name === "string" && name.startsWith("draw:") +} + async function loadFromUltid(context) { if (!context?.path || !context?.user) return null const params = new URLSearchParams({ user: context.user, path: context.path }) @@ -219,6 +344,9 @@ const server = new Server({ async onLoadDocument(data) { const ctx = hookContext(data) + if (isDrawRoom(data.documentName) || isDrawRoom(ctx.room)) { + return await loadFromUltidraw(ctx) + } return await loadFromUltid(ctx) }, @@ -226,7 +354,11 @@ const server = new Server({ const ctx = hookContext(data) if (ctx.mode === "view") return try { - await storeToUltid(ctx, data.document) + if (isDrawRoom(data.documentName) || isDrawRoom(ctx.room)) { + await storeToUltidraw(ctx, data.document) + } else { + await storeToUltid(ctx, data.document) + } } catch (err) { console.error("[onStoreDocument]", err) } diff --git a/services/openwebui/pipelines/ulti-nc-sync.py b/services/openwebui/pipelines/ulti-nc-sync.py new file mode 100644 index 0000000..a5a8bab --- /dev/null +++ b/services/openwebui/pipelines/ulti-nc-sync.py @@ -0,0 +1,59 @@ +""" +title: UltiAI NC Sync +author: ulti-suite +version: 0.1.0 +description: Sync completed chats to Nextcloud via ulti-backend. +""" + +from typing import Optional +import os +import json +import urllib.request + + +class Pipeline: + def __init__(self): + self.ultid_api = os.environ.get("ULTID_API_URL", "http://ultid:8080/api/v1").rstrip("/") + self.sync_token = os.environ.get("ULTI_AI_SYNC_TOKEN", "") + + async def on_shutdown(self): + pass + + async def inlet(self, body: dict, user: Optional[dict] = None) -> dict: + return body + + async def outlet(self, body: dict, user: Optional[dict] = None) -> dict: + chat = body.get("chat") or body.get("messages") + if not chat: + return body + chat_id = body.get("chat_id") or body.get("id") + if not chat_id: + return body + record = { + "id": chat_id, + "title": body.get("title") or "Conversation", + "source": "openwebui", + "openwebui_chat_id": chat_id, + "messages": body.get("messages") or [], + "meta": { + "model": body.get("model"), + "context": body.get("context") or "standalone", + }, + } + try: + payload = json.dumps(record).encode("utf-8") + req = urllib.request.Request( + f"{self.ultid_api}/ai/chats/sync", + data=payload, + headers={ + "Content-Type": "application/json", + "Authorization": f"Bearer {self.sync_token}" if self.sync_token else "", + }, + method="POST", + ) + with urllib.request.urlopen(req, timeout=15) as resp: + if resp.status >= 400: + print(f"[ulti-nc-sync] sync failed: {resp.status}") + except Exception as exc: + print(f"[ulti-nc-sync] sync error: {exc}") + return body diff --git a/services/openwebui/skills/docs-context.md b/services/openwebui/skills/docs-context.md new file mode 100644 index 0000000..528ab6f --- /dev/null +++ b/services/openwebui/skills/docs-context.md @@ -0,0 +1,29 @@ +# UltiDocs (éditeur texte) + +Tu aides l'utilisateur dans un document UltiDocs (TipTap / ProseMirror). + +## Lecture + +- Le contexte embarqué contient titre, chemin sidecar, extrait texte, sélection et JSON tronqué. +- Pour un document non chargé dans le volet, utilise `docs_read` avec le chemin `.ultidoc`. + +## Modification + +Deux modes : + +1. **Volet intégré (Gemini)** — renvoie un bloc fenced pour appliquer côté éditeur : + ` ```ulti-docs-apply\n{ "action": "insert_text"|"replace_selection"|"append_paragraph"|"set_content", ... }\n``` ` + - `insert_text` / `replace_selection` : texte TipTap/HTML simple (paragraphes, gras, etc.) + - `append_paragraph` : texte brut découpé en paragraphes + - `set_content` : document JSON TipTap complet `{ type: "doc", content: [...] }` + +2. **API / MCP** — `docs_save` avec `{ path, document }` où `document` est le nœud `content` TipTap ou l'objet doc complet selon l'API. + +## Syntaxe TipTap + +- Racine : `{ type: "doc", content: [blocs] }` +- Blocs : `paragraph`, `heading` (level 1-6), `bulletList`, `orderedList`, `blockquote`, `codeBlock` +- Inline : `{ type: "text", text: "...", marks?: [{ type: "bold"|"italic"|"link", attrs? }] }` +- Toujours produire du JSON valide ; ne pas inventer de nœuds custom (`docsGraphic`, etc.) sans preuve dans le document source. + +Réponds en français par défaut. diff --git a/services/openwebui/skills/ultimail-assistant.md b/services/openwebui/skills/ultimail-assistant.md new file mode 100644 index 0000000..8c0f77b --- /dev/null +++ b/services/openwebui/skills/ultimail-assistant.md @@ -0,0 +1,8 @@ +# Ultimail Assistant + +Tu es UltiAI, l'assistant de la suite souveraine Ultimail. + +- Utilise les tools pour lire/agir sur mail, drive, contacts et documents UltiDocs quand c'est pertinent. +- Cite les sources (sujet mail, chemin fichier, nom contact). +- Ne fabrique pas de données : interroge l'API via les tools. +- Réponds en français par défaut. diff --git a/services/ultimail-mcp/Dockerfile b/services/ultimail-mcp/Dockerfile new file mode 100644 index 0000000..e337c33 --- /dev/null +++ b/services/ultimail-mcp/Dockerfile @@ -0,0 +1,15 @@ +FROM node:22-alpine AS build +WORKDIR /app +COPY package.json tsconfig.json ./ +RUN npm install +COPY src ./src +RUN npm run build + +FROM node:22-alpine +WORKDIR /app +COPY package.json ./ +RUN npm install --omit=dev +COPY --from=build /app/dist ./dist +ENV MCP_PORT=3100 +EXPOSE 3100 +CMD ["node", "dist/index.js"] diff --git a/services/ultimail-mcp/package.json b/services/ultimail-mcp/package.json new file mode 100644 index 0000000..0db7479 --- /dev/null +++ b/services/ultimail-mcp/package.json @@ -0,0 +1,22 @@ +{ + "name": "ultimail-mcp", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "start": "node dist/index.js", + "build": "tsc", + "dev": "tsx src/index.ts" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.12.1", + "express": "^5.1.0", + "zod": "^3.24.4" + }, + "devDependencies": { + "@types/express": "^5.0.3", + "@types/node": "^22.15.0", + "tsx": "^4.19.4", + "typescript": "^5.8.3" + } +} diff --git a/services/ultimail-mcp/src/index.ts b/services/ultimail-mcp/src/index.ts new file mode 100644 index 0000000..0da80e6 --- /dev/null +++ b/services/ultimail-mcp/src/index.ts @@ -0,0 +1,167 @@ +import express from "express" +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js" +import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js" +import { z } from "zod" + +const PORT = Number(process.env.MCP_PORT ?? 3100) +const API_BASE = (process.env.ULTID_API_URL ?? "http://localhost:8080/api/v1").replace(/\/$/, "") + +async function ultiFetch( + token: string, + path: string, + init?: RequestInit +): Promise { + const headers = new Headers(init?.headers) + headers.set("Accept", "application/json") + if (token) headers.set("Authorization", `Bearer ${token}`) + const res = await fetch(`${API_BASE}${path}`, { ...init, headers }) + const text = await res.text() + if (!res.ok) { + throw new Error(`ulti ${path} failed (${res.status}): ${text.slice(0, 500)}`) + } + try { + return JSON.parse(text) + } catch { + return text + } +} + +function createServer(getToken: () => string) { + const server = new McpServer({ + name: "ultimail-mcp", + version: "0.1.0", + }) + + server.tool( + "mail_search", + "Search mail messages", + { query: z.string(), account_id: z.string().optional() }, + async ({ query, account_id }) => { + const qs = new URLSearchParams({ q: query }) + if (account_id) qs.set("account_id", account_id) + const data = await ultiFetch(getToken(), `/mail/search?${qs}`) + return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] } + } + ) + + server.tool( + "mail_read_message", + "Read a mail message by id", + { message_id: z.string(), account_id: z.string().optional() }, + async ({ message_id, account_id }) => { + const qs = account_id ? `?account_id=${encodeURIComponent(account_id)}` : "" + const data = await ultiFetch(getToken(), `/mail/messages/${message_id}${qs}`) + return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] } + } + ) + + server.tool( + "drive_list", + "List drive files in a folder", + { path: z.string().optional() }, + async ({ path }) => { + const qs = path ? `?path=${encodeURIComponent(path)}` : "" + const data = await ultiFetch(getToken(), `/drive/list${qs}`) + return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] } + } + ) + + server.tool( + "contacts_search", + "Search contacts", + { query: z.string() }, + async ({ query }) => { + const qs = new URLSearchParams({ q: query }) + const data = await ultiFetch(getToken(), `/contacts/search?${qs}`) + return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] } + } + ) + + server.tool( + "suite_search", + "Unified search across mail, drive, contacts", + { query: z.string(), types: z.string().optional() }, + async ({ query, types }) => { + const qs = new URLSearchParams({ q: query }) + if (types) qs.set("types", types) + const data = await ultiFetch(getToken(), `/search?${qs}`) + return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] } + } + ) + + server.tool( + "docs_read", + "Read UltiDocs document JSON (.ultidoc sidecar path)", + { path: z.string() }, + async ({ path }) => { + const encoded = path + .replace(/^\/+/, "") + .split("/") + .filter(Boolean) + .map((seg) => encodeURIComponent(seg)) + .join("/") + const data = await ultiFetch(getToken(), `/drive/download/${encoded}`) + return { + content: [ + { + type: "text", + text: typeof data === "string" ? data : JSON.stringify(data, null, 2), + }, + ], + } + } + ) + + server.tool( + "docs_save", + "Save UltiDocs TipTap content to sidecar path", + { + path: z.string(), + document: z.record(z.string(), z.unknown()), + }, + async ({ path, document }) => { + const data = await ultiFetch(getToken(), "/richtext/save", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ path, document }), + }) + return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] } + } + ) + + return server +} + +const app = express() +const transports = new Map() + +app.get("/health", (_req, res) => { + res.json({ ok: true }) +}) + +app.get("/mcp", async (req, res) => { + const token = + String(req.headers["x-ulti-token"] ?? req.headers.authorization ?? "").replace( + /^Bearer\s+/i, + "" + ) || "" + const transport = new SSEServerTransport("/mcp/messages", res) + transports.set(transport.sessionId, transport) + res.on("close", () => transports.delete(transport.sessionId)) + const server = createServer(() => token) + await server.connect(transport) +}) + +app.post("/mcp/messages", express.json(), async (req, res) => { + const sessionId = String(req.query.sessionId ?? "") + const transport = transports.get(sessionId) + if (!transport) { + res.status(404).json({ error: "session not found" }) + return + } + await transport.handlePostMessage(req, res, req.body) +}) + +app.listen(PORT, () => { + console.log(`ultimail-mcp listening on :${PORT}`) +}) diff --git a/services/ultimail-mcp/tsconfig.json b/services/ultimail-mcp/tsconfig.json new file mode 100644 index 0000000..e5157b4 --- /dev/null +++ b/services/ultimail-mcp/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "dist", + "rootDir": "src", + "strict": true, + "skipLibCheck": true, + "esModuleInterop": true + }, + "include": ["src/**/*"] +}