diff --git a/.env.example b/.env.example
index 0b3a5b4..d52c93a 100644
--- a/.env.example
+++ b/.env.example
@@ -189,6 +189,10 @@ JITSI_DOMAIN=meet.jitsi
JITSI_APP_ID=ulti
# JITSI_APP_SECRET — defini dans la section Secrets
JITSI_PUBLIC_URL=https://{{DOMAIN}}/meet
+# Secret partagé avec Jigasi pour POST /api/v1/meet/transcripts
+MEET_TRANSCRIPT_WEBHOOK_SECRET=changeme-meet-transcript-secret
+# Modèle Faster Whisper (Skynet) : tiny, base, small…
+SKYNET_WHISPER_MODEL=tiny
JICOFO_AUTH_PASSWORD={{JITSI_INTERNAL_AUTH_PASSWORD}}
JVB_AUTH_PASSWORD={{JITSI_INTERNAL_AUTH_PASSWORD}}
diff --git a/README.md b/README.md
index 87c1079..fcf5463 100644
--- a/README.md
+++ b/README.md
@@ -104,7 +104,7 @@ Un seul **nginx** expose l’entrée HTTP (`:80`) et route :
| `/auth/*` | Authentik |
| `/meet/*` | Jitsi (si `JITSI_ENABLED=true`) |
| `/cloud/*` | Nextcloud nginx+FPM (si `NEXTCLOUD_ENABLED=true`) |
-| `/mail/*`, `/drive/*`, `/contacts`, `/admin/*` | Suite frontend (`MAIL_FRONTEND_UPSTREAM`, défaut `host.docker.internal:3004` ; Docker : `suite-frontend:3000`) |
+| `/mail/*`, `/drive/*`, `/contacts`, `/agenda`, `/compte`, `/admin/*` | Suite frontend (`MAIL_FRONTEND_UPSTREAM`, défaut `host.docker.internal:3004` ; Docker : `suite-frontend:3000`) |
Nextcloud : FPM + nginx dédié ; ultid appelle `NEXTCLOUD_URL` en interne (`http://nextcloud:80`).
Caddy retiré : un seul proxy évite la double couche ; TLS plus tard (certbot, Traefik, ou `listen 443` nginx).
diff --git a/deploy/jitsi/docker-compose.jitsi.yml b/deploy/jitsi/docker-compose.jitsi.yml
index 4e0bafb..980b477 100644
--- a/deploy/jitsi/docker-compose.jitsi.yml
+++ b/deploy/jitsi/docker-compose.jitsi.yml
@@ -69,3 +69,45 @@ services:
- ulti-net
depends_on:
- jitsi-prosody
+
+ skynet:
+ build:
+ context: ./skynet
+ dockerfile: Dockerfile
+ restart: unless-stopped
+ environment:
+ ENABLED_MODULES: streaming_whisper
+ BYPASS_AUTHORIZATION: "1"
+ WHISPER_MODEL_NAME: ${SKYNET_WHISPER_MODEL:-tiny}
+ WHISPER_MODEL_PATH: /models/streaming-whisper
+ BEAM_SIZE: "1"
+ volumes:
+ - skynet-models:/models
+ networks:
+ - ulti-net
+
+ jitsi-jigasi:
+ image: jitsi/jigasi:stable-9823
+ restart: unless-stopped
+ environment:
+ <<: *jitsi-env
+ XMPP_DOMAIN: meet.jitsi
+ XMPP_MUC_DOMAIN: muc.meet.jitsi
+ XMPP_INTERNAL_MUC_DOMAIN: internal-muc.meet.jitsi
+ JIGASI_BREWERY_MUC: jigasibrewery@internal-muc.meet.jitsi
+ JIGASI_ENABLE_SDES_SRTP: "0"
+ ENABLE_TRANSCRIPTIONS: "1"
+ JIGASI_TRANSCRIBER_CUSTOM_SERVICE: org.jitsi.jigasi.transcription.WhisperTranscriptionService
+ JIGASI_TRANSCRIBER_WHISPER_URL: ws://skynet:8000/streaming-whisper/ws
+ JIGASI_TRANSCRIBER_SEND_JSON: "true"
+ JIGASI_TRANSCRIBER_BASE_URL: http://ultid:8080/api/v1/meet/transcripts/
+ volumes:
+ - ./jigasi:/config:ro
+ networks:
+ - ulti-net
+ depends_on:
+ - jitsi-prosody
+ - skynet
+
+volumes:
+ skynet-models:
diff --git a/deploy/jitsi/jigasi/sip-communicator.properties b/deploy/jitsi/jigasi/sip-communicator.properties
new file mode 100644
index 0000000..3d96397
--- /dev/null
+++ b/deploy/jitsi/jigasi/sip-communicator.properties
@@ -0,0 +1,5 @@
+# UltiMeet — transcription via Jigasi + Skynet (Faster Whisper)
+org.jitsi.jigasi.transcription.customService=org.jitsi.jigasi.transcription.WhisperTranscriptionService
+org.jitsi.jigasi.transcription.whisper.websocket_url=ws://skynet:8000/streaming-whisper/ws
+org.jitsi.jigasi.transcription.SEND_JSON=true
+org.jitsi.jigasi.transcription.BASE_URL=http://ultid:8080/api/v1/meet/transcripts/
diff --git a/deploy/jitsi/skynet/Dockerfile b/deploy/jitsi/skynet/Dockerfile
new file mode 100644
index 0000000..ba13a3e
--- /dev/null
+++ b/deploy/jitsi/skynet/Dockerfile
@@ -0,0 +1,23 @@
+FROM python:3.12-bookworm
+
+RUN apt-get update \
+ && apt-get install -y --no-install-recommends git ffmpeg \
+ && rm -rf /var/lib/apt/lists/*
+
+WORKDIR /skynet
+RUN git clone --depth 1 https://github.com/jitsi/skynet.git .
+
+RUN pip install --no-cache-dir poetry \
+ && poetry config virtualenvs.create false \
+ && poetry install --no-interaction --no-ansi
+
+ENV ENABLED_MODULES=streaming_whisper
+ENV BYPASS_AUTHORIZATION=1
+ENV WHISPER_MODEL_NAME=tiny
+ENV WHISPER_MODEL_PATH=/models/streaming-whisper
+ENV BEAM_SIZE=1
+
+EXPOSE 8000
+VOLUME ["/models"]
+
+CMD ["poetry", "run", "./run.sh"]
diff --git a/deploy/nextcloud/init.sh b/deploy/nextcloud/init.sh
index 82ae8ba..ab0d84a 100755
--- a/deploy/nextcloud/init.sh
+++ b/deploy/nextcloud/init.sh
@@ -18,6 +18,8 @@ $OCC app:enable files || true
$OCC app:enable calendar || true
$OCC app:enable contacts || true
$OCC app:enable groupfolders || true
+$OCC app:enable files_external || true
+$OCC config:app:set files_external allow_user_mounting --value=1 || true
$OCC app:enable user_oidc || true
# Configure OIDC (Authentik)
diff --git a/deploy/nginx/default.conf.template b/deploy/nginx/default.conf.template
index c7776aa..6202adc 100644
--- a/deploy/nginx/default.conf.template
+++ b/deploy/nginx/default.conf.template
@@ -107,6 +107,10 @@ server {
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_read_timeout 86400;
+ proxy_hide_header X-Frame-Options;
+ proxy_hide_header Content-Security-Policy;
+ # Permet l’embed du portail Authentik dans la suite (même host + dev Next :3004).
+ add_header Content-Security-Policy "frame-ancestors 'self' http://localhost:3004 http://127.0.0.1:3004" always;
}
location /meet/ {
@@ -248,7 +252,7 @@ server {
proxy_set_header Connection $connection_upgrade;
}
- # Ulti Suite frontend (mail + drive + contacts) — dev: pnpm dev on host (MAIL_FRONTEND_UPSTREAM=host.docker.internal:3004)
+ # Ulti Suite frontend (mail + drive + contacts + agenda + compte) — dev: pnpm dev on host (MAIL_FRONTEND_UPSTREAM=host.docker.internal:3004)
# Prod: set MAIL_FRONTEND_UPSTREAM=suite-frontend:3000
# Démos publiques de la landing (zéro rétention) — frontend Next.
location ^~ /demo {
@@ -357,6 +361,19 @@ server {
proxy_set_header Connection $connection_upgrade;
}
+ location ^~ /agenda {
+ resolver 127.0.0.11 valid=10s ipv6=off;
+ set $mail_upstream ${MAIL_FRONTEND_UPSTREAM};
+ proxy_pass http://$mail_upstream;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection $connection_upgrade;
+ }
+
# Réglages du compte Ulti
location ^~ /compte {
resolver 127.0.0.11 valid=10s ipv6=off;
@@ -398,6 +415,18 @@ server {
proxy_set_header Connection $connection_upgrade;
}
+ # next/font (Geist, etc.) — separate from /_next/ static chunks
+ location ^~ /__nextjs_font/ {
+ resolver 127.0.0.11 valid=10s ipv6=off;
+ set $mail_upstream ${MAIL_FRONTEND_UPSTREAM};
+ proxy_pass http://$mail_upstream;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ }
+
location ^~ /brand/ {
resolver 127.0.0.11 valid=10s ipv6=off;
set $mail_upstream ${MAIL_FRONTEND_UPSTREAM};
diff --git a/internal/api/admin/drive_org_folders.go b/internal/api/admin/drive_org_folders.go
new file mode 100644
index 0000000..5b82c46
--- /dev/null
+++ b/internal/api/admin/drive_org_folders.go
@@ -0,0 +1,128 @@
+package admin
+
+import (
+ "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/drive"
+ "github.com/ultisuite/ulti-backend/internal/api/middleware"
+)
+
+func (h *Handler) registerDriveAdminRoutes(r chi.Router, read, write func(http.Handler) http.Handler) {
+ r.With(read).Get("/drive/org-folders", h.ListDriveOrgFolders)
+ r.With(write).Post("/drive/org-folders", h.CreateDriveOrgFolder)
+ r.With(write).Put("/drive/org-folders/{folderID}", h.UpdateDriveOrgFolder)
+ r.With(write).Delete("/drive/org-folders/{folderID}", h.DeleteDriveOrgFolder)
+ r.With(write).Post("/drive/org-folders/sync", h.SyncDriveOrgFolders)
+}
+
+func (h *Handler) driveService() *drive.Service {
+ return drive.NewService(h.svc.nc, nil, h.svc.db)
+}
+
+func (h *Handler) ListDriveOrgFolders(w http.ResponseWriter, r *http.Request) {
+ svc := h.driveService()
+ folders, err := svc.ListOrgFoldersAdmin(r.Context())
+ if err != nil {
+ h.logger.Error("list drive org folders", "error", err)
+ apivalidate.WriteInternal(w, r)
+ return
+ }
+ apiresponse.WriteJSON(w, http.StatusOK, map[string]any{"folders": folders})
+}
+
+func (h *Handler) CreateDriveOrgFolder(w http.ResponseWriter, r *http.Request) {
+ claims := middleware.ClaimsFromContext(r.Context())
+ svc := h.driveService()
+ var req struct {
+ OrgSlug string `json:"org_slug"`
+ MountPoint string `json:"mount_point"`
+ QuotaBytes *int64 `json:"quota_bytes"`
+ }
+ if err := apivalidate.DecodeJSON(w, r, 32<<10, &req); err != nil {
+ return
+ }
+ createdBy := ""
+ if claims != nil {
+ createdBy = claims.Email
+ }
+ folder, err := svc.CreateOrgFolder(r.Context(), drive.CreateOrgFolderParams{
+ OrgSlug: req.OrgSlug,
+ MountPoint: req.MountPoint,
+ QuotaBytes: req.QuotaBytes,
+ CreatedBy: createdBy,
+ })
+ if err != nil {
+ writeDriveAdminError(w, r, err)
+ return
+ }
+ apiresponse.WriteJSON(w, http.StatusCreated, folder)
+}
+
+func (h *Handler) UpdateDriveOrgFolder(w http.ResponseWriter, r *http.Request) {
+ svc := h.driveService()
+ var req struct {
+ MountPoint string `json:"mount_point"`
+ QuotaBytes *int64 `json:"quota_bytes"`
+ }
+ if err := apivalidate.DecodeJSON(w, r, 32<<10, &req); err != nil {
+ return
+ }
+ folder, err := svc.UpdateOrgFolder(r.Context(), chi.URLParam(r, "folderID"), req.MountPoint, req.QuotaBytes)
+ if err != nil {
+ writeDriveAdminError(w, r, err)
+ return
+ }
+ apiresponse.WriteJSON(w, http.StatusOK, folder)
+}
+
+func (h *Handler) DeleteDriveOrgFolder(w http.ResponseWriter, r *http.Request) {
+ svc := h.driveService()
+ if err := svc.DeleteOrgFolder(r.Context(), chi.URLParam(r, "folderID")); err != nil {
+ writeDriveAdminError(w, r, err)
+ return
+ }
+ w.WriteHeader(http.StatusNoContent)
+}
+
+func (h *Handler) SyncDriveOrgFolders(w http.ResponseWriter, r *http.Request) {
+ claims := middleware.ClaimsFromContext(r.Context())
+ svc := h.driveService()
+ var req struct {
+ OrgSlugs []string `json:"org_slugs"`
+ }
+ if err := apivalidate.DecodeJSON(w, r, 32<<10, &req); err != nil {
+ return
+ }
+ createdBy := ""
+ if claims != nil {
+ createdBy = claims.Email
+ }
+ folders, err := svc.SyncOrgFolders(r.Context(), req.OrgSlugs, createdBy)
+ if err != nil {
+ writeDriveAdminError(w, r, err)
+ return
+ }
+ apiresponse.WriteJSON(w, http.StatusOK, map[string]any{"folders": folders})
+}
+
+func writeDriveAdminError(w http.ResponseWriter, r *http.Request, err error) {
+ switch {
+ case err == drive.ErrNotFound:
+ apivalidate.WriteNotFound(w, r, "not found")
+ case err == drive.ErrConflict:
+ apiresponse.WriteError(w, r, http.StatusConflict, "drive.conflict", "resource conflict", nil)
+ case err == drive.ErrInvalid:
+ apiresponse.WriteError(w, r, http.StatusBadRequest, apiresponse.CodeInvalidRequest, "invalid request body", nil)
+ default:
+ if strings.Contains(err.Error(), "not found") {
+ apivalidate.WriteNotFound(w, r, "not found")
+ return
+ }
+ apivalidate.WriteInternal(w, r)
+ }
+}
diff --git a/internal/api/admin/handlers.go b/internal/api/admin/handlers.go
index e1fd9e2..74f85e0 100644
--- a/internal/api/admin/handlers.go
+++ b/internal/api/admin/handlers.go
@@ -64,6 +64,8 @@ func (h *Handler) Routes() chi.Router {
r.With(write).Post("/org/identity-providers/{providerID}/test", h.TestIdentityProvider)
r.With(write).Post("/org/identity-providers/{providerID}/sync", h.SyncIdentityProvider)
+ h.registerDriveAdminRoutes(r, read, write)
+
return r
}
diff --git a/internal/api/admin/org_settings.go b/internal/api/admin/org_settings.go
index 63c9ed3..1744355 100644
--- a/internal/api/admin/org_settings.go
+++ b/internal/api/admin/org_settings.go
@@ -10,6 +10,7 @@ import (
"github.com/ultisuite/ulti-backend/internal/config"
"github.com/ultisuite/ulti-backend/internal/authentik"
+ "github.com/ultisuite/ulti-backend/internal/orgpolicy"
)
const orgSettingsSingletonID = 1
@@ -59,6 +60,7 @@ func defaultOrgPolicy() map[string]any {
"virus_scan_enabled": false,
"virustotal_api_key": "",
"retention_trash_days": 30,
+ "mount_oauth": orgpolicy.DefaultMountOAuthSection(),
},
"llm": map[string]any{
"default_provider_id": "",
@@ -121,6 +123,36 @@ func defaultOrgPolicy() map[string]any {
"chat_sync_enabled": true,
"chat_nc_path": "/.ultimail/ai/chats",
},
+ "agenda": map[string]any{
+ "default_theme_mode": "system",
+ "enforce_org_theme": false,
+ "default_video_provider": "ultimeet",
+ "enforce_org_video_provider": false,
+ "video_provider_api_keys": map[string]any{},
+ },
+ "meet": map[string]any{
+ "transcription_enabled": false,
+ "transcription_mode": "live",
+ "transcription_engine": "faster_whisper_local",
+ "skynet_url": "http://skynet:8000",
+ "whisper_model": "tiny",
+ "external_api_url": "",
+ "external_api_provider": "openai_compatible",
+ "external_api_key": "",
+ "auto_start_transcription": false,
+ "post_actions": map[string]any{
+ "email_enabled": false,
+ "email_recipients": "organizer",
+ "email_custom_addresses": "",
+ "drive_enabled": true,
+ "drive_folder_path": "/UltiMeet/Transcripts",
+ "llm_enabled": false,
+ "llm_provider_id": "",
+ "llm_prompt": "Résume cette réunion en français : points clés, décisions et actions à suivre.",
+ "llm_then_email": true,
+ "llm_then_drive": true,
+ },
+ },
"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"},
@@ -230,9 +262,112 @@ func mergeOrgSecrets(existing, patch map[string]any) map[string]any {
if patchIDP, ok := patch["identity_providers"].(map[string]any); ok {
mergeIdentityProviderSecrets(existing, patchIDP, merged)
}
+ if patchAgenda, ok := patch["agenda"].(map[string]any); ok {
+ mergeAgendaProviderSecrets(existing, patchAgenda, merged)
+ }
+ if patchMeet, ok := patch["meet"].(map[string]any); ok {
+ mergeMeetSecrets(existing, patchMeet, merged)
+ }
+ if patchFilePolicies, ok := patch["file_policies"].(map[string]any); ok {
+ mergeMountOAuthSecrets(existing, patchFilePolicies, merged)
+ }
return merged
}
+func mergeMountOAuthSecrets(existing, patchFilePolicies, merged map[string]any) {
+ patchMountOAuth, _ := patchFilePolicies["mount_oauth"].(map[string]any)
+ if patchMountOAuth == nil {
+ return
+ }
+ existingFilePolicies, _ := existing["file_policies"].(map[string]any)
+ existingMountOAuth, _ := existingFilePolicies["mount_oauth"].(map[string]any)
+ mergedFilePolicies, _ := merged["file_policies"].(map[string]any)
+ if mergedFilePolicies == nil {
+ return
+ }
+ out := map[string]any{}
+ for k, v := range patchMountOAuth {
+ if k == "redirect_uri" {
+ out[k] = v
+ continue
+ }
+ providerPatch, ok := v.(map[string]any)
+ if !ok {
+ out[k] = v
+ continue
+ }
+ mergedProvider := map[string]any{}
+ for pk, pv := range providerPatch {
+ mergedProvider[pk] = pv
+ }
+ if secret, _ := providerPatch["client_secret"].(string); strings.TrimSpace(secret) == "" {
+ if existingMountOAuth != nil {
+ if existingProvider, ok := existingMountOAuth[k].(map[string]any); ok {
+ if prev, ok := existingProvider["client_secret"].(string); ok && prev != "" {
+ mergedProvider["client_secret"] = prev
+ }
+ }
+ }
+ }
+ out[k] = mergedProvider
+ }
+ mergedFilePolicies["mount_oauth"] = out
+ merged["file_policies"] = mergedFilePolicies
+}
+
+func mergeMeetSecrets(existing, patchMeet, merged map[string]any) {
+ if strings.TrimSpace(stringValueMap(patchMeet, "external_api_key")) != "" {
+ return
+ }
+ existingMeet, _ := existing["meet"].(map[string]any)
+ prev := stringValueMap(existingMeet, "external_api_key")
+ if prev == "" {
+ return
+ }
+ mergedMeet, _ := merged["meet"].(map[string]any)
+ if mergedMeet == nil {
+ return
+ }
+ mergedMeet["external_api_key"] = prev
+ merged["meet"] = mergedMeet
+}
+
+func stringValueMap(m map[string]any, key string) string {
+ if m == nil {
+ return ""
+ }
+ s, _ := m[key].(string)
+ return s
+}
+
+func mergeAgendaProviderSecrets(existing, patchAgenda, merged map[string]any) {
+ patchKeys, _ := patchAgenda["video_provider_api_keys"].(map[string]any)
+ if len(patchKeys) == 0 {
+ return
+ }
+ existingAgenda, _ := existing["agenda"].(map[string]any)
+ existingKeys, _ := existingAgenda["video_provider_api_keys"].(map[string]any)
+ mergedAgenda, _ := merged["agenda"].(map[string]any)
+ if mergedAgenda == nil {
+ return
+ }
+ outKeys := map[string]any{}
+ for k, v := range patchKeys {
+ s, _ := v.(string)
+ if strings.TrimSpace(s) == "" {
+ if existingKeys != nil {
+ if prev, ok := existingKeys[k].(string); ok && prev != "" {
+ outKeys[k] = prev
+ continue
+ }
+ }
+ }
+ outKeys[k] = v
+ }
+ mergedAgenda["video_provider_api_keys"] = outKeys
+ merged["agenda"] = mergedAgenda
+}
+
func mergeLLMProviderSecrets(existing, patchLLM, merged map[string]any) {
patchProviders, _ := patchLLM["providers"].([]any)
if len(patchProviders) == 0 {
@@ -384,6 +519,34 @@ func maskOrgPolicy(policy map[string]any) map[string]any {
maskStringField(cloned, "search", "meilisearch_api_key")
maskStringField(cloned, "search", "typesense_api_key")
maskStringField(cloned, "file_policies", "virustotal_api_key")
+ if filePolicies, ok := cloned["file_policies"].(map[string]any); ok {
+ if mountOAuth, ok := filePolicies["mount_oauth"].(map[string]any); ok {
+ for _, provider := range []string{"google", "dropbox", "microsoft"} {
+ if section, ok := mountOAuth[provider].(map[string]any); ok {
+ if secret, _ := section["client_secret"].(string); strings.TrimSpace(secret) != "" {
+ section["client_secret"] = ""
+ }
+ mountOAuth[provider] = section
+ }
+ }
+ filePolicies["mount_oauth"] = mountOAuth
+ }
+ }
+ if agenda, ok := cloned["agenda"].(map[string]any); ok {
+ if keys, ok := agenda["video_provider_api_keys"].(map[string]any); ok {
+ for k, v := range keys {
+ if s, ok := v.(string); ok && strings.TrimSpace(s) != "" {
+ keys[k] = ""
+ }
+ }
+ agenda["video_provider_api_keys"] = keys
+ }
+ }
+ if meet, ok := cloned["meet"].(map[string]any); ok {
+ if v, _ := meet["external_api_key"].(string); strings.TrimSpace(v) != "" {
+ meet["external_api_key"] = ""
+ }
+ }
if llm, ok := cloned["llm"].(map[string]any); ok {
if providers, ok := llm["providers"].([]any); ok {
for i, p := range providers {
@@ -477,6 +640,28 @@ func secretConfigured(policy map[string]any, section, key string) bool {
return strings.TrimSpace(v) != ""
}
+func mountOAuthProviderSecretConfigured(policy map[string]any, provider string) bool {
+ fp, ok := policy["file_policies"].(map[string]any)
+ if !ok {
+ return false
+ }
+ mo, ok := fp["mount_oauth"].(map[string]any)
+ if !ok {
+ return false
+ }
+ section, ok := mo[provider].(map[string]any)
+ if !ok {
+ return false
+ }
+ enabled, _ := section["enabled"].(bool)
+ if !enabled {
+ return false
+ }
+ clientID, _ := section["client_id"].(string)
+ clientSecret, _ := section["client_secret"].(string)
+ return strings.TrimSpace(clientID) != "" && strings.TrimSpace(clientSecret) != ""
+}
+
func buildOrgSecretsStatus(policy map[string]any, cfg *config.Config) map[string]any {
secrets := map[string]any{
"nextcloud_admin_password": map[string]any{
@@ -497,6 +682,15 @@ func buildOrgSecretsStatus(policy map[string]any, cfg *config.Config) map[string
"virustotal_api_key": map[string]any{
"configured": secretConfigured(policy, "file_policies", "virustotal_api_key") || strings.TrimSpace(cfg.VirusTotalAPIKey) != "",
},
+ "mount_oauth_google": map[string]any{
+ "configured": mountOAuthProviderSecretConfigured(policy, "google"),
+ },
+ "mount_oauth_dropbox": map[string]any{
+ "configured": mountOAuthProviderSecretConfigured(policy, "dropbox"),
+ },
+ "mount_oauth_microsoft": map[string]any{
+ "configured": mountOAuthProviderSecretConfigured(policy, "microsoft"),
+ },
}
if idpSecrets := buildIdentityProviderSecretsStatus(policy); len(idpSecrets) > 0 {
secrets["identity_providers"] = idpSecrets
diff --git a/internal/api/calendar/handlers.go b/internal/api/calendar/handlers.go
index 85985e1..87d4b75 100644
--- a/internal/api/calendar/handlers.go
+++ b/internal/api/calendar/handlers.go
@@ -16,6 +16,7 @@ import (
"github.com/ultisuite/ulti-backend/internal/auth"
meetpkg "github.com/ultisuite/ulti-backend/internal/meet"
"github.com/ultisuite/ulti-backend/internal/nextcloud"
+ "github.com/ultisuite/ulti-backend/internal/orgpolicy"
"github.com/ultisuite/ulti-backend/internal/permission"
)
@@ -24,9 +25,9 @@ type Handler struct {
logger *slog.Logger
}
-func NewHandler(nc *nextcloud.Client, meetCfg *meetpkg.Config) *Handler {
+func NewHandler(nc *nextcloud.Client, meetCfg *meetpkg.Config, policy *orgpolicy.Loader) *Handler {
return &Handler{
- svc: NewService(nc, meetCfg),
+ svc: NewService(nc, meetCfg, policy),
logger: slog.Default().With("component", "calendar-api"),
}
}
@@ -78,6 +79,15 @@ func (h *Handler) retryOnDAVMissing(
return op(refreshed)
}
+func (h *Handler) writeCalendarServiceError(w http.ResponseWriter, r *http.Request, op string, err error) {
+ if errors.Is(err, nextcloud.ErrDAVCredentialsMissing) {
+ apiresponse.WriteError(w, r, http.StatusServiceUnavailable, "calendar_unavailable", "calendar backend credentials need refresh; retry shortly", nil)
+ return
+ }
+ h.logger.Error(op, "error", err)
+ apivalidate.WriteInternal(w, r)
+}
+
func (h *Handler) ListCalendars(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
ncUser, ok := h.nextcloudUser(w, r, claims)
@@ -91,8 +101,7 @@ func (h *Handler) ListCalendars(w http.ResponseWriter, r *http.Request) {
return listErr
})
if err != nil {
- h.logger.Error("list calendars", "error", err)
- apivalidate.WriteInternal(w, r)
+ h.writeCalendarServiceError(w, r, "list calendars", err)
return
}
apiresponse.WriteJSON(w, http.StatusOK, map[string]any{"calendars": cals})
@@ -117,8 +126,7 @@ func (h *Handler) CreateCalendar(w http.ResponseWriter, r *http.Request) {
return h.svc.CreateCalendar(r.Context(), userID, normalized.ID, normalized.DisplayName, normalized.Color)
})
if err != nil {
- h.logger.Error("create calendar", "error", err)
- apivalidate.WriteInternal(w, r)
+ h.writeCalendarServiceError(w, r, "create calendar", err)
return
}
apiresponse.WriteJSON(w, http.StatusCreated, map[string]any{"id": normalized.ID})
@@ -143,8 +151,7 @@ func (h *Handler) UpdateCalendar(w http.ResponseWriter, r *http.Request) {
return h.svc.UpdateCalendar(r.Context(), userID, calID, strings.TrimSpace(req.DisplayName), strings.TrimSpace(req.Color))
})
if err != nil {
- h.logger.Error("update calendar", "error", err)
- apivalidate.WriteInternal(w, r)
+ h.writeCalendarServiceError(w, r, "update calendar", err)
return
}
w.WriteHeader(http.StatusNoContent)
@@ -161,8 +168,7 @@ func (h *Handler) DeleteCalendar(w http.ResponseWriter, r *http.Request) {
return h.svc.DeleteCalendar(r.Context(), userID, calID)
})
if err != nil {
- h.logger.Error("delete calendar", "error", err)
- apivalidate.WriteInternal(w, r)
+ h.writeCalendarServiceError(w, r, "delete calendar", err)
return
}
w.WriteHeader(http.StatusNoContent)
@@ -188,8 +194,7 @@ func (h *Handler) ListEvents(w http.ResponseWriter, r *http.Request) {
return listErr
})
if err != nil {
- h.logger.Error("list events", "error", err)
- apivalidate.WriteInternal(w, r)
+ h.writeCalendarServiceError(w, r, "list events", err)
return
}
apiresponse.WriteJSON(w, http.StatusOK, result)
@@ -219,8 +224,7 @@ func (h *Handler) FreeBusy(w http.ResponseWriter, r *http.Request) {
return fbErr
})
if err != nil {
- h.logger.Error("free/busy", "error", err)
- apivalidate.WriteInternal(w, r)
+ h.writeCalendarServiceError(w, r, "free/busy", err)
return
}
apiresponse.WriteJSON(w, http.StatusOK, result)
@@ -250,8 +254,7 @@ func (h *Handler) CreateEvent(w http.ResponseWriter, r *http.Request) {
return h.svc.CreateEvent(r.Context(), userID, calID, &event)
})
if err != nil {
- h.logger.Error("create event", "error", err)
- apivalidate.WriteInternal(w, r)
+ h.writeCalendarServiceError(w, r, "create event", err)
return
}
w.WriteHeader(http.StatusCreated)
@@ -299,8 +302,7 @@ func (h *Handler) UpdateEvent(w http.ResponseWriter, r *http.Request) {
apiresponse.WriteError(w, r, http.StatusPreconditionFailed, "etag_mismatch", "etag does not match current resource version", nil)
return
}
- h.logger.Error("update event", "error", err)
- apivalidate.WriteInternal(w, r)
+ h.writeCalendarServiceError(w, r, "update event", err)
return
}
apiresponse.WriteJSON(w, http.StatusOK, map[string]any{"etag": etag})
@@ -411,8 +413,7 @@ func (h *Handler) DeleteEvent(w http.ResponseWriter, r *http.Request) {
return h.svc.DeleteEvent(r.Context(), userID, eventPath)
})
if err != nil {
- h.logger.Error("delete event", "error", err)
- apivalidate.WriteInternal(w, r)
+ h.writeCalendarServiceError(w, r, "delete event", err)
return
}
w.WriteHeader(http.StatusNoContent)
diff --git a/internal/api/calendar/service.go b/internal/api/calendar/service.go
index d36e317..c90c8ef 100644
--- a/internal/api/calendar/service.go
+++ b/internal/api/calendar/service.go
@@ -12,17 +12,19 @@ import (
"github.com/ultisuite/ulti-backend/internal/auth"
meetpkg "github.com/ultisuite/ulti-backend/internal/meet"
"github.com/ultisuite/ulti-backend/internal/nextcloud"
+ "github.com/ultisuite/ulti-backend/internal/orgpolicy"
)
type Service struct {
nc *nextcloud.Client
meetCfg *meetpkg.Config
+ policy *orgpolicy.Loader
}
var ErrMeetDisabled = errors.New("meet is disabled")
-func NewService(nc *nextcloud.Client, meetCfg *meetpkg.Config) *Service {
- return &Service{nc: nc, meetCfg: meetCfg}
+func NewService(nc *nextcloud.Client, meetCfg *meetpkg.Config, policy *orgpolicy.Loader) *Service {
+ return &Service{nc: nc, meetCfg: meetCfg, policy: policy}
}
func (s *Service) EnsureNextcloudUser(ctx context.Context, claims *auth.Claims) (string, error) {
@@ -99,10 +101,23 @@ func (s *Service) CreateEvent(ctx context.Context, userID, calID string, event *
}
func (s *Service) UpdateEvent(ctx context.Context, userID, eventPath, ifMatch string, event *nextcloud.Event) (string, error) {
- if strings.TrimSpace(event.Organizer) == "" {
- event.Organizer = userID
+ existing, err := s.nc.GetEvent(ctx, userID, eventPath)
+ if err != nil {
+ return "", err
}
- return s.nc.UpdateEvent(ctx, userID, eventPath, ifMatch, event)
+ merged := nextcloud.MergeEvent(existing, event)
+ if strings.TrimSpace(merged.Organizer) == "" {
+ merged.Organizer = userID
+ }
+ merged.Sequence = existing.Sequence + 1
+ if merged.Sequence < 1 {
+ merged.Sequence = 1
+ }
+ match := strings.TrimSpace(ifMatch)
+ if match == "" || match == "*" {
+ match = strings.TrimSpace(existing.ETag)
+ }
+ return s.nc.UpdateEvent(ctx, userID, eventPath, match, merged)
}
func (s *Service) DeleteEvent(ctx context.Context, userID, eventPath string) error {
@@ -144,12 +159,18 @@ func (s *Service) CreateMeetLink(ctx context.Context, userID, userName, userEmai
if roomID == "" {
roomID = fmt.Sprintf("event-%d", time.Now().Unix())
}
+ tokenOpts := meetpkg.TokenOptions{}
+ if s.policy != nil {
+ if p, err := s.policy.MeetPolicy(ctx); err == nil && p.LiveTranscriptionJWT() {
+ tokenOpts.Transcription = true
+ }
+ }
token, err := s.meetCfg.GenerateToken(roomID, &meetpkg.UserInfo{
ID: userID,
Name: userName,
Email: userEmail,
IsMod: true,
- }, 24*time.Hour)
+ }, 24*time.Hour, tokenOpts)
if err != nil {
return "", "", err
}
diff --git a/internal/api/drive/handlers.go b/internal/api/drive/handlers.go
index 1d33abc..767d2fb 100644
--- a/internal/api/drive/handlers.go
+++ b/internal/api/drive/handlers.go
@@ -108,6 +108,8 @@ func (h *Handler) Routes() chi.Router {
r.With(write).Put("/shares/{shareID}", h.UpdateShare)
r.With(write).Delete("/shares/{shareID}", h.DeleteShare)
+ h.registerOrgAndMountRoutes(r, read, write)
+
return r
}
@@ -348,7 +350,23 @@ func (h *Handler) Move(w http.ResponseWriter, r *http.Request) {
return
}
- if err := h.svc.Move(r.Context(), ncUser, req.Source, req.Destination); err != nil {
+ srcRef, err := pathRefFromMoveSource(&req)
+ if err != nil {
+ writeDriveError(w, r, err)
+ return
+ }
+ destRef, err := pathRefFromMoveDestination(&req)
+ if err != nil {
+ writeDriveError(w, r, err)
+ return
+ }
+ if usesAlternateRoot(srcRef) || usesAlternateRoot(destRef) {
+ if err := h.svc.MoveAtRoot(r.Context(), ncUser, srcRef, destRef); err != nil {
+ h.logger.Error("move", "error", err)
+ writeDriveError(w, r, err)
+ return
+ }
+ } else if err := h.svc.Move(r.Context(), ncUser, req.Source, req.Destination); err != nil {
h.logger.Error("move", "error", err)
writeDriveError(w, r, err)
return
@@ -376,7 +394,23 @@ func (h *Handler) Copy(w http.ResponseWriter, r *http.Request) {
return
}
- if err := h.svc.Copy(r.Context(), ncUser, req.Source, req.Destination); err != nil {
+ srcRef, err := pathRefFromCopySource(&req)
+ if err != nil {
+ writeDriveError(w, r, err)
+ return
+ }
+ destRef, err := pathRefFromCopyDestination(&req)
+ if err != nil {
+ writeDriveError(w, r, err)
+ return
+ }
+ if usesAlternateRoot(srcRef) || usesAlternateRoot(destRef) {
+ if err := h.svc.CopyAtRoot(r.Context(), ncUser, srcRef, destRef); err != nil {
+ h.logger.Error("copy", "error", err)
+ writeDriveError(w, r, err)
+ return
+ }
+ } else if err := h.svc.Copy(r.Context(), ncUser, req.Source, req.Destination); err != nil {
h.logger.Error("copy", "error", err)
writeDriveError(w, r, err)
return
@@ -404,7 +438,18 @@ func (h *Handler) Rename(w http.ResponseWriter, r *http.Request) {
return
}
- if err := h.svc.Rename(r.Context(), ncUser, req.Path, req.NewName); err != nil {
+ ref, err := pathRefFromRename(&req)
+ if err != nil {
+ writeDriveError(w, r, err)
+ return
+ }
+ if usesAlternateRoot(ref) {
+ if err := h.svc.RenameAtRoot(r.Context(), ncUser, ref, req.NewName); err != nil {
+ h.logger.Error("rename", "error", err)
+ writeDriveError(w, r, err)
+ return
+ }
+ } else if err := h.svc.Rename(r.Context(), ncUser, req.Path, req.NewName); err != nil {
h.logger.Error("rename", "error", err)
writeDriveError(w, r, err)
return
@@ -513,6 +558,11 @@ func (h *Handler) CreateShare(w http.ResponseWriter, r *http.Request) {
apivalidate.WriteValidationError(w, r, verr)
return
}
+ shareRef, err := pathRefFromShare(&req)
+ if err != nil {
+ writeDriveError(w, r, err)
+ return
+ }
permissions := req.Permissions
if permissions == 0 && strings.TrimSpace(req.Role) != "" {
@@ -521,7 +571,7 @@ func (h *Handler) CreateShare(w http.ResponseWriter, r *http.Request) {
}
}
- share, err := h.svc.CreateShare(r.Context(), ncUser, req.Path, req, permissions)
+ share, err := h.svc.CreateShareAtRoot(r.Context(), ncUser, shareRef, req, permissions)
if err != nil {
h.logger.Error("create share", "error", err)
writeDriveError(w, r, err)
@@ -583,7 +633,14 @@ func (h *Handler) ListShares(w http.ResponseWriter, r *http.Request) {
))
return
}
- shares, err := h.svc.ListShares(r.Context(), ncUser, filePath)
+ rootKind := r.URL.Query().Get("root")
+ rootID := r.URL.Query().Get("root_id")
+ ref, err := pathRefFromParts(rootKind, rootID, filePath)
+ if err != nil {
+ writeDriveError(w, r, err)
+ return
+ }
+ shares, err := h.svc.ListSharesAtRoot(r.Context(), ncUser, ref)
if err != nil {
writeDriveError(w, r, err)
return
@@ -749,7 +806,12 @@ func (h *Handler) SetFavorite(w http.ResponseWriter, r *http.Request) {
apivalidate.WriteValidationError(w, r, verr)
return
}
- if err := h.svc.SetFavorite(r.Context(), ncUser, req.Path, req.Favorite); err != nil {
+ favRef, err := pathRefFromFavorite(&req)
+ if err != nil {
+ writeDriveError(w, r, err)
+ return
+ }
+ if err := h.svc.SetFavoriteAtRoot(r.Context(), ncUser, favRef, req.Favorite); err != nil {
writeDriveError(w, r, err)
return
}
@@ -798,6 +860,8 @@ func writeDriveError(w http.ResponseWriter, r *http.Request, err error) {
apiresponse.WriteError(w, r, http.StatusUnprocessableEntity, "drive.malware_detected", "malware detected in file", nil)
case errors.Is(err, ErrInvalid):
apiresponse.WriteError(w, r, http.StatusBadRequest, apiresponse.CodeInvalidRequest, "invalid request body", nil)
+ case errors.Is(err, ErrOAuthNotConfigured):
+ apiresponse.WriteError(w, r, http.StatusBadRequest, "drive.oauth_not_configured", "cloud storage OAuth is not configured for this organization", nil)
default:
apivalidate.WriteInternal(w, r)
}
diff --git a/internal/api/drive/mounts_service.go b/internal/api/drive/mounts_service.go
new file mode 100644
index 0000000..ab6d88c
--- /dev/null
+++ b/internal/api/drive/mounts_service.go
@@ -0,0 +1,298 @@
+package drive
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "strings"
+
+ "github.com/ultisuite/ulti-backend/internal/drivestore"
+ "github.com/ultisuite/ulti-backend/internal/nextcloud"
+ "github.com/ultisuite/ulti-backend/internal/orgpolicy"
+)
+
+var ErrOAuthNotConfigured = errors.New("oauth provider not configured")
+
+type MountView struct {
+ ID string `json:"id"`
+ Scope string `json:"scope"`
+ OrgSlug *string `json:"org_slug,omitempty"`
+ DisplayName string `json:"display_name"`
+ BackendType string `json:"backend_type"`
+ MountPoint string `json:"mount_point"`
+ Status string `json:"status"`
+ LastError string `json:"last_error,omitempty"`
+ NCMountID *int `json:"nc_mount_id,omitempty"`
+ NeedsOAuth bool `json:"needs_oauth,omitempty"`
+}
+
+type CreateMountParams struct {
+ Scope string
+ OrgSlug string
+ DisplayName string
+ BackendType string
+ WebDAV *nextcloud.WebDAVMountConfig
+ OAuthBackend string
+ OAuthAuth string
+}
+
+func oauthProviderForBackend(backendType string) (providerKey, ncBackend, authBackend string, isOAuth bool) {
+ switch strings.TrimSpace(strings.ToLower(backendType)) {
+ case "googledrive", "google":
+ return orgpolicy.MountOAuthProviderGoogle, "googledrive", "oauth2::google", true
+ case "dropbox":
+ return orgpolicy.MountOAuthProviderDropbox, "dropbox", "oauth2::dropbox", true
+ case "onedrive", "microsoft":
+ return orgpolicy.MountOAuthProviderMicrosoft, "onedrive", "oauth2::microsoft", true
+ default:
+ return "", "", "", false
+ }
+}
+
+func (s *Service) orgPolicyLoader() *orgpolicy.Loader {
+ return orgpolicy.NewLoader(s.db, nil)
+}
+
+func (s *Service) ListMountsForUser(ctx context.Context, platformUserID, ncUserID string, orgSlugs []string) ([]MountView, error) {
+ store := s.ensureStore()
+ if store == nil {
+ return nil, fmt.Errorf("store not configured")
+ }
+ mounts, err := store.ListMountsForUser(ctx, platformUserID, orgSlugs)
+ if err != nil {
+ return nil, err
+ }
+ out := make([]MountView, 0, len(mounts))
+ for _, m := range mounts {
+ out = append(out, mapMountView(m))
+ }
+ return out, nil
+}
+
+func mapMountView(m drivestore.Mount) MountView {
+ view := MountView{
+ ID: m.ID,
+ Scope: m.Scope,
+ OrgSlug: m.OrgSlug,
+ DisplayName: m.DisplayName,
+ BackendType: m.BackendType,
+ MountPoint: m.MountPoint,
+ Status: m.Status,
+ LastError: m.LastError,
+ NCMountID: m.NCMountID,
+ }
+ if m.Status == "pending_oauth" {
+ view.NeedsOAuth = true
+ }
+ return view
+}
+
+func (s *Service) CreateMount(ctx context.Context, platformUserID, ncUserID string, p CreateMountParams) (MountView, error) {
+ store := s.ensureStore()
+ if store == nil {
+ return MountView{}, fmt.Errorf("store not configured")
+ }
+ displayName := strings.TrimSpace(p.DisplayName)
+ backendType := strings.TrimSpace(strings.ToLower(p.BackendType))
+ if displayName == "" || backendType == "" {
+ return MountView{}, ErrInvalid
+ }
+ mountPoint := "/" + strings.Trim(displayName, "/")
+ scope := strings.TrimSpace(strings.ToLower(p.Scope))
+ if scope != "user" && scope != "org" {
+ return MountView{}, ErrInvalid
+ }
+
+ var ncMountID int
+ var err error
+ var configEnc []byte
+ status := "active"
+
+ providerKey, ncBackend, authBackend, isOAuth := oauthProviderForBackend(backendType)
+ if isOAuth {
+ creds, err := s.orgPolicyLoader().MountOAuthCredentials(ctx, providerKey)
+ if err != nil {
+ return MountView{}, err
+ }
+ if !creds.Enabled {
+ return MountView{}, ErrOAuthNotConfigured
+ }
+ oauthConfig := map[string]string{
+ "client_id": creds.ClientID,
+ "client_secret": creds.ClientSecret,
+ "configured": "false",
+ "token": "",
+ }
+ configEnc, _ = json.Marshal(oauthConfig)
+ if scope == "org" {
+ return MountView{}, ErrInvalid
+ }
+ ncMountID, err = s.nc.CreateOAuthExternalMount(ctx, ncUserID, mountPoint, ncBackend, authBackend, oauthConfig)
+ status = "pending_oauth"
+ } else {
+ switch backendType {
+ case "webdav", "dav":
+ if p.WebDAV == nil {
+ return MountView{}, ErrInvalid
+ }
+ configEnc, _ = json.Marshal(p.WebDAV)
+ if scope == "org" {
+ ncMountID, err = s.nc.CreateGlobalWebDAVMount(ctx, mountPoint, *p.WebDAV)
+ } else {
+ ncMountID, err = s.nc.CreateUserWebDAVMount(ctx, ncUserID, mountPoint, *p.WebDAV)
+ }
+ case "googledrive", "google", "dropbox", "onedrive", "microsoft":
+ return MountView{}, ErrOAuthNotConfigured
+ default:
+ if p.OAuthBackend != "" {
+ auth := p.OAuthAuth
+ if auth == "" {
+ auth = "oauth2::" + backendType
+ }
+ ncMountID, err = s.nc.CreateOAuthExternalMount(ctx, ncUserID, mountPoint, p.OAuthBackend, auth, nil)
+ status = "pending_oauth"
+ } else {
+ return MountView{}, ErrInvalid
+ }
+ }
+ }
+
+ lastError := ""
+ if err != nil {
+ status = "error"
+ lastError = err.Error()
+ }
+
+ var ownerID *string
+ var orgSlug *string
+ if scope == "user" {
+ ownerID = &platformUserID
+ } else {
+ slug := strings.TrimSpace(strings.ToLower(p.OrgSlug))
+ if slug == "" {
+ return MountView{}, ErrInvalid
+ }
+ orgSlug = &slug
+ }
+
+ var ncIDPtr *int
+ if ncMountID > 0 {
+ ncIDPtr = &ncMountID
+ }
+ row, err := store.CreateMount(ctx, drivestore.CreateMountParams{
+ Scope: scope,
+ OwnerUserID: ownerID,
+ OrgSlug: orgSlug,
+ NCMountID: ncIDPtr,
+ DisplayName: displayName,
+ BackendType: backendType,
+ MountPoint: mountPoint,
+ Status: status,
+ ConfigEnc: configEnc,
+ })
+ if err != nil {
+ if ncMountID > 0 {
+ _ = s.nc.DeleteExternalMount(ctx, ncMountID)
+ }
+ return MountView{}, err
+ }
+ if status == "error" {
+ _ = store.UpdateMountStatus(ctx, row.ID, status, lastError, ncIDPtr)
+ }
+ return mapMountView(row), nil
+}
+
+func (s *Service) DeleteMount(ctx context.Context, mountID string) error {
+ store := s.ensureStore()
+ if store == nil {
+ return fmt.Errorf("store not configured")
+ }
+ mount, err := store.GetMount(ctx, mountID)
+ if err != nil {
+ return err
+ }
+ if mount.NCMountID != nil && *mount.NCMountID > 0 {
+ if err := s.nc.DeleteExternalMount(ctx, *mount.NCMountID); err != nil {
+ return mapDriveError(err)
+ }
+ }
+ return store.DeleteMount(ctx, mountID)
+}
+
+func (s *Service) GetMountOAuthURL(ctx context.Context, mountID, platformUserID, ncUserID, redirectURI string) (string, error) {
+ if err := validateMountOAuthRedirectURI(redirectURI); err != nil {
+ return "", err
+ }
+ store := s.ensureStore()
+ if store == nil {
+ return "", fmt.Errorf("store not configured")
+ }
+ mount, err := store.GetMount(ctx, mountID)
+ if err != nil {
+ return "", err
+ }
+ if mount.OwnerUserID == nil || *mount.OwnerUserID != platformUserID {
+ return "", ErrForbidden
+ }
+ if mount.NCMountID == nil {
+ return "", ErrInvalid
+ }
+ providerKey, _, _, isOAuth := oauthProviderForBackend(mount.BackendType)
+ if !isOAuth {
+ return "", ErrInvalid
+ }
+ creds, err := s.orgPolicyLoader().MountOAuthCredentials(ctx, providerKey)
+ if err != nil {
+ return "", err
+ }
+ if !creds.Enabled {
+ return "", ErrOAuthNotConfigured
+ }
+ return s.nc.StartExternalStorageOAuth2(ctx, ncUserID, creds.ClientID, creds.ClientSecret, redirectURI)
+}
+
+func (s *Service) CompleteMountOAuth(ctx context.Context, mountID, platformUserID, ncUserID, redirectURI, code string) error {
+ if err := validateMountOAuthRedirectURI(redirectURI); err != nil {
+ return err
+ }
+ store := s.ensureStore()
+ if store == nil {
+ return fmt.Errorf("store not configured")
+ }
+ code = strings.TrimSpace(code)
+ if code == "" {
+ return ErrInvalid
+ }
+ mount, err := store.GetMount(ctx, mountID)
+ if err != nil {
+ return err
+ }
+ if mount.OwnerUserID == nil || *mount.OwnerUserID != platformUserID {
+ return ErrForbidden
+ }
+ if mount.NCMountID == nil {
+ return ErrInvalid
+ }
+ providerKey, _, _, isOAuth := oauthProviderForBackend(mount.BackendType)
+ if !isOAuth {
+ return ErrInvalid
+ }
+ creds, err := s.orgPolicyLoader().MountOAuthCredentials(ctx, providerKey)
+ if err != nil {
+ return err
+ }
+ if !creds.Enabled {
+ return ErrOAuthNotConfigured
+ }
+ token, err := s.nc.CompleteExternalStorageOAuth2(ctx, ncUserID, creds.ClientID, creds.ClientSecret, redirectURI, code)
+ if err != nil {
+ _ = store.UpdateMountStatus(ctx, mount.ID, "error", err.Error(), mount.NCMountID)
+ return err
+ }
+ if err := s.nc.UpdateUserExternalMountOAuth(ctx, ncUserID, *mount.NCMountID, creds.ClientID, creds.ClientSecret, token); err != nil {
+ _ = store.UpdateMountStatus(ctx, mount.ID, "error", err.Error(), mount.NCMountID)
+ return err
+ }
+ return store.UpdateMountStatus(ctx, mount.ID, "active", "", mount.NCMountID)
+}
diff --git a/internal/api/drive/org_folders_service.go b/internal/api/drive/org_folders_service.go
new file mode 100644
index 0000000..99310d4
--- /dev/null
+++ b/internal/api/drive/org_folders_service.go
@@ -0,0 +1,256 @@
+package drive
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "strings"
+
+ "github.com/ultisuite/ulti-backend/internal/drivestore"
+ "github.com/ultisuite/ulti-backend/internal/nextcloud"
+)
+
+type OrgFolderView struct {
+ ID string `json:"id"`
+ OrgSlug string `json:"org_slug"`
+ MountPoint string `json:"mount_point"`
+ NCFolderID int `json:"nc_folder_id"`
+ QuotaBytes *int64 `json:"quota_bytes,omitempty"`
+ AutoProvisioned bool `json:"auto_provisioned"`
+ Permissions int `json:"permissions,omitempty"`
+ Size int64 `json:"size,omitempty"`
+}
+
+func (s *Service) ListOrgFoldersForUser(ctx context.Context, ncUserID string) ([]OrgFolderView, error) {
+ store := s.ensureStore()
+ if store == nil {
+ return nil, fmt.Errorf("store not configured")
+ }
+ rows, err := store.ListOrgFolders(ctx)
+ if err != nil {
+ return nil, err
+ }
+ ncFolders, err := s.nc.ListGroupFolders(ctx, ncUserID)
+ if err != nil {
+ return nil, mapDriveError(err)
+ }
+ accessible := make(map[int]nextcloud.GroupFolder, len(ncFolders))
+ for _, f := range ncFolders {
+ accessible[f.ID] = f
+ }
+ out := make([]OrgFolderView, 0, len(rows))
+ for _, row := range rows {
+ ncFolder, ok := accessible[row.NCFolderID]
+ if !ok {
+ continue
+ }
+ perms := 0
+ for _, p := range ncFolder.Groups {
+ if p > perms {
+ perms = p
+ }
+ }
+ out = append(out, OrgFolderView{
+ ID: row.ID,
+ OrgSlug: row.OrgSlug,
+ MountPoint: row.MountPoint,
+ NCFolderID: row.NCFolderID,
+ QuotaBytes: row.QuotaBytes,
+ AutoProvisioned: row.AutoProvisioned,
+ Permissions: perms,
+ Size: ncFolder.Size,
+ })
+ }
+ return out, nil
+}
+
+func (s *Service) ListOrgFoldersAdmin(ctx context.Context) ([]OrgFolderView, error) {
+ store := s.ensureStore()
+ if store == nil {
+ return nil, fmt.Errorf("store not configured")
+ }
+ rows, err := store.ListOrgFolders(ctx)
+ if err != nil {
+ return nil, err
+ }
+ out := make([]OrgFolderView, 0, len(rows))
+ for _, row := range rows {
+ out = append(out, OrgFolderView{
+ ID: row.ID,
+ OrgSlug: row.OrgSlug,
+ MountPoint: row.MountPoint,
+ NCFolderID: row.NCFolderID,
+ QuotaBytes: row.QuotaBytes,
+ AutoProvisioned: row.AutoProvisioned,
+ })
+ }
+ return out, nil
+}
+
+type CreateOrgFolderParams struct {
+ OrgSlug string
+ MountPoint string
+ QuotaBytes *int64
+ AutoProvisioned bool
+ CreatedBy string
+}
+
+func (s *Service) CreateOrgFolder(ctx context.Context, p CreateOrgFolderParams) (OrgFolderView, error) {
+ store := s.ensureStore()
+ if store == nil {
+ return OrgFolderView{}, fmt.Errorf("store not configured")
+ }
+ orgSlug := strings.TrimSpace(strings.ToLower(p.OrgSlug))
+ mountPoint := strings.TrimSpace(p.MountPoint)
+ if orgSlug == "" || mountPoint == "" {
+ return OrgFolderView{}, ErrInvalid
+ }
+ if _, err := store.GetOrgFolderBySlug(ctx, orgSlug); err == nil {
+ return OrgFolderView{}, ErrConflict
+ } else if !errors.Is(err, drivestore.ErrOrgFolderNotFound) {
+ return OrgFolderView{}, err
+ }
+
+ groupID := nextcloud.OrgGroupID(orgSlug)
+ if err := s.nc.EnsureGroup(ctx, groupID); err != nil {
+ return OrgFolderView{}, mapDriveError(err)
+ }
+ ncFolderID, err := s.nc.CreateGroupFolder(ctx, mountPoint)
+ if err != nil {
+ return OrgFolderView{}, mapDriveError(err)
+ }
+ if err := s.nc.AssignGroupToFolder(ctx, ncFolderID, groupID, 31); err != nil {
+ _ = s.nc.DeleteGroupFolder(ctx, ncFolderID)
+ return OrgFolderView{}, mapDriveError(err)
+ }
+ if p.QuotaBytes != nil {
+ if err := s.nc.SetGroupFolderQuota(ctx, ncFolderID, *p.QuotaBytes); err != nil {
+ return OrgFolderView{}, mapDriveError(err)
+ }
+ }
+ row, err := store.CreateOrgFolder(ctx, drivestore.CreateOrgFolderParams{
+ OrgSlug: orgSlug,
+ NCFolderID: ncFolderID,
+ MountPoint: mountPoint,
+ QuotaBytes: p.QuotaBytes,
+ AutoProvisioned: p.AutoProvisioned,
+ CreatedBy: p.CreatedBy,
+ })
+ if err != nil {
+ _ = s.nc.DeleteGroupFolder(ctx, ncFolderID)
+ return OrgFolderView{}, err
+ }
+ return OrgFolderView{
+ ID: row.ID,
+ OrgSlug: row.OrgSlug,
+ MountPoint: row.MountPoint,
+ NCFolderID: row.NCFolderID,
+ QuotaBytes: row.QuotaBytes,
+ }, nil
+}
+
+func (s *Service) ProvisionOrgFolder(ctx context.Context, orgSlug, createdBy string) (OrgFolderView, error) {
+ orgSlug = strings.TrimSpace(strings.ToLower(orgSlug))
+ if orgSlug == "" {
+ return OrgFolderView{}, ErrInvalid
+ }
+ store := s.ensureStore()
+ if store == nil {
+ return OrgFolderView{}, fmt.Errorf("store not configured")
+ }
+ if existing, err := store.GetOrgFolderBySlug(ctx, orgSlug); err == nil {
+ return OrgFolderView{
+ ID: existing.ID,
+ OrgSlug: existing.OrgSlug,
+ MountPoint: existing.MountPoint,
+ NCFolderID: existing.NCFolderID,
+ QuotaBytes: existing.QuotaBytes,
+ AutoProvisioned: existing.AutoProvisioned,
+ }, nil
+ } else if !errors.Is(err, drivestore.ErrOrgFolderNotFound) {
+ return OrgFolderView{}, err
+ }
+ mountPoint := orgSlug
+ if mountPoint == "" {
+ mountPoint = "org"
+ }
+ return s.createOrgFolderInternal(ctx, orgSlug, mountPoint, nil, true, createdBy)
+}
+
+func (s *Service) createOrgFolderInternal(ctx context.Context, orgSlug, mountPoint string, quota *int64, auto bool, createdBy string) (OrgFolderView, error) {
+ view, err := s.CreateOrgFolder(ctx, CreateOrgFolderParams{
+ OrgSlug: orgSlug,
+ MountPoint: mountPoint,
+ QuotaBytes: quota,
+ AutoProvisioned: auto,
+ CreatedBy: createdBy,
+ })
+ if err != nil {
+ return OrgFolderView{}, err
+ }
+ view.AutoProvisioned = auto
+ return view, nil
+}
+
+func (s *Service) UpdateOrgFolder(ctx context.Context, id, mountPoint string, quotaBytes *int64) (OrgFolderView, error) {
+ store := s.ensureStore()
+ if store == nil {
+ return OrgFolderView{}, fmt.Errorf("store not configured")
+ }
+ row, err := store.GetOrgFolder(ctx, id)
+ if err != nil {
+ return OrgFolderView{}, err
+ }
+ if mountPoint != "" && mountPoint != row.MountPoint {
+ if err := s.nc.RenameGroupFolder(ctx, row.NCFolderID, mountPoint); err != nil {
+ return OrgFolderView{}, mapDriveError(err)
+ }
+ }
+ if quotaBytes != nil {
+ if err := s.nc.SetGroupFolderQuota(ctx, row.NCFolderID, *quotaBytes); err != nil {
+ return OrgFolderView{}, mapDriveError(err)
+ }
+ }
+ updated, err := store.UpdateOrgFolder(ctx, id, mountPoint, quotaBytes)
+ if err != nil {
+ return OrgFolderView{}, err
+ }
+ return OrgFolderView{
+ ID: updated.ID,
+ OrgSlug: updated.OrgSlug,
+ MountPoint: updated.MountPoint,
+ NCFolderID: updated.NCFolderID,
+ QuotaBytes: updated.QuotaBytes,
+ }, nil
+}
+
+func (s *Service) DeleteOrgFolder(ctx context.Context, id string) error {
+ store := s.ensureStore()
+ if store == nil {
+ return fmt.Errorf("store not configured")
+ }
+ row, err := store.GetOrgFolder(ctx, id)
+ if err != nil {
+ return err
+ }
+ if err := s.nc.DeleteGroupFolder(ctx, row.NCFolderID); err != nil {
+ return mapDriveError(err)
+ }
+ return store.DeleteOrgFolder(ctx, id)
+}
+
+func (s *Service) SyncOrgFolders(ctx context.Context, orgSlugs []string, createdBy string) ([]OrgFolderView, error) {
+ out := make([]OrgFolderView, 0, len(orgSlugs))
+ for _, slug := range orgSlugs {
+ slug = strings.TrimSpace(strings.ToLower(slug))
+ if slug == "" {
+ continue
+ }
+ view, err := s.ProvisionOrgFolder(ctx, slug, createdBy)
+ if err != nil {
+ return nil, err
+ }
+ out = append(out, view)
+ }
+ return out, nil
+}
diff --git a/internal/api/drive/org_mount_handlers.go b/internal/api/drive/org_mount_handlers.go
new file mode 100644
index 0000000..f613eed
--- /dev/null
+++ b/internal/api/drive/org_mount_handlers.go
@@ -0,0 +1,382 @@
+package drive
+
+import (
+ "io"
+ "net/http"
+ "strconv"
+ "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/api/query"
+ "github.com/ultisuite/ulti-backend/internal/driveroot"
+ "github.com/ultisuite/ulti-backend/internal/nextcloud"
+)
+
+func (h *Handler) registerOrgAndMountRoutes(r chi.Router, read, write func(http.Handler) http.Handler) {
+ r.With(read).Get("/org-folders", h.ListOrgFolders)
+ r.With(read).Get("/org-folders/{folderID}/files/*", h.ListOrgFolderFiles)
+ r.With(read).Get("/org-folders/{folderID}/files/info/*", h.GetOrgFolderFileInfo)
+ r.With(read).Get("/org-folders/{folderID}/download/*", h.DownloadOrgFolderFile)
+ r.With(read).Get("/org-folders/{folderID}/preview/*", h.PreviewOrgFolderFile)
+ r.With(write).Post("/org-folders/{folderID}/files/*", h.UploadOrgFolderFile)
+ r.With(write).Post("/org-folders/{folderID}/folders/*", h.CreateOrgFolderDir)
+ r.With(write).Delete("/org-folders/{folderID}/files/*", h.DeleteOrgFolderFile)
+
+ r.With(read).Get("/mounts", h.ListMounts)
+ r.With(write).Post("/mounts", h.CreateMount)
+ r.With(write).Delete("/mounts/{mountID}", h.DeleteMount)
+ r.With(read).Get("/mounts/{mountID}/oauth-url", h.GetMountOAuthURL)
+ r.With(write).Post("/mounts/{mountID}/oauth/complete", h.CompleteMountOAuth)
+ r.With(read).Get("/mounts/{mountID}/files/*", h.ListMountFiles)
+ r.With(read).Get("/mounts/{mountID}/files/info/*", h.GetMountFileInfo)
+ r.With(read).Get("/mounts/{mountID}/download/*", h.DownloadMountFile)
+ r.With(read).Get("/mounts/{mountID}/preview/*", h.PreviewMountFile)
+ r.With(write).Post("/mounts/{mountID}/files/*", h.UploadMountFile)
+ r.With(write).Post("/mounts/{mountID}/folders/*", h.CreateMountDir)
+ r.With(write).Delete("/mounts/{mountID}/files/*", h.DeleteMountFile)
+}
+
+func (h *Handler) ListOrgFolders(w http.ResponseWriter, r *http.Request) {
+ claims := middleware.ClaimsFromContext(r.Context())
+ ncUser, ok := h.nextcloudUser(w, r, claims)
+ if !ok {
+ return
+ }
+ folders, err := h.svc.ListOrgFoldersForUser(r.Context(), ncUser)
+ if err != nil {
+ h.logger.Error("list org folders", "error", err)
+ writeDriveError(w, r, err)
+ return
+ }
+ apiresponse.WriteJSON(w, http.StatusOK, map[string]any{"folders": folders})
+}
+
+func (h *Handler) ListOrgFolderFiles(w http.ResponseWriter, r *http.Request) {
+ h.listRootFiles(w, r, driveroot.KindOrg, chi.URLParam(r, "folderID"))
+}
+
+func (h *Handler) ListMountFiles(w http.ResponseWriter, r *http.Request) {
+ h.listRootFiles(w, r, driveroot.KindMount, chi.URLParam(r, "mountID"))
+}
+
+func (h *Handler) listRootFiles(w http.ResponseWriter, r *http.Request, kind driveroot.Kind, rootID string) {
+ claims := middleware.ClaimsFromContext(r.Context())
+ ncUser, ok := h.nextcloudUser(w, r, claims)
+ if !ok {
+ return
+ }
+ params, err := query.ParseListRequest(r)
+ if err != nil {
+ apivalidate.WriteQueryError(w, r, err)
+ return
+ }
+ path := nextcloud.NormalizeClientPath(chi.URLParam(r, "*"))
+ ref := driveroot.Ref{Kind: kind, RootID: rootID, Path: path}
+ result, err := h.svc.ListFilesAtRoot(r.Context(), ncUser, ref, params)
+ if err != nil {
+ writeDriveError(w, r, err)
+ return
+ }
+ h.svc.EnrichSources(r.Context(), claims.Sub, result.Files)
+ apiresponse.WriteJSON(w, http.StatusOK, result)
+}
+
+func (h *Handler) GetOrgFolderFileInfo(w http.ResponseWriter, r *http.Request) {
+ h.getRootFileInfo(w, r, driveroot.KindOrg, chi.URLParam(r, "folderID"))
+}
+
+func (h *Handler) GetMountFileInfo(w http.ResponseWriter, r *http.Request) {
+ h.getRootFileInfo(w, r, driveroot.KindMount, chi.URLParam(r, "mountID"))
+}
+
+func (h *Handler) getRootFileInfo(w http.ResponseWriter, r *http.Request, kind driveroot.Kind, rootID string) {
+ claims := middleware.ClaimsFromContext(r.Context())
+ ncUser, ok := h.nextcloudUser(w, r, claims)
+ if !ok {
+ return
+ }
+ path := nextcloud.NormalizeClientPath(chi.URLParam(r, "*"))
+ ref := driveroot.Ref{Kind: kind, RootID: rootID, Path: path}
+ file, err := h.svc.StatFileAtRoot(r.Context(), ncUser, ref)
+ if err != nil {
+ writeDriveError(w, r, err)
+ return
+ }
+ h.svc.EnrichSources(r.Context(), claims.Sub, []nextcloud.FileInfo{file})
+ apiresponse.WriteJSON(w, http.StatusOK, file)
+}
+
+func (h *Handler) DownloadOrgFolderFile(w http.ResponseWriter, r *http.Request) {
+ h.downloadRootFile(w, r, driveroot.KindOrg, chi.URLParam(r, "folderID"))
+}
+
+func (h *Handler) DownloadMountFile(w http.ResponseWriter, r *http.Request) {
+ h.downloadRootFile(w, r, driveroot.KindMount, chi.URLParam(r, "mountID"))
+}
+
+func (h *Handler) downloadRootFile(w http.ResponseWriter, r *http.Request, kind driveroot.Kind, rootID string) {
+ claims := middleware.ClaimsFromContext(r.Context())
+ ncUser, ok := h.nextcloudUser(w, r, claims)
+ if !ok {
+ return
+ }
+ path := chi.URLParam(r, "*")
+ if verr := validatePath(path); verr != nil {
+ apivalidate.WriteValidationError(w, r, verr)
+ return
+ }
+ ref := driveroot.Ref{Kind: kind, RootID: rootID, Path: nextcloud.NormalizeClientPath(path)}
+ body, contentType, err := h.svc.DownloadAtRoot(r.Context(), ncUser, ref)
+ if err != nil {
+ writeDriveError(w, r, err)
+ return
+ }
+ defer body.Close()
+ w.Header().Set("Content-Type", contentType)
+ io.Copy(w, body)
+}
+
+func (h *Handler) PreviewOrgFolderFile(w http.ResponseWriter, r *http.Request) {
+ h.previewRootFile(w, r, driveroot.KindOrg, chi.URLParam(r, "folderID"))
+}
+
+func (h *Handler) PreviewMountFile(w http.ResponseWriter, r *http.Request) {
+ h.previewRootFile(w, r, driveroot.KindMount, chi.URLParam(r, "mountID"))
+}
+
+func (h *Handler) previewRootFile(w http.ResponseWriter, r *http.Request, kind driveroot.Kind, rootID string) {
+ claims := middleware.ClaimsFromContext(r.Context())
+ ncUser, ok := h.nextcloudUser(w, r, claims)
+ if !ok {
+ return
+ }
+ path := chi.URLParam(r, "*")
+ ref := driveroot.Ref{Kind: kind, RootID: rootID, Path: nextcloud.NormalizeClientPath(path)}
+ file, err := h.svc.StatFileAtRoot(r.Context(), ncUser, ref)
+ if err != nil {
+ writeDriveError(w, r, err)
+ return
+ }
+ width, _ := strconv.Atoi(r.URL.Query().Get("w"))
+ height, _ := strconv.Atoi(r.URL.Query().Get("h"))
+ _ = width
+ _ = height
+ var body io.ReadCloser
+ var contentType string
+ if kind == driveroot.KindPersonal {
+ body, contentType, err = h.svc.Preview(r.Context(), ncUser, file.Path, width, height)
+ } else {
+ body, contentType, err = h.svc.DownloadAtRoot(r.Context(), ncUser, ref)
+ }
+ if err != nil {
+ writeDriveError(w, r, err)
+ return
+ }
+ defer body.Close()
+ w.Header().Set("Content-Type", contentType)
+ w.Header().Set("Cache-Control", "private, max-age=300")
+ io.Copy(w, body)
+}
+
+func (h *Handler) UploadOrgFolderFile(w http.ResponseWriter, r *http.Request) {
+ h.uploadRootFile(w, r, driveroot.KindOrg, chi.URLParam(r, "folderID"))
+}
+
+func (h *Handler) UploadMountFile(w http.ResponseWriter, r *http.Request) {
+ h.uploadRootFile(w, r, driveroot.KindMount, chi.URLParam(r, "mountID"))
+}
+
+func (h *Handler) uploadRootFile(w http.ResponseWriter, r *http.Request, kind driveroot.Kind, rootID string) {
+ claims := middleware.ClaimsFromContext(r.Context())
+ ncUser, ok := h.nextcloudUser(w, r, claims)
+ if !ok {
+ return
+ }
+ path := chi.URLParam(r, "*")
+ if verr := validatePath(path); verr != nil {
+ apivalidate.WriteValidationError(w, r, verr)
+ return
+ }
+ ref := driveroot.Ref{Kind: kind, RootID: rootID, Path: nextcloud.NormalizeClientPath(path)}
+ if err := h.svc.UploadAtRoot(r.Context(), ncUser, ref, r.Body, r.Header.Get("Content-Type"), r.ContentLength); err != nil {
+ writeDriveError(w, r, err)
+ return
+ }
+ apiresponse.WriteJSON(w, http.StatusCreated, map[string]string{"status": "uploaded", "path": path})
+}
+
+func (h *Handler) CreateOrgFolderDir(w http.ResponseWriter, r *http.Request) {
+ h.createRootDir(w, r, driveroot.KindOrg, chi.URLParam(r, "folderID"))
+}
+
+func (h *Handler) CreateMountDir(w http.ResponseWriter, r *http.Request) {
+ h.createRootDir(w, r, driveroot.KindMount, chi.URLParam(r, "mountID"))
+}
+
+func (h *Handler) createRootDir(w http.ResponseWriter, r *http.Request, kind driveroot.Kind, rootID string) {
+ claims := middleware.ClaimsFromContext(r.Context())
+ ncUser, ok := h.nextcloudUser(w, r, claims)
+ if !ok {
+ return
+ }
+ path := chi.URLParam(r, "*")
+ if verr := validatePath(path); verr != nil {
+ apivalidate.WriteValidationError(w, r, verr)
+ return
+ }
+ ref := driveroot.Ref{Kind: kind, RootID: rootID, Path: nextcloud.NormalizeClientPath(path)}
+ if err := h.svc.CreateFolderAtRoot(r.Context(), ncUser, ref); err != nil {
+ writeDriveError(w, r, err)
+ return
+ }
+ w.WriteHeader(http.StatusCreated)
+}
+
+func (h *Handler) DeleteOrgFolderFile(w http.ResponseWriter, r *http.Request) {
+ h.deleteRootFile(w, r, driveroot.KindOrg, chi.URLParam(r, "folderID"))
+}
+
+func (h *Handler) DeleteMountFile(w http.ResponseWriter, r *http.Request) {
+ h.deleteRootFile(w, r, driveroot.KindMount, chi.URLParam(r, "mountID"))
+}
+
+func (h *Handler) deleteRootFile(w http.ResponseWriter, r *http.Request, kind driveroot.Kind, rootID string) {
+ claims := middleware.ClaimsFromContext(r.Context())
+ ncUser, ok := h.nextcloudUser(w, r, claims)
+ if !ok {
+ return
+ }
+ path := chi.URLParam(r, "*")
+ ref := driveroot.Ref{Kind: kind, RootID: rootID, Path: nextcloud.NormalizeClientPath(path)}
+ if err := h.svc.DeleteAtRoot(r.Context(), ncUser, ref); err != nil {
+ writeDriveError(w, r, err)
+ return
+ }
+ w.WriteHeader(http.StatusNoContent)
+}
+
+func (h *Handler) ListMounts(w http.ResponseWriter, r *http.Request) {
+ claims := middleware.ClaimsFromContext(r.Context())
+ ncUser, ok := h.nextcloudUser(w, r, claims)
+ if !ok {
+ return
+ }
+ platformUserID, err := h.svc.platformUserID(r.Context(), claims.Sub)
+ if err != nil {
+ apivalidate.WriteInternal(w, r)
+ return
+ }
+ orgSlugs := parseOrgSlugs(r.URL.Query().Get("org_slugs"))
+ mounts, err := h.svc.ListMountsForUser(r.Context(), platformUserID, ncUser, orgSlugs)
+ if err != nil {
+ writeDriveError(w, r, err)
+ return
+ }
+ apiresponse.WriteJSON(w, http.StatusOK, map[string]any{"mounts": mounts})
+}
+
+func (h *Handler) CreateMount(w http.ResponseWriter, r *http.Request) {
+ claims := middleware.ClaimsFromContext(r.Context())
+ ncUser, ok := h.nextcloudUser(w, r, claims)
+ if !ok {
+ return
+ }
+ platformUserID, err := h.svc.platformUserID(r.Context(), claims.Sub)
+ if err != nil {
+ apivalidate.WriteInternal(w, r)
+ return
+ }
+ var req createMountRequest
+ if err := apivalidate.DecodeJSON(w, r, maxJSONRequestBody, &req); err != nil {
+ return
+ }
+ mount, err := h.svc.CreateMount(r.Context(), platformUserID, ncUser, CreateMountParams{
+ Scope: req.Scope,
+ OrgSlug: req.OrgSlug,
+ DisplayName: req.DisplayName,
+ BackendType: req.BackendType,
+ WebDAV: req.WebDAV,
+ OAuthBackend: req.OAuthBackend,
+ OAuthAuth: req.OAuthAuth,
+ })
+ if err != nil {
+ writeDriveError(w, r, err)
+ return
+ }
+ apiresponse.WriteJSON(w, http.StatusCreated, mount)
+}
+
+func (h *Handler) DeleteMount(w http.ResponseWriter, r *http.Request) {
+ mountID := chi.URLParam(r, "mountID")
+ if err := h.svc.DeleteMount(r.Context(), mountID); err != nil {
+ writeDriveError(w, r, err)
+ return
+ }
+ w.WriteHeader(http.StatusNoContent)
+}
+
+func (h *Handler) GetMountOAuthURL(w http.ResponseWriter, r *http.Request) {
+ claims := middleware.ClaimsFromContext(r.Context())
+ ncUser, ok := h.nextcloudUser(w, r, claims)
+ if !ok {
+ return
+ }
+ platformUserID, err := h.svc.platformUserID(r.Context(), claims.Sub)
+ if err != nil {
+ apivalidate.WriteInternal(w, r)
+ return
+ }
+ url, err := h.svc.GetMountOAuthURL(r.Context(), chi.URLParam(r, "mountID"), platformUserID, ncUser, strings.TrimSpace(r.URL.Query().Get("redirect_uri")))
+ if err != nil {
+ writeDriveError(w, r, err)
+ return
+ }
+ apiresponse.WriteJSON(w, http.StatusOK, map[string]string{"oauth_url": url})
+}
+
+type completeMountOAuthRequest struct {
+ Code string `json:"code"`
+ RedirectURI string `json:"redirect_uri"`
+}
+
+func (h *Handler) CompleteMountOAuth(w http.ResponseWriter, r *http.Request) {
+ claims := middleware.ClaimsFromContext(r.Context())
+ ncUser, ok := h.nextcloudUser(w, r, claims)
+ if !ok {
+ return
+ }
+ platformUserID, err := h.svc.platformUserID(r.Context(), claims.Sub)
+ if err != nil {
+ apivalidate.WriteInternal(w, r)
+ return
+ }
+ var req completeMountOAuthRequest
+ if err := apivalidate.DecodeJSON(w, r, maxJSONRequestBody, &req); err != nil {
+ return
+ }
+ if err := h.svc.CompleteMountOAuth(r.Context(), chi.URLParam(r, "mountID"), platformUserID, ncUser, strings.TrimSpace(req.RedirectURI), req.Code); err != nil {
+ writeDriveError(w, r, err)
+ return
+ }
+ apiresponse.WriteJSON(w, http.StatusOK, map[string]string{"status": "active"})
+}
+
+func parseOrgSlugs(raw string) []string {
+ raw = strings.TrimSpace(raw)
+ if raw == "" {
+ return nil
+ }
+ parts := strings.Split(raw, ",")
+ out := make([]string, 0, len(parts))
+ for _, p := range parts {
+ p = strings.TrimSpace(strings.ToLower(p))
+ if p != "" {
+ out = append(out, p)
+ }
+ }
+ return out
+}
diff --git a/internal/api/drive/path_ref.go b/internal/api/drive/path_ref.go
new file mode 100644
index 0000000..7e8876c
--- /dev/null
+++ b/internal/api/drive/path_ref.go
@@ -0,0 +1,45 @@
+package drive
+
+import (
+ "github.com/ultisuite/ulti-backend/internal/driveroot"
+)
+
+func pathRefFromParts(rootKind, rootID, path string) (driveroot.Ref, error) {
+ kind, err := driveroot.ParseKind(rootKind)
+ if err != nil {
+ return driveroot.Ref{}, ErrInvalid
+ }
+ return driveroot.Ref{Kind: kind, RootID: rootID, Path: path}, nil
+}
+
+func pathRefFromMoveSource(req *moveRequest) (driveroot.Ref, error) {
+ return pathRefFromParts(req.SourceRoot, req.SourceRootID, req.Source)
+}
+
+func pathRefFromMoveDestination(req *moveRequest) (driveroot.Ref, error) {
+ return pathRefFromParts(req.DestinationRoot, req.DestinationRootID, req.Destination)
+}
+
+func pathRefFromCopySource(req *copyRequest) (driveroot.Ref, error) {
+ return pathRefFromParts(req.SourceRoot, req.SourceRootID, req.Source)
+}
+
+func pathRefFromCopyDestination(req *copyRequest) (driveroot.Ref, error) {
+ return pathRefFromParts(req.DestinationRoot, req.DestinationRootID, req.Destination)
+}
+
+func pathRefFromRename(req *renameRequest) (driveroot.Ref, error) {
+ return pathRefFromParts(req.Root, req.RootID, req.Path)
+}
+
+func pathRefFromFavorite(req *favoriteRequest) (driveroot.Ref, error) {
+ return pathRefFromParts(req.Root, req.RootID, req.Path)
+}
+
+func pathRefFromShare(req *createShareRequest) (driveroot.Ref, error) {
+ return pathRefFromParts(req.Root, req.RootID, req.Path)
+}
+
+func usesAlternateRoot(ref driveroot.Ref) bool {
+ return ref.Kind != driveroot.KindPersonal && ref.Kind != ""
+}
diff --git a/internal/api/drive/service.go b/internal/api/drive/service.go
index c6bc4b1..179b779 100644
--- a/internal/api/drive/service.go
+++ b/internal/api/drive/service.go
@@ -18,6 +18,7 @@ import (
"github.com/ultisuite/ulti-backend/internal/api/query"
"github.com/ultisuite/ulti-backend/internal/auth"
"github.com/ultisuite/ulti-backend/internal/automation"
+ "github.com/ultisuite/ulti-backend/internal/drivestore"
"github.com/ultisuite/ulti-backend/internal/filescan"
"github.com/ultisuite/ulti-backend/internal/mail/rules"
"github.com/ultisuite/ulti-backend/internal/nextcloud"
@@ -38,6 +39,7 @@ type Service struct {
nc *nextcloud.Client
hub *realtime.Hub
db *pgxpool.Pool
+ store *drivestore.Store
automation driveAutomation
scanner *filescan.Scanner
maxUploadBytes int64
@@ -49,13 +51,17 @@ type driveAutomation interface {
}
func NewService(nc *nextcloud.Client, hub *realtime.Hub, db *pgxpool.Pool) *Service {
- return &Service{
+ s := &Service{
nc: nc,
hub: hub,
db: db,
maxUploadBytes: envInt64("ULTID_DRIVE_MAX_UPLOAD_BYTES", 0),
quotaReserveByte: envInt64("ULTID_DRIVE_QUOTA_RESERVED_BYTES", 0),
}
+ if db != nil {
+ s.store = drivestore.NewStore(db)
+ }
+ return s
}
func (s *Service) EnsureNextcloudUser(ctx context.Context, claims *auth.Claims) (string, error) {
diff --git a/internal/api/drive/service_roots.go b/internal/api/drive/service_roots.go
new file mode 100644
index 0000000..0b81d99
--- /dev/null
+++ b/internal/api/drive/service_roots.go
@@ -0,0 +1,299 @@
+package drive
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "path"
+ "strings"
+
+ "github.com/jackc/pgx/v5/pgxpool"
+
+ "github.com/ultisuite/ulti-backend/internal/api/paginate"
+ "github.com/ultisuite/ulti-backend/internal/api/query"
+ "github.com/ultisuite/ulti-backend/internal/driveroot"
+ "github.com/ultisuite/ulti-backend/internal/drivestore"
+ "github.com/ultisuite/ulti-backend/internal/nextcloud"
+)
+
+type resolvedRoot struct {
+ ref driveroot.Ref
+ davPath string
+ logicalDir string
+ ncUserID string
+}
+
+func (s *Service) ensureStore() *drivestore.Store {
+ if s.store == nil && s.db != nil {
+ s.store = drivestore.NewStore(s.db)
+ }
+ return s.store
+}
+
+func (s *Service) resolveRoot(ctx context.Context, ncUserID string, ref driveroot.Ref) (resolvedRoot, error) {
+ ref.Path = nextcloud.NormalizeClientPath(ref.Path)
+ switch ref.Kind {
+ case driveroot.KindPersonal, "":
+ return resolvedRoot{
+ ref: driveroot.Personal(ref.Path),
+ davPath: s.nc.WebDAVPath(ncUserID, ref.Path),
+ logicalDir: ref.Path,
+ ncUserID: ncUserID,
+ }, nil
+ case driveroot.KindOrg:
+ store := s.ensureStore()
+ if store == nil {
+ return resolvedRoot{}, ErrInvalid
+ }
+ folder, err := store.GetOrgFolder(ctx, ref.RootID)
+ if err != nil {
+ if errors.Is(err, drivestore.ErrOrgFolderNotFound) {
+ return resolvedRoot{}, ErrNotFound
+ }
+ return resolvedRoot{}, err
+ }
+ return resolvedRoot{
+ ref: driveroot.Org(ref.RootID, ref.Path),
+ davPath: nextcloud.GroupFolderWebDAVPath(folder.NCFolderID, ref.Path),
+ logicalDir: ref.Path,
+ ncUserID: ncUserID,
+ }, nil
+ case driveroot.KindMount:
+ store := s.ensureStore()
+ if store == nil {
+ return resolvedRoot{}, ErrInvalid
+ }
+ mount, err := store.GetMount(ctx, ref.RootID)
+ if err != nil {
+ if errors.Is(err, drivestore.ErrMountNotFound) {
+ return resolvedRoot{}, ErrNotFound
+ }
+ return resolvedRoot{}, err
+ }
+ fullPath := joinMountPath(mount.MountPoint, ref.Path)
+ return resolvedRoot{
+ ref: driveroot.Mount(ref.RootID, ref.Path),
+ davPath: s.nc.WebDAVPath(ncUserID, fullPath),
+ logicalDir: ref.Path,
+ ncUserID: ncUserID,
+ }, nil
+ default:
+ return resolvedRoot{}, ErrInvalid
+ }
+}
+
+func joinMountPath(mountPoint, logicalPath string) string {
+ mp := strings.Trim(mountPoint, "/")
+ lp := strings.Trim(logicalPath, "/")
+ if mp == "" {
+ if lp == "" {
+ return "/"
+ }
+ return "/" + lp
+ }
+ if lp == "" {
+ return "/" + mp
+ }
+ return "/" + mp + "/" + lp
+}
+
+func (s *Service) resolveFileRoot(ctx context.Context, ncUserID string, ref driveroot.Ref) (resolvedRoot, error) {
+ resolved, err := s.resolveRoot(ctx, ncUserID, ref)
+ if err != nil {
+ return resolvedRoot{}, err
+ }
+ if ref.Path != "/" && !strings.HasSuffix(resolved.ref.Path, "/") {
+ return resolved, nil
+ }
+ return resolved, nil
+}
+
+func (s *Service) resolveFileDAV(ctx context.Context, ncUserID string, ref driveroot.Ref) (resolvedRoot, string, error) {
+ base, err := s.resolveRoot(ctx, ncUserID, ref)
+ if err != nil {
+ return resolvedRoot{}, "", err
+ }
+ filePath := nextcloud.NormalizeClientPath(ref.Path)
+ if filePath == "/" {
+ return base, base.davPath, nil
+ }
+ davPath := base.davPath
+ if !strings.HasSuffix(davPath, "/") && filePath != "/" {
+ // base.davPath is directory; append file segment if needed
+ rel := strings.TrimPrefix(filePath, "/")
+ if base.ref.Path == "/" || strings.HasPrefix(filePath, base.ref.Path+"/") || base.ref.Path == filePath {
+ // already included in path from ref
+ }
+ _ = rel
+ }
+ // For file operations, ref.Path is full logical path within root
+ switch base.ref.Kind {
+ case driveroot.KindOrg:
+ folder, _ := s.ensureStore().GetOrgFolder(ctx, ref.RootID)
+ davPath = nextcloud.GroupFolderWebDAVPath(folder.NCFolderID, filePath)
+ case driveroot.KindMount:
+ mount, _ := s.ensureStore().GetMount(ctx, ref.RootID)
+ davPath = s.nc.WebDAVPath(ncUserID, joinMountPath(mount.MountPoint, filePath))
+ default:
+ davPath = s.nc.WebDAVPath(ncUserID, filePath)
+ }
+ return base, davPath, nil
+}
+
+func (s *Service) ListFilesAtRoot(ctx context.Context, ncUserID string, ref driveroot.Ref, params query.ListParams) (FilesList, error) {
+ resolved, err := s.resolveRoot(ctx, ncUserID, ref)
+ if err != nil {
+ return FilesList{}, err
+ }
+ var files []nextcloud.FileInfo
+ if ref.Kind == driveroot.KindPersonal || ref.Kind == "" {
+ files, err = s.nc.ListFiles(ctx, ncUserID, ref.Path)
+ } else {
+ files, err = s.nc.ListFilesAtDAV(ctx, ncUserID, resolved.davPath, ref.Path)
+ }
+ if err != nil {
+ return FilesList{}, mapDriveError(err)
+ }
+ files = driveroot.EnrichFiles(files, resolved.ref)
+ filtered := visibleDriveFiles(files, params.Q)
+ page, total := paginate.Slice(filtered, params.Offset(), params.Limit())
+ return FilesList{
+ Files: page,
+ Pagination: params.Meta(&total),
+ }, nil
+}
+
+func (s *Service) StatFileAtRoot(ctx context.Context, ncUserID string, ref driveroot.Ref) (nextcloud.FileInfo, error) {
+ _, davPath, err := s.resolveFileDAV(ctx, ncUserID, ref)
+ if err != nil {
+ return nextcloud.FileInfo{}, err
+ }
+ var file nextcloud.FileInfo
+ if ref.Kind == driveroot.KindPersonal || ref.Kind == "" {
+ file, err = s.nc.StatFile(ctx, ncUserID, ref.Path)
+ } else {
+ file, err = s.nc.StatFileAtDAV(ctx, ncUserID, davPath, ref.Path)
+ }
+ if err != nil {
+ return nextcloud.FileInfo{}, mapDriveError(err)
+ }
+ return driveroot.EnrichFile(file, ref), nil
+}
+
+func (s *Service) UploadAtRoot(ctx context.Context, ncUserID string, ref driveroot.Ref, content io.Reader, contentType string, contentLength int64) error {
+ _, davPath, err := s.resolveFileDAV(ctx, ncUserID, ref)
+ if err != nil {
+ return err
+ }
+ if ref.Kind == driveroot.KindPersonal || ref.Kind == "" {
+ return s.Upload(ctx, ncUserID, ref.Path, content, contentType, contentLength)
+ }
+ return mapDriveError(s.nc.UploadAtDAV(ctx, ncUserID, davPath, contentType, content))
+}
+
+func (s *Service) DeleteAtRoot(ctx context.Context, ncUserID string, ref driveroot.Ref) error {
+ _, davPath, err := s.resolveFileDAV(ctx, ncUserID, ref)
+ if err != nil {
+ return err
+ }
+ if ref.Kind == driveroot.KindPersonal || ref.Kind == "" {
+ return s.Delete(ctx, ncUserID, ref.Path)
+ }
+ return mapDriveError(s.nc.DeleteAtDAV(ctx, ncUserID, davPath))
+}
+
+func (s *Service) CreateFolderAtRoot(ctx context.Context, ncUserID string, ref driveroot.Ref) error {
+ _, davPath, err := s.resolveFileDAV(ctx, ncUserID, ref)
+ if err != nil {
+ return err
+ }
+ if ref.Kind == driveroot.KindPersonal || ref.Kind == "" {
+ return s.CreateFolder(ctx, ncUserID, ref.Path)
+ }
+ return mapDriveError(s.nc.CreateFolderAtDAV(ctx, ncUserID, davPath))
+}
+
+func (s *Service) DownloadAtRoot(ctx context.Context, ncUserID string, ref driveroot.Ref) (io.ReadCloser, string, error) {
+ _, davPath, err := s.resolveFileDAV(ctx, ncUserID, ref)
+ if err != nil {
+ return nil, "", err
+ }
+ if ref.Kind == driveroot.KindPersonal || ref.Kind == "" {
+ return s.Download(ctx, ncUserID, ref.Path)
+ }
+ body, contentType, err := s.nc.DownloadAtDAV(ctx, ncUserID, davPath)
+ if err != nil {
+ return nil, "", mapDriveError(err)
+ }
+ return body, contentType, nil
+}
+
+func (s *Service) MoveAtRoot(ctx context.Context, ncUserID string, source, destination driveroot.Ref) error {
+ srcDAV, err := s.davPathForRef(ctx, ncUserID, source)
+ if err != nil {
+ return err
+ }
+ destDAV, err := s.davPathForRef(ctx, ncUserID, destination)
+ if err != nil {
+ return err
+ }
+ if source.Kind == driveroot.KindPersonal && destination.Kind == driveroot.KindPersonal {
+ return s.Move(ctx, ncUserID, source.Path, destination.Path)
+ }
+ return mapDriveError(s.nc.MoveAtDAV(ctx, ncUserID, srcDAV, destDAV))
+}
+
+func (s *Service) CopyAtRoot(ctx context.Context, ncUserID string, source, destination driveroot.Ref) error {
+ srcDAV, err := s.davPathForRef(ctx, ncUserID, source)
+ if err != nil {
+ return err
+ }
+ destDAV, err := s.davPathForRef(ctx, ncUserID, destination)
+ if err != nil {
+ return err
+ }
+ if source.Kind == driveroot.KindPersonal && destination.Kind == driveroot.KindPersonal {
+ return s.Copy(ctx, ncUserID, source.Path, destination.Path)
+ }
+ return mapDriveError(s.nc.CopyAtDAV(ctx, ncUserID, srcDAV, destDAV))
+}
+
+func (s *Service) RenameAtRoot(ctx context.Context, ncUserID string, ref driveroot.Ref, newName string) error {
+ if strings.Contains(newName, "/") {
+ return ErrInvalid
+ }
+ dir := path.Dir(strings.TrimPrefix(ref.Path, "/"))
+ if dir == "." {
+ dir = ""
+ }
+ destPath := "/" + strings.Trim(newName, "/")
+ if dir != "" {
+ destPath = "/" + dir + destPath
+ }
+ dest := driveroot.Ref{Kind: ref.Kind, RootID: ref.RootID, Path: destPath}
+ return s.MoveAtRoot(ctx, ncUserID, ref, dest)
+}
+
+func (s *Service) davPathForRef(ctx context.Context, ncUserID string, ref driveroot.Ref) (string, error) {
+ _, davPath, err := s.resolveFileDAV(ctx, ncUserID, ref)
+ return davPath, err
+}
+
+func (s *Service) platformUserID(ctx context.Context, externalID string) (string, error) {
+ if s.db == nil {
+ return "", fmt.Errorf("database not configured")
+ }
+ var id string
+ err := s.db.QueryRow(ctx, `SELECT id::text FROM users WHERE external_id = $1`, externalID).Scan(&id)
+ if err != nil {
+ return "", err
+ }
+ return id, nil
+}
+
+func extendServiceStore(s *Service, db *pgxpool.Pool) {
+ if s != nil && s.store == nil && db != nil {
+ s.store = drivestore.NewStore(db)
+ }
+}
diff --git a/internal/api/drive/service_roots_share.go b/internal/api/drive/service_roots_share.go
new file mode 100644
index 0000000..b7c6a95
--- /dev/null
+++ b/internal/api/drive/service_roots_share.go
@@ -0,0 +1,42 @@
+package drive
+
+import (
+ "context"
+
+ "github.com/ultisuite/ulti-backend/internal/driveroot"
+ "github.com/ultisuite/ulti-backend/internal/nextcloud"
+)
+
+func (s *Service) CreateShareAtRoot(ctx context.Context, ncUserID string, ref driveroot.Ref, req createShareRequest, permissions int) (*nextcloud.ShareInfo, error) {
+ if usesAlternateRoot(ref) {
+ file, err := s.StatFileAtRoot(ctx, ncUserID, ref)
+ if err != nil {
+ return nil, err
+ }
+ return s.CreateShare(ctx, ncUserID, file.Path, req, permissions)
+ }
+ return s.CreateShare(ctx, ncUserID, ref.Path, req, permissions)
+}
+
+func (s *Service) SetFavoriteAtRoot(ctx context.Context, ncUserID string, ref driveroot.Ref, favorite bool) error {
+ if usesAlternateRoot(ref) {
+ // Favorites are only supported on personal NC file paths today.
+ file, err := s.StatFileAtRoot(ctx, ncUserID, ref)
+ if err != nil {
+ return err
+ }
+ return s.SetFavorite(ctx, ncUserID, file.Path, favorite)
+ }
+ return s.SetFavorite(ctx, ncUserID, ref.Path, favorite)
+}
+
+func (s *Service) ListSharesAtRoot(ctx context.Context, ncUserID string, ref driveroot.Ref) ([]nextcloud.ShareInfo, error) {
+ if usesAlternateRoot(ref) {
+ file, err := s.StatFileAtRoot(ctx, ncUserID, ref)
+ if err != nil {
+ return nil, err
+ }
+ return s.ListShares(ctx, ncUserID, file.Path)
+ }
+ return s.ListShares(ctx, ncUserID, ref.Path)
+}
diff --git a/internal/api/drive/validate.go b/internal/api/drive/validate.go
index 5019a4c..8da7648 100644
--- a/internal/api/drive/validate.go
+++ b/internal/api/drive/validate.go
@@ -1,26 +1,55 @@
package drive
import (
+ "net/url"
"strings"
"github.com/ultisuite/ulti-backend/internal/api/apivalidate"
+ "github.com/ultisuite/ulti-backend/internal/nextcloud"
)
const maxJSONRequestBody = 32 << 10
type moveRequest struct {
- Source string `json:"source"`
- Destination string `json:"destination"`
+ Source string `json:"source"`
+ Destination string `json:"destination"`
+ SourceRoot string `json:"source_root,omitempty"`
+ SourceRootID string `json:"source_root_id,omitempty"`
+ DestinationRoot string `json:"destination_root,omitempty"`
+ DestinationRootID string `json:"destination_root_id,omitempty"`
}
type copyRequest struct {
- Source string `json:"source"`
- Destination string `json:"destination"`
+ Source string `json:"source"`
+ Destination string `json:"destination"`
+ SourceRoot string `json:"source_root,omitempty"`
+ SourceRootID string `json:"source_root_id,omitempty"`
+ DestinationRoot string `json:"destination_root,omitempty"`
+ DestinationRootID string `json:"destination_root_id,omitempty"`
}
type renameRequest struct {
Path string `json:"path"`
NewName string `json:"new_name"`
+ Root string `json:"root,omitempty"`
+ RootID string `json:"root_id,omitempty"`
+}
+
+type favoriteRequest struct {
+ Path string `json:"path"`
+ Favorite bool `json:"favorite"`
+ Root string `json:"root,omitempty"`
+ RootID string `json:"root_id,omitempty"`
+}
+
+type createMountRequest struct {
+ Scope string `json:"scope"`
+ OrgSlug string `json:"org_slug,omitempty"`
+ DisplayName string `json:"display_name"`
+ BackendType string `json:"backend_type"`
+ WebDAV *nextcloud.WebDAVMountConfig `json:"webdav,omitempty"`
+ OAuthBackend string `json:"oauth_backend,omitempty"`
+ OAuthAuth string `json:"oauth_auth,omitempty"`
}
func validateMoveRequest(req *moveRequest) *apivalidate.ValidationError {
@@ -77,6 +106,8 @@ type createShareRequest struct {
ShareWith string `json:"share_with"`
Note string `json:"note"`
SendMail *bool `json:"send_mail"`
+ Root string `json:"root,omitempty"`
+ RootID string `json:"root_id,omitempty"`
}
func sharePermissionsForRole(role string) (int, bool) {
@@ -144,11 +175,6 @@ func validateDeleteTrashRequest(req *deleteTrashRequest) *apivalidate.Validation
return nil
}
-type favoriteRequest struct {
- Path string `json:"path"`
- Favorite bool `json:"favorite"`
-}
-
func validateFavoriteRequest(req *favoriteRequest) *apivalidate.ValidationError {
if strings.TrimSpace(req.Path) == "" {
return apivalidate.NewValidationError(apivalidate.FieldDetail{
@@ -194,3 +220,24 @@ func validatePath(path string) *apivalidate.ValidationError {
}
return nil
}
+
+const mountOAuthCallbackPath = "/drive/mounts/oauth/callback"
+
+func validateMountOAuthRedirectURI(raw string) error {
+ raw = strings.TrimSpace(raw)
+ if raw == "" {
+ return ErrInvalid
+ }
+ parsed, err := url.Parse(raw)
+ if err != nil || parsed.Scheme == "" || parsed.Host == "" {
+ return ErrInvalid
+ }
+ if parsed.Scheme != "http" && parsed.Scheme != "https" {
+ return ErrInvalid
+ }
+ path := strings.TrimRight(parsed.Path, "/")
+ if path != mountOAuthCallbackPath {
+ return ErrInvalid
+ }
+ return nil
+}
diff --git a/internal/api/mail/handlers_api_tokens.go b/internal/api/mail/handlers_api_tokens.go
index 383c66a..9e4a4a0 100644
--- a/internal/api/mail/handlers_api_tokens.go
+++ b/internal/api/mail/handlers_api_tokens.go
@@ -20,6 +20,7 @@ type createApiTokenRequest struct {
Permissions []apitokens.PermissionGrant `json:"permissions"`
MailScope apitokens.MailScope `json:"mail_scope"`
DriveScope apitokens.DriveScope `json:"drive_scope"`
+ AgendaScope apitokens.AgendaScope `json:"agenda_scope"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
}
@@ -75,6 +76,7 @@ func (h *Handler) CreateApiToken(w http.ResponseWriter, r *http.Request) {
req.Permissions,
normalizeMailScope(req.MailScope),
normalizeDriveScope(req.DriveScope),
+ normalizeAgendaScope(req.AgendaScope),
req.ExpiresAt,
)
if err != nil {
@@ -129,6 +131,13 @@ func normalizeDriveScope(scope apitokens.DriveScope) apitokens.DriveScope {
return scope
}
+func normalizeAgendaScope(scope apitokens.AgendaScope) apitokens.AgendaScope {
+ if scope.AllCalendars || len(scope.CalendarIDs) == 0 {
+ return apitokens.AgendaScope{AllCalendars: true, CalendarIDs: nil}
+ }
+ return scope
+}
+
func (h *Handler) db() *pgxpool.Pool {
if s, ok := h.svc.(*Service); ok {
return s.DB()
diff --git a/internal/api/mail/service.go b/internal/api/mail/service.go
index 94748ac..0f97ab7 100644
--- a/internal/api/mail/service.go
+++ b/internal/api/mail/service.go
@@ -724,7 +724,7 @@ func (s *Service) ListWebhooks(ctx context.Context, externalID string, params qu
}
rows, err := s.db.Query(ctx, `
- SELECT id, name, url, method, version, is_active, body_template, event_types, mail_scope, drive_scope, contacts_scope
+ SELECT id, name, url, method, version, is_active, body_template, event_types, mail_scope, drive_scope, contacts_scope, agenda_scope
FROM webhook_templates
WHERE user_id = (SELECT id FROM users WHERE external_id = $1)
ORDER BY created_at ASC
@@ -740,8 +740,8 @@ func (s *Service) ListWebhooks(ctx context.Context, externalID string, params qu
var id, name, url, method, bodyTemplate string
var version int
var isActive bool
- var eventTypes, mailScope, driveScope, contactsScope []byte
- if err := rows.Scan(&id, &name, &url, &method, &version, &isActive, &bodyTemplate, &eventTypes, &mailScope, &driveScope, &contactsScope); err != nil {
+ var eventTypes, mailScope, driveScope, contactsScope, agendaScope []byte
+ if err := rows.Scan(&id, &name, &url, &method, &version, &isActive, &bodyTemplate, &eventTypes, &mailScope, &driveScope, &contactsScope, &agendaScope); err != nil {
return WebhooksList{}, err
}
webhooks = append(webhooks, map[string]any{
@@ -751,6 +751,7 @@ func (s *Service) ListWebhooks(ctx context.Context, externalID string, params qu
"mail_scope": jsonRawOrEmptyObject(mailScope),
"drive_scope": jsonRawOrEmptyObject(driveScope),
"contacts_scope": jsonRawOrEmptyObject(contactsScope),
+ "agenda_scope": jsonRawOrEmptyObject(agendaScope),
})
}
if err := rows.Err(); err != nil {
@@ -795,20 +796,24 @@ func (s *Service) CreateWebhook(ctx context.Context, externalID string, req *cre
if err != nil {
return "", err
}
+ agendaScopeJSON, err := marshalWebhookAgendaScope(req.AgendaScope)
+ if err != nil {
+ return "", err
+ }
var id string
err = s.db.QueryRow(ctx, `
INSERT INTO webhook_templates (
user_id, name, url, method, headers, body_template, version, signing_secret, max_retries,
- event_types, mail_scope, drive_scope, contacts_scope
+ event_types, mail_scope, drive_scope, contacts_scope, agenda_scope
)
VALUES (
(SELECT id FROM users WHERE external_id = $1), $2, $3, $4, $5, $6, 1, $7, $8,
- $9, $10, $11, $12
+ $9, $10, $11, $12, $13
)
RETURNING id
`, externalID, req.Name, req.URL, method, headersJSON, req.BodyTemplate, req.SigningSecret, maxRetries,
- eventTypesJSON, mailScopeJSON, driveScopeJSON, contactsScopeJSON).Scan(&id)
+ eventTypesJSON, mailScopeJSON, driveScopeJSON, contactsScopeJSON, agendaScopeJSON).Scan(&id)
if err != nil {
return "", err
}
diff --git a/internal/api/mail/service_webhooks.go b/internal/api/mail/service_webhooks.go
index d6f7ca4..ea09154 100644
--- a/internal/api/mail/service_webhooks.go
+++ b/internal/api/mail/service_webhooks.go
@@ -35,6 +35,10 @@ func (s *Service) UpdateWebhook(ctx context.Context, externalID, webhookID strin
if err != nil {
return err
}
+ agendaScopeJSON, err := marshalWebhookAgendaScope(req.AgendaScope)
+ if err != nil {
+ return err
+ }
err = tx.QueryRow(ctx, `
UPDATE webhook_templates
@@ -50,13 +54,14 @@ func (s *Service) UpdateWebhook(ctx context.Context, externalID, webhookID strin
mail_scope = $9,
drive_scope = $10,
contacts_scope = $11,
+ agenda_scope = $12,
version = version + 1,
updated_at = NOW()
- WHERE id = $12
- AND user_id = (SELECT id FROM users WHERE external_id = $13)
+ WHERE id = $13
+ AND user_id = (SELECT id FROM users WHERE external_id = $14)
RETURNING version
`, req.Name, req.URL, method, headersJSON, req.BodyTemplate, req.SigningSecret, maxRetries,
- eventTypesJSON, mailScopeJSON, driveScopeJSON, contactsScopeJSON, webhookID, externalID).Scan(&version)
+ eventTypesJSON, mailScopeJSON, driveScopeJSON, contactsScopeJSON, agendaScopeJSON, webhookID, externalID).Scan(&version)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrNotFound
diff --git a/internal/api/mail/validate.go b/internal/api/mail/validate.go
index 8208825..f86716d 100644
--- a/internal/api/mail/validate.go
+++ b/internal/api/mail/validate.go
@@ -609,6 +609,7 @@ type createWebhookRequest struct {
MailScope *webhookMailScope `json:"mail_scope"`
DriveScope *webhookDriveScope `json:"drive_scope"`
ContactsScope *webhookContactsScope `json:"contacts_scope"`
+ AgendaScope *webhookAgendaScope `json:"agenda_scope"`
}
type webhookMailScope struct {
@@ -626,6 +627,11 @@ type webhookContactsScope struct {
BookIDs []string `json:"book_ids"`
}
+type webhookAgendaScope struct {
+ AllCalendars bool `json:"all_calendars"`
+ CalendarIDs []string `json:"calendar_ids"`
+}
+
type updateWebhookRequest struct {
Name string `json:"name"`
URL string `json:"url"`
@@ -638,6 +644,7 @@ type updateWebhookRequest struct {
MailScope *webhookMailScope `json:"mail_scope"`
DriveScope *webhookDriveScope `json:"drive_scope"`
ContactsScope *webhookContactsScope `json:"contacts_scope"`
+ AgendaScope *webhookAgendaScope `json:"agenda_scope"`
}
type previewWebhookMessageRequest struct {
diff --git a/internal/api/mail/webhook_scope.go b/internal/api/mail/webhook_scope.go
index 7bda7bb..f6a8bb7 100644
--- a/internal/api/mail/webhook_scope.go
+++ b/internal/api/mail/webhook_scope.go
@@ -48,3 +48,15 @@ func marshalWebhookContactsScope(scope *webhookContactsScope) ([]byte, error) {
}
return json.Marshal(out)
}
+
+func marshalWebhookAgendaScope(scope *webhookAgendaScope) ([]byte, error) {
+ out := automation.AgendaScope{AllCalendars: true}
+ if scope != nil {
+ out.AllCalendars = scope.AllCalendars
+ out.CalendarIDs = scope.CalendarIDs
+ if out.CalendarIDs == nil {
+ out.CalendarIDs = []string{}
+ }
+ }
+ return json.Marshal(out)
+}
diff --git a/internal/api/meet/handlers.go b/internal/api/meet/handlers.go
index aa6e307..d51053e 100644
--- a/internal/api/meet/handlers.go
+++ b/internal/api/meet/handlers.go
@@ -3,35 +3,78 @@ package meet
import (
"log/slog"
"net/http"
+ "strings"
"github.com/go-chi/chi/v5"
+ "github.com/jackc/pgx/v5/pgxpool"
"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/auth"
meetpkg "github.com/ultisuite/ulti-backend/internal/meet"
+ "github.com/ultisuite/ulti-backend/internal/nextcloud"
+ "github.com/ultisuite/ulti-backend/internal/orgpolicy"
)
type Handler struct {
- svc *Service
- logger *slog.Logger
+ svc *Service
+ enabled bool
+ publicURL string
+ policy *orgpolicy.Loader
+ transcripts *TranscriptProcessor
+ transcriptSecret string
+ logger *slog.Logger
}
-func NewHandler(meetCfg *meetpkg.Config) *Handler {
+func NewHandler(
+ meetCfg *meetpkg.Config,
+ enabled bool,
+ publicURL string,
+ policy *orgpolicy.Loader,
+ db *pgxpool.Pool,
+ nc *nextcloud.Client,
+ transcriptSecret string,
+) *Handler {
return &Handler{
- svc: NewService(meetCfg),
- logger: slog.Default().With("component", "meet-api"),
+ svc: NewService(meetCfg, policy),
+ enabled: enabled && meetCfg != nil,
+ publicURL: strings.TrimRight(strings.TrimSpace(publicURL), "/"),
+ policy: policy,
+ transcripts: NewTranscriptProcessor(db, nc, policy),
+ transcriptSecret: strings.TrimSpace(transcriptSecret),
+ logger: slog.Default().With("component", "meet-api"),
}
}
func (h *Handler) Routes() chi.Router {
r := chi.NewRouter()
- r.Post("/rooms", h.CreateRoom)
- r.Post("/rooms/{roomID}/token", h.GetToken)
+ r.Get("/config", h.GetConfig)
+ r.Post("/transcripts", h.ReceiveTranscript)
+ r.Group(func(r chi.Router) {
+ r.Use(middleware.RequireFullAccount)
+ r.Post("/rooms", h.CreateRoom)
+ r.Post("/rooms/{roomID}/token", h.GetToken)
+ })
return r
}
+func (h *Handler) GetConfig(w http.ResponseWriter, r *http.Request) {
+ resp := map[string]any{
+ "enabled": h.enabled,
+ "public_url": h.publicURL,
+ "brand_name": "UltiMeet",
+ }
+ if h.policy != nil {
+ if pub, err := h.policy.PublicMeetPolicy(r.Context()); err == nil {
+ resp["transcription_enabled"] = pub.TranscriptionEnabled
+ resp["transcription_mode"] = pub.TranscriptionMode
+ resp["auto_start_transcription"] = pub.AutoStartTranscription
+ }
+ }
+ apiresponse.WriteJSON(w, http.StatusOK, resp)
+}
+
func meetUser(claims *auth.Claims) *meetpkg.UserInfo {
return &meetpkg.UserInfo{
ID: claims.Sub,
@@ -41,6 +84,10 @@ func meetUser(claims *auth.Claims) *meetpkg.UserInfo {
}
func (h *Handler) CreateRoom(w http.ResponseWriter, r *http.Request) {
+ if !h.enabled {
+ apiresponse.WriteError(w, r, http.StatusConflict, "meet_disabled", "meet is disabled", nil)
+ return
+ }
claims := middleware.ClaimsFromContext(r.Context())
var req createRoomRequest
@@ -56,7 +103,7 @@ func (h *Handler) CreateRoom(w http.ResponseWriter, r *http.Request) {
user := meetUser(claims)
user.IsMod = true
- token, err := h.svc.CreateRoom(req.Name, user)
+ token, err := h.svc.CreateRoom(r.Context(), req.Name, user)
if err != nil {
h.logger.Error("create room token", "error", err)
apivalidate.WriteInternal(w, r)
@@ -66,6 +113,10 @@ func (h *Handler) CreateRoom(w http.ResponseWriter, r *http.Request) {
}
func (h *Handler) GetToken(w http.ResponseWriter, r *http.Request) {
+ if !h.enabled {
+ apiresponse.WriteError(w, r, http.StatusConflict, "meet_disabled", "meet is disabled", nil)
+ return
+ }
claims := middleware.ClaimsFromContext(r.Context())
roomID := chi.URLParam(r, "roomID")
if verr := validateRoomID(roomID); verr != nil {
@@ -76,7 +127,7 @@ func (h *Handler) GetToken(w http.ResponseWriter, r *http.Request) {
user := meetUser(claims)
user.IsMod = false
- token, err := h.svc.GetToken(roomID, user)
+ token, err := h.svc.GetToken(r.Context(), roomID, user)
if err != nil {
h.logger.Error("get room token", "error", err)
apivalidate.WriteInternal(w, r)
@@ -84,3 +135,50 @@ func (h *Handler) GetToken(w http.ResponseWriter, r *http.Request) {
}
apiresponse.WriteJSON(w, http.StatusOK, token)
}
+
+func (h *Handler) ReceiveTranscript(w http.ResponseWriter, r *http.Request) {
+ if h.transcriptSecret != "" {
+ authHeader := strings.TrimSpace(r.Header.Get("Authorization"))
+ if authHeader != "Bearer "+h.transcriptSecret {
+ apiresponse.WriteError(w, r, http.StatusUnauthorized, "auth.unauthorized", "unauthorized", nil)
+ return
+ }
+ }
+
+ var req transcriptRequest
+ if err := apivalidate.DecodeJSON(w, r, 2<<20, &req); err != nil {
+ return
+ }
+ if verr := validateTranscriptRequest(&req); verr != nil {
+ apivalidate.WriteValidationError(w, r, verr)
+ return
+ }
+
+ claims := middleware.ClaimsFromContext(r.Context())
+ organizerUserID := strings.TrimSpace(req.OrganizerUserID)
+ organizerEmail := strings.TrimSpace(req.OrganizerEmail)
+ if claims != nil {
+ if organizerUserID == "" {
+ organizerUserID = claims.Sub
+ }
+ if organizerEmail == "" {
+ organizerEmail = claims.Email
+ }
+ }
+
+ err := h.transcripts.Handle(r.Context(), transcriptJobInput{
+ RoomID: strings.TrimSpace(req.RoomID),
+ OrganizerUserID: organizerUserID,
+ OrganizerEmail: organizerEmail,
+ ParticipantEmails: req.ParticipantEmails,
+ RawTranscript: req.Transcript,
+ Mode: strings.TrimSpace(req.Mode),
+ QueuedAudioURL: strings.TrimSpace(req.QueuedAudioURL),
+ })
+ if err != nil {
+ h.logger.Error("process transcript", "error", err)
+ apiresponse.WriteError(w, r, http.StatusBadRequest, "transcript_failed", err.Error(), nil)
+ return
+ }
+ apiresponse.WriteJSON(w, http.StatusAccepted, map[string]any{"ok": true})
+}
diff --git a/internal/api/meet/service.go b/internal/api/meet/service.go
index abc43d6..37e2fe0 100644
--- a/internal/api/meet/service.go
+++ b/internal/api/meet/service.go
@@ -1,30 +1,46 @@
package meet
import (
+ "context"
"strings"
"time"
"github.com/google/uuid"
meetpkg "github.com/ultisuite/ulti-backend/internal/meet"
+ "github.com/ultisuite/ulti-backend/internal/orgpolicy"
)
type Service struct {
- cfg *meetpkg.Config
+ cfg *meetpkg.Config
+ policy *orgpolicy.Loader
}
-func NewService(cfg *meetpkg.Config) *Service {
- return &Service{cfg: cfg}
+func NewService(cfg *meetpkg.Config, policy *orgpolicy.Loader) *Service {
+ return &Service{cfg: cfg, policy: policy}
}
-func (s *Service) CreateRoom(name string, user *meetpkg.UserInfo) (*meetpkg.RoomToken, error) {
+func (s *Service) tokenOpts(ctx context.Context, user *meetpkg.UserInfo) meetpkg.TokenOptions {
+ opts := meetpkg.TokenOptions{}
+ if s.policy == nil {
+ return opts
+ }
+ p, err := s.policy.MeetPolicy(ctx)
+ if err != nil || !p.LiveTranscriptionJWT() {
+ return opts
+ }
+ opts.Transcription = user.IsMod || p.AutoStartTranscription
+ return opts
+}
+
+func (s *Service) CreateRoom(ctx context.Context, name string, user *meetpkg.UserInfo) (*meetpkg.RoomToken, error) {
roomID := uuid.New().String()[:8]
if strings.TrimSpace(name) != "" {
roomID = strings.TrimSpace(name)
}
- return s.cfg.GenerateToken(roomID, user, 24*time.Hour)
+ return s.cfg.GenerateToken(roomID, user, 24*time.Hour, s.tokenOpts(ctx, user))
}
-func (s *Service) GetToken(roomID string, user *meetpkg.UserInfo) (*meetpkg.RoomToken, error) {
- return s.cfg.GenerateToken(roomID, user, 4*time.Hour)
+func (s *Service) GetToken(ctx context.Context, roomID string, user *meetpkg.UserInfo) (*meetpkg.RoomToken, error) {
+ return s.cfg.GenerateToken(roomID, user, 4*time.Hour, s.tokenOpts(ctx, user))
}
diff --git a/internal/api/meet/transcript_processor.go b/internal/api/meet/transcript_processor.go
new file mode 100644
index 0000000..c3205a3
--- /dev/null
+++ b/internal/api/meet/transcript_processor.go
@@ -0,0 +1,335 @@
+package meet
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "log/slog"
+ "net/smtp"
+ "path"
+ "strings"
+ "time"
+
+ "github.com/jackc/pgx/v5/pgxpool"
+
+ "github.com/ultisuite/ulti-backend/internal/llm"
+ "github.com/ultisuite/ulti-backend/internal/nextcloud"
+ "github.com/ultisuite/ulti-backend/internal/orgpolicy"
+)
+
+type TranscriptProcessor struct {
+ db *pgxpool.Pool
+ nc *nextcloud.Client
+ policy *orgpolicy.Loader
+ llm *llm.Client
+ logger *slog.Logger
+}
+
+func NewTranscriptProcessor(db *pgxpool.Pool, nc *nextcloud.Client, policy *orgpolicy.Loader) *TranscriptProcessor {
+ return &TranscriptProcessor{
+ db: db,
+ nc: nc,
+ policy: policy,
+ llm: llm.NewClient(),
+ logger: slog.Default().With("component", "meet-transcript"),
+ }
+}
+
+type transcriptJobInput struct {
+ RoomID string
+ OrganizerUserID string
+ OrganizerEmail string
+ ParticipantEmails []string
+ RawTranscript string
+ Mode string
+ QueuedAudioURL string
+}
+
+func (p *TranscriptProcessor) Handle(ctx context.Context, in transcriptJobInput) error {
+ policy, err := p.policy.MeetPolicy(ctx)
+ if err != nil {
+ return err
+ }
+ if !policy.TranscriptionEnabled {
+ return fmt.Errorf("transcription disabled")
+ }
+
+ mode := strings.TrimSpace(in.Mode)
+ if mode == "" {
+ mode = policy.TranscriptionMode
+ }
+
+ status := "completed"
+ body := strings.TrimSpace(in.RawTranscript)
+ if mode == "queued" && body == "" && strings.TrimSpace(in.QueuedAudioURL) != "" {
+ status = "queued"
+ body = ""
+ }
+ if body == "" && status != "queued" {
+ return fmt.Errorf("empty transcript")
+ }
+
+ participantsJSON, _ := json.Marshal(in.ParticipantEmails)
+ metadataJSON, _ := json.Marshal(map[string]any{"queued_audio_url": in.QueuedAudioURL})
+ var jobID string
+ err = p.db.QueryRow(ctx, `
+ INSERT INTO meet_transcript_jobs (
+ room_id, organizer_user_id, organizer_email, mode, status,
+ raw_transcript, participant_emails, metadata
+ ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb)
+ RETURNING id::text
+ `, in.RoomID, nullIfEmpty(in.OrganizerUserID), nullIfEmpty(in.OrganizerEmail), mode, status,
+ body, participantsJSON, metadataJSON).Scan(&jobID)
+ if err != nil {
+ return fmt.Errorf("insert transcript job: %w", err)
+ }
+
+ if status == "queued" {
+ p.logger.Info("transcript queued for async processing", "job_id", jobID, "room", in.RoomID)
+ return nil
+ }
+
+ return p.runPostActions(ctx, jobID, policy, in, body)
+}
+
+func (p *TranscriptProcessor) runPostActions(
+ ctx context.Context,
+ jobID string,
+ policy orgpolicy.MeetPolicy,
+ in transcriptJobInput,
+ rawTranscript string,
+) error {
+ finalText := rawTranscript
+ actions := policy.PostActions
+
+ if actions.LLMEnabled {
+ summary, err := p.summarize(ctx, policy, rawTranscript)
+ if err != nil {
+ p.logger.Warn("llm summary failed", "error", err, "job_id", jobID)
+ } else if strings.TrimSpace(summary) != "" {
+ finalText = summary
+ }
+ }
+
+ if actions.DriveEnabled && p.nc != nil && strings.TrimSpace(in.OrganizerUserID) != "" {
+ if err := p.saveToDrive(ctx, in.OrganizerUserID, actions.DriveFolderPath, in.RoomID, finalText); err != nil {
+ p.logger.Warn("drive save failed", "error", err, "job_id", jobID)
+ }
+ }
+ if actions.LLMEnabled && actions.LLMThenDrive && p.nc != nil && strings.TrimSpace(in.OrganizerUserID) != "" {
+ if err := p.saveToDrive(ctx, in.OrganizerUserID, actions.DriveFolderPath, in.RoomID+"-raw", rawTranscript); err != nil {
+ p.logger.Warn("drive raw save failed", "error", err, "job_id", jobID)
+ }
+ }
+
+ emailBody := finalText
+ sendEmail := actions.EmailEnabled
+ if actions.LLMEnabled && actions.LLMThenEmail {
+ sendEmail = true
+ }
+ if sendEmail {
+ recipients := p.resolveRecipients(actions, in)
+ if len(recipients) > 0 {
+ if err := p.sendOrgEmail(ctx, recipients, "Transcription UltiMeet — "+in.RoomID, emailBody); err != nil {
+ p.logger.Warn("transcript email failed", "error", err, "job_id", jobID)
+ }
+ }
+ }
+
+ _, err := p.db.Exec(ctx, `
+ UPDATE meet_transcript_jobs
+ SET processed_transcript = $2, status = 'completed', updated_at = NOW()
+ WHERE id = $3::uuid
+ `, jobID, finalText, jobID)
+ return err
+}
+
+func (p *TranscriptProcessor) summarize(ctx context.Context, policy orgpolicy.MeetPolicy, transcript string) (string, error) {
+ provider, model, err := p.resolveLLM(ctx, policy.PostActions.LLMProviderID)
+ if err != nil {
+ return "", err
+ }
+ prompt := strings.TrimSpace(policy.PostActions.LLMPrompt)
+ if prompt == "" {
+ prompt = "Résume cette réunion en français."
+ }
+ return p.llm.Complete(ctx, provider, model, prompt, transcript)
+}
+
+func (p *TranscriptProcessor) resolveLLM(ctx context.Context, providerID string) (llm.Provider, string, error) {
+ var raw []byte
+ err := p.db.QueryRow(ctx, `SELECT settings FROM org_settings WHERE id = 1`).Scan(&raw)
+ if err != nil {
+ return llm.Provider{}, "", err
+ }
+ stored := map[string]any{}
+ if err := json.Unmarshal(raw, &stored); err != nil {
+ return llm.Provider{}, "", err
+ }
+ llmSection, _ := stored["llm"].(map[string]any)
+ providersRaw, _ := llmSection["providers"].([]any)
+ defaultID, _ := llmSection["default_provider_id"].(string)
+ targetID := strings.TrimSpace(providerID)
+ if targetID == "" {
+ targetID = defaultID
+ }
+ for _, item := range providersRaw {
+ pm, ok := item.(map[string]any)
+ if !ok {
+ continue
+ }
+ id, _ := pm["id"].(string)
+ if id != targetID {
+ continue
+ }
+ return llm.Provider{
+ ID: id,
+ BaseURL: stringValue(pm["base_url"]),
+ APIKey: stringValue(pm["api_key"]),
+ DefaultModel: stringValue(pm["default_model"]),
+ }, stringValue(pm["default_model"]), nil
+ }
+ return llm.Provider{}, "", fmt.Errorf("llm provider not found")
+}
+
+func (p *TranscriptProcessor) saveToDrive(ctx context.Context, userID, folderPath, roomID, content string) error {
+ folder := strings.TrimSpace(folderPath)
+ if folder == "" {
+ folder = "/UltiMeet/Transcripts"
+ }
+ if !strings.HasPrefix(folder, "/") {
+ folder = "/" + folder
+ }
+ fileName := fmt.Sprintf("%s-%s.txt", sanitizeFileName(roomID), time.Now().UTC().Format("20060102-150405"))
+ davPath := path.Join(folder, fileName)
+ return p.nc.Upload(ctx, userID, davPath, strings.NewReader(content), "text/plain; charset=utf-8")
+}
+
+func (p *TranscriptProcessor) resolveRecipients(actions orgpolicy.MeetPostActions, in transcriptJobInput) []string {
+ out := make([]string, 0, 8)
+ seen := map[string]struct{}{}
+ add := func(email string) {
+ e := strings.ToLower(strings.TrimSpace(email))
+ if e == "" {
+ return
+ }
+ if _, ok := seen[e]; ok {
+ return
+ }
+ seen[e] = struct{}{}
+ out = append(out, e)
+ }
+ switch actions.EmailRecipients {
+ case "participants":
+ for _, e := range in.ParticipantEmails {
+ add(e)
+ }
+ case "both":
+ add(in.OrganizerEmail)
+ for _, e := range in.ParticipantEmails {
+ add(e)
+ }
+ case "custom":
+ for part := range strings.SplitSeq(actions.EmailCustomAddresses, ",") {
+ add(part)
+ }
+ default:
+ add(in.OrganizerEmail)
+ }
+ return out
+}
+
+func (p *TranscriptProcessor) sendOrgEmail(ctx context.Context, to []string, subject, body string) error {
+ var raw []byte
+ if err := p.db.QueryRow(ctx, `SELECT settings FROM org_settings WHERE id = 1`).Scan(&raw); err != nil {
+ return err
+ }
+ stored := map[string]any{}
+ if err := json.Unmarshal(raw, &stored); err != nil {
+ return err
+ }
+ mailing, _ := stored["mailing"].(map[string]any)
+ if mailing == nil || !boolValue(mailing["enabled"]) {
+ return fmt.Errorf("org mailing disabled")
+ }
+ host := stringValue(mailing["smtp_host"])
+ port := intValue(mailing["smtp_port"], 587)
+ user := stringValue(mailing["smtp_user"])
+ pass := stringValue(mailing["smtp_password"])
+ from := stringValue(mailing["from_email"])
+ fromName := stringValue(mailing["from_name"])
+ if from == "" {
+ return fmt.Errorf("mailing from_email missing")
+ }
+ addr := fmt.Sprintf("%s:%d", host, port)
+ msg := buildPlainEmail(from, fromName, to, subject, body)
+ auth := smtp.PlainAuth("", user, pass, host)
+ tlsMode := stringValue(mailing["tls_mode"])
+ if tlsMode == "none" {
+ return smtp.SendMail(addr, nil, from, to, msg)
+ }
+ return smtp.SendMail(addr, auth, from, to, msg)
+}
+
+func buildPlainEmail(from, fromName string, to []string, subject, body string) []byte {
+ fromHeader := from
+ if strings.TrimSpace(fromName) != "" {
+ fromHeader = fmt.Sprintf("%s <%s>", fromName, from)
+ }
+ var buf bytes.Buffer
+ buf.WriteString("From: " + fromHeader + "\r\n")
+ buf.WriteString("To: " + strings.Join(to, ", ") + "\r\n")
+ buf.WriteString("Subject: " + subject + "\r\n")
+ buf.WriteString("MIME-Version: 1.0\r\n")
+ buf.WriteString("Content-Type: text/plain; charset=UTF-8\r\n")
+ buf.WriteString("\r\n")
+ buf.WriteString(body)
+ return buf.Bytes()
+}
+
+func sanitizeFileName(s string) string {
+ s = strings.Map(func(r rune) rune {
+ switch {
+ case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_':
+ return r
+ default:
+ return '-'
+ }
+ }, s)
+ if s == "" {
+ return "room"
+ }
+ return s
+}
+
+func nullIfEmpty(s string) any {
+ if strings.TrimSpace(s) == "" {
+ return nil
+ }
+ return s
+}
+
+func boolValue(v any) bool {
+ b, _ := v.(bool)
+ return b
+}
+
+func stringValue(v any) string {
+ s, _ := v.(string)
+ return s
+}
+
+func intValue(v any, fallback int) int {
+ switch t := v.(type) {
+ case float64:
+ if t > 0 {
+ return int(t)
+ }
+ case int:
+ if t > 0 {
+ return t
+ }
+ }
+ return fallback
+}
diff --git a/internal/api/meet/transcript_validate.go b/internal/api/meet/transcript_validate.go
new file mode 100644
index 0000000..a39bc0c
--- /dev/null
+++ b/internal/api/meet/transcript_validate.go
@@ -0,0 +1,44 @@
+package meet
+
+import (
+ "strings"
+ "unicode/utf8"
+
+ "github.com/ultisuite/ulti-backend/internal/api/apivalidate"
+)
+
+type transcriptRequest struct {
+ RoomID string `json:"room_id"`
+ OrganizerUserID string `json:"organizer_user_id"`
+ OrganizerEmail string `json:"organizer_email"`
+ ParticipantEmails []string `json:"participant_emails"`
+ Transcript string `json:"transcript"`
+ Mode string `json:"mode"`
+ QueuedAudioURL string `json:"queued_audio_url"`
+}
+
+func validateTranscriptRequest(req *transcriptRequest) *apivalidate.ValidationError {
+ room := strings.TrimSpace(req.RoomID)
+ if room == "" {
+ return apivalidate.NewValidationError(apivalidate.FieldDetail{
+ Field: "room_id", Message: "required",
+ })
+ }
+ if utf8.RuneCountInString(room) > 256 {
+ return apivalidate.NewValidationError(apivalidate.FieldDetail{
+ Field: "room_id", Message: "too long",
+ })
+ }
+ mode := strings.TrimSpace(req.Mode)
+ if mode != "" && mode != "live" && mode != "queued" {
+ return apivalidate.NewValidationError(apivalidate.FieldDetail{
+ Field: "mode", Message: "must be live or queued",
+ })
+ }
+ if strings.TrimSpace(req.Transcript) == "" && strings.TrimSpace(req.QueuedAudioURL) == "" {
+ return apivalidate.NewValidationError(apivalidate.FieldDetail{
+ Field: "transcript", Message: "transcript or queued_audio_url required",
+ })
+ }
+ return nil
+}
diff --git a/internal/api/users/handlers.go b/internal/api/users/handlers.go
index adeef83..2f241e8 100644
--- a/internal/api/users/handlers.go
+++ b/internal/api/users/handlers.go
@@ -1,10 +1,13 @@
package users
import (
+ "encoding/json"
+ "errors"
"log/slog"
"net/http"
"github.com/go-chi/chi/v5"
+ "github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/ultisuite/ulti-backend/internal/api/apiresponse"
@@ -12,23 +15,28 @@ import (
"github.com/ultisuite/ulti-backend/internal/api/middleware"
"github.com/ultisuite/ulti-backend/internal/permission"
platformusers "github.com/ultisuite/ulti-backend/internal/users"
+ "github.com/ultisuite/ulti-backend/internal/orgpolicy"
)
type Handler struct {
- db *pgxpool.Pool
- logger *slog.Logger
+ db *pgxpool.Pool
+ logger *slog.Logger
+ orgPolicy *orgpolicy.Loader
}
func NewHandler(db *pgxpool.Pool) *Handler {
return &Handler{
- db: db,
- logger: slog.Default().With("component", "users-api"),
+ db: db,
+ orgPolicy: orgpolicy.NewLoader(db, nil),
+ logger: slog.Default().With("component", "users-api"),
}
}
func (h *Handler) Routes() chi.Router {
r := chi.NewRouter()
r.Get("/me", h.Me)
+ r.Put("/me/avatar", h.PutAvatar)
+ r.Delete("/me/avatar", h.DeleteAvatar)
return r
}
@@ -47,7 +55,28 @@ func (h *Handler) Me(w http.ResponseWriter, r *http.Request) {
}
role := permission.DeriveAccountRole(state.PlatformAdmin, state.Status)
- apiresponse.WriteJSON(w, http.StatusOK, map[string]any{
+ orgAgenda, err := h.orgPolicy.PublicAgendaPolicy(r.Context())
+ if err != nil {
+ h.logger.Warn("read org agenda policy", "error", err)
+ orgAgenda = orgpolicy.PublicAgendaPolicy{
+ DefaultThemeMode: "system",
+ DefaultVideoProvider: "ultimeet",
+ ConfiguredVideoProviders: []string{"ultimeet"},
+ }
+ }
+
+ orgDrive, err := h.orgPolicy.PublicDrivePolicy(r.Context())
+ if err != nil {
+ h.logger.Warn("read org drive policy", "error", err)
+ orgDrive = orgpolicy.PublicDrivePolicy{}
+ }
+
+ avatarURL, err := platformusers.GetAvatarURL(r.Context(), h.db, claims.Sub)
+ if err != nil {
+ h.logger.Warn("read user avatar", "error", err)
+ }
+
+ payload := map[string]any{
"sub": claims.Sub,
"email": claims.Email,
"name": claims.Name,
@@ -55,5 +84,67 @@ func (h *Handler) Me(w http.ResponseWriter, r *http.Request) {
"platform_admin": state.PlatformAdmin,
"role": role,
"groups": claims.Groups,
+ "org_agenda": orgAgenda,
+ "org_drive": orgDrive,
+ }
+ if avatarURL != "" {
+ payload["avatar_url"] = avatarURL
+ }
+
+ apiresponse.WriteJSON(w, http.StatusOK, payload)
+}
+
+func (h *Handler) PutAvatar(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 body struct {
+ AvatarURL string `json:"avatar_url"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
+ apiresponse.WriteError(w, r, http.StatusBadRequest, apiresponse.CodeInvalidRequest, "invalid json body", nil)
+ return
+ }
+
+ if err := platformusers.SetAvatarURL(r.Context(), h.db, claims.Sub, body.AvatarURL); err != nil {
+ switch {
+ case errors.Is(err, platformusers.ErrAvatarTooLarge):
+ apiresponse.WriteError(w, r, http.StatusBadRequest, apiresponse.CodeInvalidRequest, "avatar too large (max 512 KiB)", nil)
+ case errors.Is(err, platformusers.ErrAvatarInvalid):
+ apiresponse.WriteError(w, r, http.StatusBadRequest, apiresponse.CodeInvalidRequest, "invalid avatar image", nil)
+ case errors.Is(err, pgx.ErrNoRows):
+ apivalidate.WriteNotFound(w, r, "user not found")
+ default:
+ h.logger.Error("set user avatar", "error", err)
+ apivalidate.WriteInternal(w, r)
+ }
+ return
+ }
+
+ apiresponse.WriteJSON(w, http.StatusOK, map[string]any{
+ "avatar_url": body.AvatarURL,
})
}
+
+func (h *Handler) DeleteAvatar(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
+ }
+
+ if err := platformusers.ClearAvatarURL(r.Context(), h.db, claims.Sub); err != nil {
+ if errors.Is(err, pgx.ErrNoRows) {
+ apivalidate.WriteNotFound(w, r, "user not found")
+ return
+ }
+ h.logger.Error("clear user avatar", "error", err)
+ apivalidate.WriteInternal(w, r)
+ return
+ }
+
+ apiresponse.WriteJSON(w, http.StatusOK, map[string]any{"ok": true})
+}
diff --git a/internal/apitokens/chat_session.go b/internal/apitokens/chat_session.go
index baf0b58..28ca452 100644
--- a/internal/apitokens/chat_session.go
+++ b/internal/apitokens/chat_session.go
@@ -33,7 +33,7 @@ func CreateChatSession(ctx context.Context, db *pgxpool.Pool, externalID, email
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)
+ return Create(ctx, db, externalID, name, perms, mailScope, driveScope, AgendaScope{AllCalendars: true}, &expiresAt)
}
func chatSessionGrants(in ChatSessionInput) ([]PermissionGrant, MailScope, DriveScope) {
diff --git a/internal/apitokens/policy.go b/internal/apitokens/policy.go
index 23b075d..2dab288 100644
--- a/internal/apitokens/policy.go
+++ b/internal/apitokens/policy.go
@@ -82,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/calendar/"):
+ return calendarRequirement(method, path)
+
case strings.HasPrefix(path, "/api/v1/richtext/"):
return richtextRequirement(method, path)
@@ -202,6 +205,30 @@ func driveRequirement(method, path string) (Requirement, bool) {
}
}
+func calendarRequirement(method, path string) (Requirement, bool) {
+ write := method != http.MethodGet && method != http.MethodHead
+
+ switch {
+ case strings.HasSuffix(path, "/freebusy"):
+ return Requirement{Resource: "agenda.freebusy", Write: false}, true
+ case strings.Contains(path, "/events/response/"):
+ return Requirement{Resource: "agenda.response", Write: true}, true
+ case strings.Contains(path, "/events/meet-link/"):
+ return Requirement{Resource: "agenda.events.write", Write: true}, true
+ case strings.Contains(path, "/events/"):
+ if write {
+ return Requirement{Resource: "agenda.events.write", Write: true}, true
+ }
+ return Requirement{Resource: "agenda.events", Write: false}, true
+ case method == http.MethodDelete:
+ return Requirement{Resource: "agenda.events.delete", Write: true}, true
+ case write:
+ return Requirement{Resource: "agenda.calendars", Write: true}, true
+ default:
+ return Requirement{Resource: "agenda.calendars", Write: false}, true
+ }
+}
+
func searchRequirement(typesQuery string) (Requirement, bool) {
types := parseSearchTypes(typesQuery)
if len(types) == 0 {
diff --git a/internal/apitokens/scope.go b/internal/apitokens/scope.go
index 7cdfeec..8ad9950 100644
--- a/internal/apitokens/scope.go
+++ b/internal/apitokens/scope.go
@@ -33,6 +33,15 @@ func AllowsDrivePath(auth *AuthContext, rawPath string) bool {
if target == "" {
return true
}
+ // Scoped paths: org:{id}:/path or mount:{id}:/path
+ if strings.HasPrefix(target, "org:") || strings.HasPrefix(target, "mount:") {
+ for _, allowed := range auth.DriveScope.FolderPaths {
+ if driveScopePrefixMatch(target, allowed) {
+ return true
+ }
+ }
+ return false
+ }
for _, allowed := range auth.DriveScope.FolderPaths {
if drivePathWithinScope(target, allowed) {
return true
@@ -41,6 +50,14 @@ func AllowsDrivePath(auth *AuthContext, rawPath string) bool {
return false
}
+func driveScopePrefixMatch(target, allowed string) bool {
+ allowed = strings.TrimSpace(allowed)
+ if allowed == "" || allowed == "/" {
+ return true
+ }
+ return target == allowed || strings.HasPrefix(target, allowed+":") || strings.HasPrefix(target, allowed+"/")
+}
+
func NormalizeDriveScopePath(rawPath string) string {
rawPath = strings.TrimSpace(rawPath)
if rawPath == "" {
@@ -67,3 +84,18 @@ func drivePathWithinScope(target, allowed string) bool {
}
return strings.HasPrefix(target, allowed+"/")
}
+
+func AllowsAgendaCalendar(auth *AuthContext, calendarID string) bool {
+ if auth == nil || calendarID == "" {
+ return true
+ }
+ if auth.AgendaScope.AllCalendars {
+ return true
+ }
+ for _, id := range auth.AgendaScope.CalendarIDs {
+ if id == calendarID {
+ return true
+ }
+ }
+ return false
+}
diff --git a/internal/apitokens/tokens.go b/internal/apitokens/tokens.go
index ffbf42b..4e52b0b 100644
--- a/internal/apitokens/tokens.go
+++ b/internal/apitokens/tokens.go
@@ -44,6 +44,11 @@ type DriveScope struct {
FolderPaths []string `json:"folder_paths"`
}
+type AgendaScope struct {
+ AllCalendars bool `json:"all_calendars"`
+ CalendarIDs []string `json:"calendar_ids"`
+}
+
type Token struct {
ID string `json:"id"`
Name string `json:"name"`
@@ -51,6 +56,7 @@ type Token struct {
Permissions []PermissionGrant `json:"permissions"`
MailScope MailScope `json:"mail_scope"`
DriveScope DriveScope `json:"drive_scope"`
+ AgendaScope AgendaScope `json:"agenda_scope"`
CreatedAt time.Time `json:"created_at"`
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
@@ -70,6 +76,7 @@ type AuthContext struct {
Permissions []PermissionGrant
MailScope MailScope
DriveScope DriveScope
+ AgendaScope AgendaScope
}
func HashSecret(secret string) []byte {
@@ -90,7 +97,7 @@ func generateSecret() (string, string, error) {
func List(ctx context.Context, db *pgxpool.Pool, externalID string) ([]Token, error) {
rows, err := db.Query(ctx, `
- SELECT t.id, t.name, t.token_prefix, t.permissions, t.mail_scope, t.drive_scope,
+ SELECT t.id, t.name, t.token_prefix, t.permissions, t.mail_scope, t.drive_scope, t.agenda_scope,
t.created_at, t.last_used_at, t.expires_at
FROM api_tokens t
JOIN users u ON u.id = t.user_id
@@ -113,7 +120,7 @@ func List(ctx context.Context, db *pgxpool.Pool, externalID string) ([]Token, er
return out, rows.Err()
}
-func Create(ctx context.Context, db *pgxpool.Pool, externalID string, name string, permissions []PermissionGrant, mailScope MailScope, driveScope DriveScope, expiresAt *time.Time) (CreatedToken, error) {
+func Create(ctx context.Context, db *pgxpool.Pool, externalID string, name string, permissions []PermissionGrant, mailScope MailScope, driveScope DriveScope, agendaScope AgendaScope, expiresAt *time.Time) (CreatedToken, error) {
secret, prefix, err := generateSecret()
if err != nil {
return CreatedToken{}, err
@@ -131,24 +138,29 @@ func Create(ctx context.Context, db *pgxpool.Pool, externalID string, name strin
if err != nil {
return CreatedToken{}, err
}
+ agendaJSON, err := json.Marshal(agendaScope)
+ if err != nil {
+ return CreatedToken{}, err
+ }
var item Token
err = db.QueryRow(ctx, `
INSERT INTO api_tokens (
- user_id, name, token_prefix, secret_hash, permissions, mail_scope, drive_scope, expires_at
+ user_id, name, token_prefix, secret_hash, permissions, mail_scope, drive_scope, agenda_scope, expires_at
)
VALUES (
(SELECT id FROM users WHERE external_id = $1),
- $2, $3, $4, $5, $6, $7, $8
+ $2, $3, $4, $5, $6, $7, $8, $9
)
- RETURNING id, name, token_prefix, permissions, mail_scope, drive_scope, created_at, last_used_at, expires_at
- `, externalID, name, prefix, HashSecret(secret), permJSON, mailJSON, driveJSON, expiresAt).Scan(
+ RETURNING id, name, token_prefix, permissions, mail_scope, drive_scope, agenda_scope, created_at, last_used_at, expires_at
+ `, externalID, name, prefix, HashSecret(secret), permJSON, mailJSON, driveJSON, agendaJSON, expiresAt).Scan(
&item.ID,
&item.Name,
&item.TokenPrefix,
&permJSON,
&mailJSON,
&driveJSON,
+ &agendaJSON,
&item.CreatedAt,
&item.LastUsedAt,
&item.ExpiresAt,
@@ -156,7 +168,7 @@ func Create(ctx context.Context, db *pgxpool.Pool, externalID string, name strin
if err != nil {
return CreatedToken{}, err
}
- if err := decodeTokenJSON(permJSON, mailJSON, driveJSON, &item); err != nil {
+ if err := decodeTokenJSON(permJSON, mailJSON, driveJSON, agendaJSON, &item); err != nil {
return CreatedToken{}, err
}
@@ -189,7 +201,7 @@ func Authenticate(ctx context.Context, db *pgxpool.Pool, secret string) (*AuthCo
hash := HashSecret(secret)
row := db.QueryRow(ctx, `
SELECT t.id, u.id::text, u.external_id, u.email, COALESCE(u.name, ''),
- t.permissions, t.mail_scope, t.drive_scope, t.expires_at, t.revoked_at
+ t.permissions, t.mail_scope, t.drive_scope, t.agenda_scope, t.expires_at, t.revoked_at
FROM api_tokens t
JOIN users u ON u.id = t.user_id
WHERE t.secret_hash = $1
@@ -197,7 +209,7 @@ func Authenticate(ctx context.Context, db *pgxpool.Pool, secret string) (*AuthCo
`, hash)
var auth AuthContext
- var permJSON, mailJSON, driveJSON []byte
+ var permJSON, mailJSON, driveJSON, agendaJSON []byte
var expiresAt *time.Time
var revokedAt *time.Time
if err := row.Scan(
@@ -209,6 +221,7 @@ func Authenticate(ctx context.Context, db *pgxpool.Pool, secret string) (*AuthCo
&permJSON,
&mailJSON,
&driveJSON,
+ &agendaJSON,
&expiresAt,
&revokedAt,
); err != nil {
@@ -232,6 +245,9 @@ func Authenticate(ctx context.Context, db *pgxpool.Pool, secret string) (*AuthCo
if err := json.Unmarshal(driveJSON, &auth.DriveScope); err != nil {
return nil, err
}
+ if err := json.Unmarshal(agendaJSON, &auth.AgendaScope); err != nil {
+ return nil, err
+ }
_, _ = db.Exec(ctx, `
UPDATE api_tokens SET last_used_at = now(), updated_at = now() WHERE id = $1
@@ -266,7 +282,7 @@ type rowScanner interface {
func scanToken(rows rowScanner) (Token, error) {
var item Token
- var permJSON, mailJSON, driveJSON []byte
+ var permJSON, mailJSON, driveJSON, agendaJSON []byte
if err := rows.Scan(
&item.ID,
&item.Name,
@@ -274,19 +290,20 @@ func scanToken(rows rowScanner) (Token, error) {
&permJSON,
&mailJSON,
&driveJSON,
+ &agendaJSON,
&item.CreatedAt,
&item.LastUsedAt,
&item.ExpiresAt,
); err != nil {
return Token{}, err
}
- if err := decodeTokenJSON(permJSON, mailJSON, driveJSON, &item); err != nil {
+ if err := decodeTokenJSON(permJSON, mailJSON, driveJSON, agendaJSON, &item); err != nil {
return Token{}, err
}
return item, nil
}
-func decodeTokenJSON(permJSON, mailJSON, driveJSON []byte, item *Token) error {
+func decodeTokenJSON(permJSON, mailJSON, driveJSON, agendaJSON []byte, item *Token) error {
if err := json.Unmarshal(permJSON, &item.Permissions); err != nil {
return err
}
@@ -296,5 +313,12 @@ func decodeTokenJSON(permJSON, mailJSON, driveJSON []byte, item *Token) error {
if err := json.Unmarshal(driveJSON, &item.DriveScope); err != nil {
return err
}
+ if len(agendaJSON) > 0 {
+ if err := json.Unmarshal(agendaJSON, &item.AgendaScope); err != nil {
+ return err
+ }
+ } else {
+ item.AgendaScope = AgendaScope{AllCalendars: true, CalendarIDs: []string{}}
+ }
return nil
}
diff --git a/internal/automation/dispatcher.go b/internal/automation/dispatcher.go
index 375784b..2af7273 100644
--- a/internal/automation/dispatcher.go
+++ b/internal/automation/dispatcher.go
@@ -73,7 +73,7 @@ func (d *Dispatcher) OnMailCreated(ctx context.Context, userID, accountID, messa
msg.ID = messageID
}
d.runRules(ctx, userID, msg, evt)
- d.dispatchWebhooks(ctx, userID, string(rules.TriggerMessageReceived), evt, msg, accountID, "", "")
+ d.dispatchWebhooks(ctx, userID, string(rules.TriggerMessageReceived), evt, msg, accountID, "", "", "")
}
func (d *Dispatcher) OnDriveEvent(ctx context.Context, externalUserID string, trigger rules.TriggerType, payload DrivePayload) {
@@ -88,7 +88,7 @@ func (d *Dispatcher) OnDriveEvent(ctx context.Context, externalUserID string, tr
evt := driveEventContext(trigger, payload)
msg := &rules.Message{}
d.runRules(ctx, userID, msg, evt)
- d.dispatchWebhooks(ctx, userID, string(trigger), evt, msg, "", payload.FilePath, "")
+ d.dispatchWebhooks(ctx, userID, string(trigger), evt, msg, "", payload.FilePath, "", "")
}
func (d *Dispatcher) OnContactEvent(ctx context.Context, externalUserID string, trigger rules.TriggerType, payload ContactPayload) {
@@ -103,7 +103,7 @@ func (d *Dispatcher) OnContactEvent(ctx context.Context, externalUserID string,
evt := contactEventContext(trigger, payload)
msg := &rules.Message{}
d.runRules(ctx, userID, msg, evt)
- d.dispatchWebhooks(ctx, userID, string(trigger), evt, msg, "", "", payload.BookID)
+ d.dispatchWebhooks(ctx, userID, string(trigger), evt, msg, "", "", payload.BookID, "")
}
func (d *Dispatcher) runRules(ctx context.Context, userID string, msg *rules.Message, evt *rules.EventContext) {
@@ -121,6 +121,7 @@ type webhookTemplateRow struct {
mailScope []byte
driveScope []byte
contactsScope []byte
+ agendaScope []byte
}
func (d *Dispatcher) dispatchWebhooks(
@@ -132,12 +133,13 @@ func (d *Dispatcher) dispatchWebhooks(
accountID string,
drivePath string,
bookID string,
+ calendarID string,
) {
if d.hooks == nil || d.db == nil {
return
}
rows, err := d.db.Query(ctx, `
- SELECT id, event_types, mail_scope, drive_scope, contacts_scope
+ SELECT id, event_types, mail_scope, drive_scope, contacts_scope, agenda_scope
FROM webhook_templates
WHERE user_id = $1 AND is_active = true
`, userID)
@@ -150,14 +152,14 @@ func (d *Dispatcher) dispatchWebhooks(
msgCtx := rules.WebhookContextFromEvent(evt, msg)
for rows.Next() {
var row webhookTemplateRow
- if err := rows.Scan(&row.id, &row.eventTypes, &row.mailScope, &row.driveScope, &row.contactsScope); err != nil {
+ if err := rows.Scan(&row.id, &row.eventTypes, &row.mailScope, &row.driveScope, &row.contactsScope, &row.agendaScope); err != nil {
d.logger.Error("scan webhook template", "error", err)
continue
}
if !webhookMatchesEvent(row, eventType) {
continue
}
- if !webhookMatchesScope(row, accountID, drivePath, bookID) {
+ if !webhookMatchesScope(row, accountID, drivePath, bookID, calendarID) {
continue
}
if err := d.hooks.Execute(ctx, row.id, msgCtx); err != nil {
@@ -182,13 +184,15 @@ func webhookMatchesEvent(row webhookTemplateRow, eventType string) bool {
return false
}
-func webhookMatchesScope(row webhookTemplateRow, accountID, drivePath, bookID string) bool {
+func webhookMatchesScope(row webhookTemplateRow, accountID, drivePath, bookID, calendarID string) bool {
var mailScope MailScope
var driveScope DriveScope
var contactsScope ContactsScope
+ var agendaScope AgendaScope
_ = json.Unmarshal(row.mailScope, &mailScope)
_ = json.Unmarshal(row.driveScope, &driveScope)
_ = json.Unmarshal(row.contactsScope, &contactsScope)
+ _ = json.Unmarshal(row.agendaScope, &agendaScope)
if accountID != "" {
return AllowsMailScope(mailScope, accountID)
@@ -199,6 +203,9 @@ func webhookMatchesScope(row webhookTemplateRow, accountID, drivePath, bookID st
if bookID != "" {
return AllowsContactsScope(contactsScope, bookID)
}
+ if calendarID != "" {
+ return AllowsAgendaScope(agendaScope, calendarID)
+ }
return true
}
diff --git a/internal/automation/scope.go b/internal/automation/scope.go
index 5126bad..c07be20 100644
--- a/internal/automation/scope.go
+++ b/internal/automation/scope.go
@@ -19,6 +19,11 @@ type ContactsScope struct {
BookIDs []string `json:"book_ids"`
}
+type AgendaScope struct {
+ AllCalendars bool `json:"all_calendars"`
+ CalendarIDs []string `json:"calendar_ids"`
+}
+
func AllowsMailScope(scope MailScope, accountID string) bool {
if accountID == "" {
return true
@@ -79,3 +84,18 @@ func AllowsContactsScope(scope ContactsScope, bookID string) bool {
}
return false
}
+
+func AllowsAgendaScope(scope AgendaScope, calendarID string) bool {
+ if calendarID == "" {
+ return true
+ }
+ if scope.AllCalendars {
+ return true
+ }
+ for _, id := range scope.CalendarIDs {
+ if id == calendarID {
+ return true
+ }
+ }
+ return false
+}
diff --git a/internal/config/config.go b/internal/config/config.go
index a258e50..ea4dcbf 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -88,6 +88,7 @@ type Config struct {
JitsiAppID string
JitsiAppSecret string
JitsiPublicURL string
+ MeetTranscriptWebhookSecret string
// Immich
ImmichEnabled bool
@@ -210,6 +211,7 @@ func Load() (*Config, error) {
JitsiAppID: envOrDefault("JITSI_APP_ID", "ulti"),
JitsiAppSecret: envOrDefaultSecret("JITSI_APP_SECRET", "changeme-jwt-secret"),
JitsiPublicURL: envOrDefault("JITSI_PUBLIC_URL", "https://localhost/meet"),
+ MeetTranscriptWebhookSecret: envOrDefaultSecret("MEET_TRANSCRIPT_WEBHOOK_SECRET", ""),
ImmichEnabled: envBool("IMMICH_ENABLED", true),
ImmichAPIURL: envOrDefault("IMMICH_API_URL", "http://immich-server:2283/api"),
diff --git a/internal/driveroot/root.go b/internal/driveroot/root.go
new file mode 100644
index 0000000..bf4383a
--- /dev/null
+++ b/internal/driveroot/root.go
@@ -0,0 +1,85 @@
+package driveroot
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/ultisuite/ulti-backend/internal/nextcloud"
+)
+
+type Kind string
+
+const (
+ KindPersonal Kind = "personal"
+ KindOrg Kind = "org"
+ KindMount Kind = "mount"
+)
+
+type Capabilities struct {
+ Share bool `json:"share"`
+ Trash bool `json:"trash"`
+ Preview bool `json:"preview"`
+}
+
+type Ref struct {
+ Kind Kind `json:"root_kind"`
+ RootID string `json:"root_id,omitempty"`
+ Path string `json:"path"`
+}
+
+func Personal(path string) Ref {
+ return Ref{Kind: KindPersonal, Path: nextcloud.NormalizeClientPath(path)}
+}
+
+func Org(rootID, path string) Ref {
+ return Ref{Kind: KindOrg, RootID: strings.TrimSpace(rootID), Path: nextcloud.NormalizeClientPath(path)}
+}
+
+func Mount(rootID, path string) Ref {
+ return Ref{Kind: KindMount, RootID: strings.TrimSpace(rootID), Path: nextcloud.NormalizeClientPath(path)}
+}
+
+func (r Ref) Capabilities() Capabilities {
+ switch r.Kind {
+ case KindOrg:
+ return Capabilities{Share: true, Trash: true, Preview: true}
+ case KindMount:
+ return Capabilities{Share: true, Trash: false, Preview: true}
+ default:
+ return Capabilities{Share: true, Trash: true, Preview: true}
+ }
+}
+
+func ParseKind(raw string) (Kind, error) {
+ switch Kind(strings.TrimSpace(strings.ToLower(raw))) {
+ case "", KindPersonal:
+ return KindPersonal, nil
+ case KindOrg:
+ return KindOrg, nil
+ case KindMount:
+ return KindMount, nil
+ default:
+ return "", fmt.Errorf("invalid root kind")
+ }
+}
+
+func EnrichFile(file nextcloud.FileInfo, ref Ref) nextcloud.FileInfo {
+ file.Source = ""
+ cap := ref.Capabilities()
+ file.RootKind = string(ref.Kind)
+ file.RootID = ref.RootID
+ file.Capabilities = &nextcloud.FileCapabilities{
+ Share: cap.Share,
+ Trash: cap.Trash,
+ Preview: cap.Preview,
+ }
+ return file
+}
+
+func EnrichFiles(files []nextcloud.FileInfo, ref Ref) []nextcloud.FileInfo {
+ out := make([]nextcloud.FileInfo, len(files))
+ for i, f := range files {
+ out[i] = EnrichFile(f, ref)
+ }
+ return out
+}
diff --git a/internal/drivestore/store.go b/internal/drivestore/store.go
new file mode 100644
index 0000000..66ee046
--- /dev/null
+++ b/internal/drivestore/store.go
@@ -0,0 +1,323 @@
+package drivestore
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "time"
+
+ "github.com/google/uuid"
+ "github.com/jackc/pgx/v5"
+ "github.com/jackc/pgx/v5/pgxpool"
+)
+
+var ErrOrgFolderNotFound = errors.New("org folder not found")
+var ErrMountNotFound = errors.New("mount not found")
+
+type OrgFolder struct {
+ ID string `json:"id"`
+ OrgSlug string `json:"org_slug"`
+ NCFolderID int `json:"nc_folder_id"`
+ MountPoint string `json:"mount_point"`
+ QuotaBytes *int64 `json:"quota_bytes,omitempty"`
+ AutoProvisioned bool `json:"auto_provisioned"`
+ CreatedBy string `json:"created_by"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+}
+
+type Mount struct {
+ ID string `json:"id"`
+ Scope string `json:"scope"`
+ OwnerUserID *string `json:"owner_user_id,omitempty"`
+ OrgSlug *string `json:"org_slug,omitempty"`
+ NCMountID *int `json:"nc_mount_id,omitempty"`
+ DisplayName string `json:"display_name"`
+ BackendType string `json:"backend_type"`
+ MountPoint string `json:"mount_point"`
+ Status string `json:"status"`
+ LastError string `json:"last_error,omitempty"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+}
+
+type Store struct {
+ db *pgxpool.Pool
+}
+
+func NewStore(db *pgxpool.Pool) *Store {
+ return &Store{db: db}
+}
+
+func (s *Store) ListOrgFolders(ctx context.Context) ([]OrgFolder, error) {
+ if s.db == nil {
+ return nil, fmt.Errorf("database not configured")
+ }
+ rows, err := s.db.Query(ctx, `
+ SELECT id, org_slug, nc_folder_id, mount_point, quota_bytes,
+ auto_provisioned, created_by, created_at, updated_at
+ FROM drive_org_folders
+ ORDER BY mount_point ASC
+ `)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var out []OrgFolder
+ for rows.Next() {
+ var item OrgFolder
+ if err := rows.Scan(
+ &item.ID, &item.OrgSlug, &item.NCFolderID, &item.MountPoint,
+ &item.QuotaBytes, &item.AutoProvisioned, &item.CreatedBy,
+ &item.CreatedAt, &item.UpdatedAt,
+ ); err != nil {
+ return nil, err
+ }
+ out = append(out, item)
+ }
+ return out, rows.Err()
+}
+
+func (s *Store) GetOrgFolder(ctx context.Context, id string) (OrgFolder, error) {
+ if s.db == nil {
+ return OrgFolder{}, fmt.Errorf("database not configured")
+ }
+ var item OrgFolder
+ err := s.db.QueryRow(ctx, `
+ SELECT id, org_slug, nc_folder_id, mount_point, quota_bytes,
+ auto_provisioned, created_by, created_at, updated_at
+ FROM drive_org_folders WHERE id = $1
+ `, id).Scan(
+ &item.ID, &item.OrgSlug, &item.NCFolderID, &item.MountPoint,
+ &item.QuotaBytes, &item.AutoProvisioned, &item.CreatedBy,
+ &item.CreatedAt, &item.UpdatedAt,
+ )
+ if errors.Is(err, pgx.ErrNoRows) {
+ return OrgFolder{}, ErrOrgFolderNotFound
+ }
+ if err != nil {
+ return OrgFolder{}, err
+ }
+ return item, nil
+}
+
+func (s *Store) GetOrgFolderBySlug(ctx context.Context, orgSlug string) (OrgFolder, error) {
+ if s.db == nil {
+ return OrgFolder{}, fmt.Errorf("database not configured")
+ }
+ var item OrgFolder
+ err := s.db.QueryRow(ctx, `
+ SELECT id, org_slug, nc_folder_id, mount_point, quota_bytes,
+ auto_provisioned, created_by, created_at, updated_at
+ FROM drive_org_folders WHERE org_slug = $1
+ `, orgSlug).Scan(
+ &item.ID, &item.OrgSlug, &item.NCFolderID, &item.MountPoint,
+ &item.QuotaBytes, &item.AutoProvisioned, &item.CreatedBy,
+ &item.CreatedAt, &item.UpdatedAt,
+ )
+ if errors.Is(err, pgx.ErrNoRows) {
+ return OrgFolder{}, ErrOrgFolderNotFound
+ }
+ if err != nil {
+ return OrgFolder{}, err
+ }
+ return item, nil
+}
+
+type CreateOrgFolderParams struct {
+ OrgSlug string
+ NCFolderID int
+ MountPoint string
+ QuotaBytes *int64
+ AutoProvisioned bool
+ CreatedBy string
+}
+
+func (s *Store) CreateOrgFolder(ctx context.Context, p CreateOrgFolderParams) (OrgFolder, error) {
+ if s.db == nil {
+ return OrgFolder{}, fmt.Errorf("database not configured")
+ }
+ id := uuid.NewString()
+ var item OrgFolder
+ err := s.db.QueryRow(ctx, `
+ INSERT INTO drive_org_folders (
+ id, org_slug, nc_folder_id, mount_point, quota_bytes,
+ auto_provisioned, created_by
+ ) VALUES ($1, $2, $3, $4, $5, $6, $7)
+ RETURNING id, org_slug, nc_folder_id, mount_point, quota_bytes,
+ auto_provisioned, created_by, created_at, updated_at
+ `, id, p.OrgSlug, p.NCFolderID, p.MountPoint, p.QuotaBytes, p.AutoProvisioned, p.CreatedBy).Scan(
+ &item.ID, &item.OrgSlug, &item.NCFolderID, &item.MountPoint,
+ &item.QuotaBytes, &item.AutoProvisioned, &item.CreatedBy,
+ &item.CreatedAt, &item.UpdatedAt,
+ )
+ if err != nil {
+ return OrgFolder{}, err
+ }
+ return item, nil
+}
+
+func (s *Store) UpdateOrgFolder(ctx context.Context, id, mountPoint string, quotaBytes *int64) (OrgFolder, error) {
+ if s.db == nil {
+ return OrgFolder{}, fmt.Errorf("database not configured")
+ }
+ var item OrgFolder
+ err := s.db.QueryRow(ctx, `
+ UPDATE drive_org_folders
+ SET mount_point = $2, quota_bytes = $3, updated_at = NOW()
+ WHERE id = $1
+ RETURNING id, org_slug, nc_folder_id, mount_point, quota_bytes,
+ auto_provisioned, created_by, created_at, updated_at
+ `, id, mountPoint, quotaBytes).Scan(
+ &item.ID, &item.OrgSlug, &item.NCFolderID, &item.MountPoint,
+ &item.QuotaBytes, &item.AutoProvisioned, &item.CreatedBy,
+ &item.CreatedAt, &item.UpdatedAt,
+ )
+ if errors.Is(err, pgx.ErrNoRows) {
+ return OrgFolder{}, ErrOrgFolderNotFound
+ }
+ if err != nil {
+ return OrgFolder{}, err
+ }
+ return item, nil
+}
+
+func (s *Store) DeleteOrgFolder(ctx context.Context, id string) error {
+ if s.db == nil {
+ return fmt.Errorf("database not configured")
+ }
+ tag, err := s.db.Exec(ctx, `DELETE FROM drive_org_folders WHERE id = $1`, id)
+ if err != nil {
+ return err
+ }
+ if tag.RowsAffected() == 0 {
+ return ErrOrgFolderNotFound
+ }
+ return nil
+}
+
+func (s *Store) ListMountsForUser(ctx context.Context, ownerUserID string, orgSlugs []string) ([]Mount, error) {
+ if s.db == nil {
+ return nil, fmt.Errorf("database not configured")
+ }
+ rows, err := s.db.Query(ctx, `
+ SELECT id, scope, owner_user_id, org_slug, nc_mount_id, display_name,
+ backend_type, mount_point, status, last_error, created_at, updated_at
+ FROM drive_mounts
+ WHERE (scope = 'user' AND owner_user_id = $1::uuid)
+ OR (scope = 'org' AND org_slug = ANY($2))
+ ORDER BY display_name ASC
+ `, ownerUserID, orgSlugs)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ return scanMounts(rows)
+}
+
+func (s *Store) GetMount(ctx context.Context, id string) (Mount, error) {
+ if s.db == nil {
+ return Mount{}, fmt.Errorf("database not configured")
+ }
+ row := s.db.QueryRow(ctx, `
+ SELECT id, scope, owner_user_id, org_slug, nc_mount_id, display_name,
+ backend_type, mount_point, status, last_error, created_at, updated_at
+ FROM drive_mounts WHERE id = $1
+ `, id)
+ item, err := scanMount(row)
+ if errors.Is(err, pgx.ErrNoRows) {
+ return Mount{}, ErrMountNotFound
+ }
+ return item, err
+}
+
+type CreateMountParams struct {
+ Scope string
+ OwnerUserID *string
+ OrgSlug *string
+ NCMountID *int
+ DisplayName string
+ BackendType string
+ MountPoint string
+ Status string
+ ConfigEnc []byte
+}
+
+func (s *Store) CreateMount(ctx context.Context, p CreateMountParams) (Mount, error) {
+ if s.db == nil {
+ return Mount{}, fmt.Errorf("database not configured")
+ }
+ id := uuid.NewString()
+ status := p.Status
+ if status == "" {
+ status = "active"
+ }
+ row := s.db.QueryRow(ctx, `
+ INSERT INTO drive_mounts (
+ id, scope, owner_user_id, org_slug, nc_mount_id, display_name,
+ backend_type, mount_point, status, config_encrypted
+ ) VALUES ($1, $2, $3::uuid, $4, $5, $6, $7, $8, $9, $10)
+ RETURNING id, scope, owner_user_id, org_slug, nc_mount_id, display_name,
+ backend_type, mount_point, status, last_error, created_at, updated_at
+ `, id, p.Scope, p.OwnerUserID, p.OrgSlug, p.NCMountID, p.DisplayName,
+ p.BackendType, p.MountPoint, status, p.ConfigEnc)
+ return scanMount(row)
+}
+
+func (s *Store) UpdateMountStatus(ctx context.Context, id, status, lastError string, ncMountID *int) error {
+ if s.db == nil {
+ return fmt.Errorf("database not configured")
+ }
+ tag, err := s.db.Exec(ctx, `
+ UPDATE drive_mounts
+ SET status = $2, last_error = $3, nc_mount_id = COALESCE($4, nc_mount_id), updated_at = NOW()
+ WHERE id = $1
+ `, id, status, lastError, ncMountID)
+ if err != nil {
+ return err
+ }
+ if tag.RowsAffected() == 0 {
+ return ErrMountNotFound
+ }
+ return nil
+}
+
+func (s *Store) DeleteMount(ctx context.Context, id string) error {
+ if s.db == nil {
+ return fmt.Errorf("database not configured")
+ }
+ tag, err := s.db.Exec(ctx, `DELETE FROM drive_mounts WHERE id = $1`, id)
+ if err != nil {
+ return err
+ }
+ if tag.RowsAffected() == 0 {
+ return ErrMountNotFound
+ }
+ return nil
+}
+
+type scannable interface {
+ Scan(dest ...any) error
+}
+
+func scanMount(row scannable) (Mount, error) {
+ var item Mount
+ err := row.Scan(
+ &item.ID, &item.Scope, &item.OwnerUserID, &item.OrgSlug, &item.NCMountID,
+ &item.DisplayName, &item.BackendType, &item.MountPoint, &item.Status,
+ &item.LastError, &item.CreatedAt, &item.UpdatedAt,
+ )
+ return item, err
+}
+
+func scanMounts(rows pgx.Rows) ([]Mount, error) {
+ var out []Mount
+ for rows.Next() {
+ item, err := scanMount(rows)
+ if err != nil {
+ return nil, err
+ }
+ out = append(out, item)
+ }
+ return out, rows.Err()
+}
diff --git a/internal/mail/rules/engine.go b/internal/mail/rules/engine.go
index 898adff..95e085a 100644
--- a/internal/mail/rules/engine.go
+++ b/internal/mail/rules/engine.go
@@ -295,6 +295,38 @@ func matchCondition(cond Condition, msg *Message, evt *EventContext) bool {
if evt != nil {
fieldValue = evt.ContactOrg
}
+ case "calendar_event_title":
+ if evt != nil {
+ fieldValue = evt.CalendarEventTitle
+ }
+ case "calendar_event_location":
+ if evt != nil {
+ fieldValue = evt.CalendarEventLocation
+ }
+ case "calendar_event_organizer":
+ if evt != nil {
+ fieldValue = evt.CalendarEventOrganizer
+ }
+ case "calendar_event_attendee":
+ if evt != nil {
+ fieldValue = evt.CalendarEventAttendee
+ }
+ case "calendar_event_all_day":
+ if evt != nil {
+ if evt.CalendarEventAllDay {
+ fieldValue = "true"
+ } else {
+ fieldValue = "false"
+ }
+ }
+ case "calendar_event_has_video":
+ if evt != nil {
+ if evt.CalendarEventHasVideo {
+ fieldValue = "true"
+ } else {
+ fieldValue = "false"
+ }
+ }
default:
return false
}
@@ -433,6 +465,9 @@ func (e *Engine) executeAction(ctx context.Context, action Action, msg *Message,
case "contact_add_label", "contact_remove_label", "contact_delete":
e.logger.Info("deferred contact action", "type", action.Type, "value", action.Value)
return nil
+ case "calendar_add_attendee", "calendar_update_title", "calendar_cancel_event", "calendar_notify_attendees":
+ e.logger.Info("deferred calendar action", "type", action.Type, "value", action.Value)
+ return nil
default:
return fmt.Errorf("unknown action type: %s", action.Type)
}
diff --git a/internal/mail/rules/workflow.go b/internal/mail/rules/workflow.go
index b5a989f..f779b2d 100644
--- a/internal/mail/rules/workflow.go
+++ b/internal/mail/rules/workflow.go
@@ -29,6 +29,10 @@ const (
TriggerContactCreated TriggerType = "contact_created"
TriggerContactUpdated TriggerType = "contact_updated"
TriggerContactDeleted TriggerType = "contact_deleted"
+ TriggerCalendarEventCreated TriggerType = "calendar_event_created"
+ TriggerCalendarEventUpdated TriggerType = "calendar_event_updated"
+ TriggerCalendarEventDeleted TriggerType = "calendar_event_deleted"
+ TriggerCalendarEventResponse TriggerType = "calendar_event_response"
)
type Trigger struct {
@@ -38,6 +42,7 @@ type Trigger struct {
AccountID string `json:"account_id,omitempty"`
FolderPath string `json:"folder_path,omitempty"`
ContactLabel string `json:"contact_label,omitempty"`
+ CalendarID string `json:"calendar_id,omitempty"`
}
type TriggerGroup struct {
@@ -130,6 +135,7 @@ type EventContext struct {
Label string
FolderPath string
ContactLabel string
+ CalendarID string
// Drive payload (when domain is drive)
DriveFileName string
DriveFilePath string
@@ -143,6 +149,16 @@ type EventContext struct {
ContactEmail string
ContactPhone string
ContactOrg string
+ // Calendar payload (when domain is agenda)
+ CalendarEventTitle string
+ CalendarEventLocation string
+ CalendarEventOrganizer string
+ CalendarEventAttendee string
+ CalendarEventStart string
+ CalendarEventEnd string
+ CalendarEventAllDay bool
+ CalendarEventHasVideo bool
+ CalendarEventUID string
}
func ParseWorkflow(raw []byte) (*Workflow, error) {
@@ -276,6 +292,14 @@ func matchTrigger(t Trigger, msg *Message, evt *EventContext) bool {
return false
}
return true
+ case TriggerCalendarEventCreated, TriggerCalendarEventUpdated, TriggerCalendarEventDeleted, TriggerCalendarEventResponse:
+ if evt == nil || evt.Type != t.Type {
+ return false
+ }
+ if t.CalendarID != "" && evt.CalendarID != "" && t.CalendarID != evt.CalendarID {
+ return false
+ }
+ return true
default:
return false
}
diff --git a/internal/mail/rules/workflow_exec.go b/internal/mail/rules/workflow_exec.go
index 682c357..a8a0019 100644
--- a/internal/mail/rules/workflow_exec.go
+++ b/internal/mail/rules/workflow_exec.go
@@ -272,6 +272,24 @@ func workflowFieldValue(field string, msg *Message, evt *EventContext, execCtx *
return evt.ContactOrg
case "contact_label":
return evt.ContactLabel
+ case "calendar_event_title":
+ return evt.CalendarEventTitle
+ case "calendar_event_location":
+ return evt.CalendarEventLocation
+ case "calendar_event_organizer":
+ return evt.CalendarEventOrganizer
+ case "calendar_event_attendee":
+ return evt.CalendarEventAttendee
+ case "calendar_event_all_day":
+ if evt.CalendarEventAllDay {
+ return "true"
+ }
+ return "false"
+ case "calendar_event_has_video":
+ if evt.CalendarEventHasVideo {
+ return "true"
+ }
+ return "false"
default:
return ""
}
diff --git a/internal/meet/meet.go b/internal/meet/meet.go
index 286f96c..5160927 100644
--- a/internal/meet/meet.go
+++ b/internal/meet/meet.go
@@ -30,6 +30,11 @@ type UserInfo struct {
IsMod bool `json:"is_moderator"`
}
+// TokenOptions toggles JWT feature flags for Jitsi.
+type TokenOptions struct {
+ Transcription bool
+}
+
func NewConfig(appID, appSecret, domain string) *Config {
return &Config{
AppID: appID,
@@ -38,7 +43,7 @@ func NewConfig(appID, appSecret, domain string) *Config {
}
}
-func (c *Config) GenerateToken(room string, user *UserInfo, ttl time.Duration) (*RoomToken, error) {
+func (c *Config) GenerateToken(room string, user *UserInfo, ttl time.Duration, opts TokenOptions) (*RoomToken, error) {
now := time.Now()
exp := now.Add(ttl)
@@ -47,6 +52,24 @@ func (c *Config) GenerateToken(room string, user *UserInfo, ttl time.Duration) (
"typ": "JWT",
}
+ userCtx := map[string]any{
+ "id": user.ID,
+ "name": user.Name,
+ "email": user.Email,
+ "avatar": user.Avatar,
+ "moderator": user.IsMod,
+ }
+ contextPayload := map[string]any{
+ "user": userCtx,
+ }
+ if opts.Transcription {
+ contextPayload["features"] = map[string]any{
+ "transcription": "true",
+ "recording": "false",
+ "livestreaming": "false",
+ }
+ }
+
payload := map[string]any{
"iss": c.AppID,
"sub": "meet.jitsi",
@@ -54,15 +77,7 @@ func (c *Config) GenerateToken(room string, user *UserInfo, ttl time.Duration) (
"iat": now.Unix(),
"exp": exp.Unix(),
"room": room,
- "context": map[string]any{
- "user": map[string]any{
- "id": user.ID,
- "name": user.Name,
- "email": user.Email,
- "avatar": user.Avatar,
- "moderator": user.IsMod,
- },
- },
+ "context": contextPayload,
}
token, err := signJWT(header, payload, c.AppSecret)
diff --git a/internal/nextcloud/calendar.go b/internal/nextcloud/calendar.go
index e2271bc..8d8d8fb 100644
--- a/internal/nextcloud/calendar.go
+++ b/internal/nextcloud/calendar.go
@@ -7,6 +7,7 @@ import (
"fmt"
"io"
"net/http"
+ "strconv"
"strings"
"time"
)
@@ -32,6 +33,7 @@ type Event struct {
Attendees []EventAttendee `json:"attendees,omitempty"`
MeetURL string `json:"meet_url,omitempty"`
Color string `json:"color,omitempty"`
+ Sequence int `json:"sequence,omitempty"`
RRule string `json:"rrule,omitempty"`
ExDates []string `json:"exdates,omitempty"`
RawICS string `json:"raw_ics,omitempty"`
@@ -215,6 +217,7 @@ func (c *Client) CreateEvent(ctx context.Context, userID, calendarPath string, e
}
func (c *Client) GetEvent(ctx context.Context, userID, eventPath string) (*Event, error) {
+ eventPath = normalizeDAVHref(eventPath)
resp, err := c.DoAsUser(ctx, "GET", eventPath, nil, userID, nil)
if err != nil {
return nil, err
@@ -234,7 +237,68 @@ func (c *Client) GetEvent(ctx context.Context, userID, eventPath string) (*Event
return &event, nil
}
+func uidFromEventPath(eventPath string) string {
+ eventPath = strings.TrimSuffix(strings.TrimSpace(eventPath), "/")
+ if idx := strings.LastIndex(eventPath, "/"); idx >= 0 {
+ eventPath = eventPath[idx+1:]
+ }
+ return strings.TrimSuffix(eventPath, ".ics")
+}
+
+// MergeEvent overlays patch onto existing. Patch wins for editable fields; UID and
+// exdates fall back to existing when absent so CalDAV PUT keeps a valid master.
+func MergeEvent(existing, patch *Event) *Event {
+ if existing == nil {
+ return patch
+ }
+ if patch == nil {
+ out := *existing
+ return &out
+ }
+
+ merged := *existing
+ merged.Summary = patch.Summary
+ merged.Description = patch.Description
+ merged.Location = patch.Location
+ if strings.TrimSpace(patch.Start) != "" {
+ merged.Start = patch.Start
+ merged.AllDay = patch.AllDay
+ }
+ if strings.TrimSpace(patch.End) != "" {
+ merged.End = patch.End
+ }
+ if strings.TrimSpace(patch.UID) != "" {
+ merged.UID = patch.UID
+ }
+ if len(patch.Attendees) > 0 {
+ merged.Attendees = patch.Attendees
+ }
+ if strings.TrimSpace(patch.Organizer) != "" {
+ merged.Organizer = patch.Organizer
+ }
+ if strings.TrimSpace(patch.MeetURL) != "" {
+ merged.MeetURL = patch.MeetURL
+ }
+ if strings.TrimSpace(patch.Color) != "" {
+ merged.Color = patch.Color
+ }
+ if strings.TrimSpace(patch.RRule) != "" {
+ merged.RRule = patch.RRule
+ }
+ if patch.ExDates != nil {
+ merged.ExDates = patch.ExDates
+ }
+ if strings.TrimSpace(merged.UID) == "" {
+ merged.UID = uidFromEventPath(existing.Path)
+ }
+ return &merged
+}
+
func (c *Client) UpdateEvent(ctx context.Context, userID, eventPath, ifMatch string, event *Event) (string, error) {
+ eventPath = normalizeDAVHref(eventPath)
+ if strings.TrimSpace(event.UID) == "" {
+ event.UID = uidFromEventPath(eventPath)
+ }
ics := buildICS(event)
headers := map[string]string{
"Content-Type": "text/calendar; charset=utf-8",
@@ -258,6 +322,7 @@ func (c *Client) UpdateEvent(ctx context.Context, userID, eventPath, ifMatch str
}
func (c *Client) DeleteEvent(ctx context.Context, userID, eventPath string) error {
+ eventPath = normalizeDAVHref(eventPath)
resp, err := c.DoAsUser(ctx, "DELETE", eventPath, nil, userID, nil)
if err != nil {
return err
@@ -484,9 +549,6 @@ func buildICS(event *Event) string {
b.WriteString(fmt.Sprintf("URL:%s\r\n", strings.TrimSpace(event.MeetURL)))
b.WriteString(fmt.Sprintf("X-ULTI-MEET-URL:%s\r\n", strings.TrimSpace(event.MeetURL)))
}
- if strings.TrimSpace(event.Color) != "" {
- b.WriteString(fmt.Sprintf("COLOR:%s\r\n", strings.TrimSpace(event.Color)))
- }
if strings.TrimSpace(event.RRule) != "" {
b.WriteString(fmt.Sprintf("RRULE:%s\r\n", strings.TrimSpace(event.RRule)))
}
@@ -503,6 +565,9 @@ func buildICS(event *Event) string {
}
writeDateProp(&b, "DTSTART", event.Start, event.AllDay)
writeDateProp(&b, "DTEND", event.End, event.AllDay)
+ if event.Sequence > 0 {
+ b.WriteString(fmt.Sprintf("SEQUENCE:%d\r\n", event.Sequence))
+ }
b.WriteString(fmt.Sprintf("DTSTAMP:%s\r\n", time.Now().UTC().Format("20060102T150405Z")))
b.WriteString("END:VEVENT\r\n")
b.WriteString("END:VCALENDAR\r\n")
@@ -515,9 +580,11 @@ func parseCalendarList(body io.Reader, basePath string) ([]Calendar, error) {
return nil, err
}
+ basePath = normalizeDAVHref(basePath)
calendars := make([]Calendar, 0)
for _, r := range ms.Responses {
- if r.Href == basePath {
+ href := normalizeDAVHref(r.Href)
+ if href == basePath {
continue
}
name := r.Propstat.Prop.DisplayName
@@ -525,10 +592,10 @@ func parseCalendarList(body io.Reader, basePath string) ([]Calendar, error) {
continue
}
calendars = append(calendars, Calendar{
- ID: strings.TrimSuffix(strings.TrimPrefix(r.Href, basePath), "/"),
+ ID: strings.TrimSuffix(strings.TrimPrefix(href, basePath), "/"),
DisplayName: name,
Color: r.Propstat.Prop.CalendarColor,
- Path: r.Href,
+ Path: href,
})
}
return calendars, nil
@@ -545,7 +612,7 @@ func parseEventList(body io.Reader) ([]Event, error) {
ics := r.Propstat.Prop.CalendarData
event := parseICS(ics)
event.RawICS = ics
- event.Path = r.Href
+ event.Path = normalizeDAVHref(r.Href)
event.ETag = strings.TrimSpace(r.Propstat.Prop.ETag)
events = append(events, event)
}
@@ -679,6 +746,10 @@ func parseICS(ics string) Event {
e.Color = value
case "RRULE":
e.RRule = value
+ case "SEQUENCE":
+ if seq, err := strconv.Atoi(strings.TrimSpace(value)); err == nil {
+ e.Sequence = seq
+ }
case "EXDATE":
for _, ex := range strings.Split(value, ",") {
normalized, _ := normalizeICSDate(ex, params)
diff --git a/internal/nextcloud/calendar_test.go b/internal/nextcloud/calendar_test.go
index 55c04e8..d924ad0 100644
--- a/internal/nextcloud/calendar_test.go
+++ b/internal/nextcloud/calendar_test.go
@@ -131,8 +131,8 @@ func TestBuildICSRoundTrip(t *testing.T) {
if parsed.RRule != event.RRule {
t.Fatalf("RRule = %q", parsed.RRule)
}
- if parsed.Color != event.Color {
- t.Fatalf("Color = %q", parsed.Color)
+ if parsed.Color != "" {
+ t.Fatalf("Color should not be serialized in ICS, got %q", parsed.Color)
}
if len(parsed.ExDates) != 1 || parsed.ExDates[0] != "20260618T100000Z" {
t.Fatalf("ExDates = %v", parsed.ExDates)
@@ -145,6 +145,48 @@ func TestBuildICSRoundTrip(t *testing.T) {
}
}
+func TestMergeEventPreservesUID(t *testing.T) {
+ existing := &Event{
+ UID: "abc@ulti",
+ Summary: "Original",
+ Start: "20260611T100000Z",
+ End: "20260611T110000Z",
+ Path: "/remote.php/dav/calendars/user/personal/abc@ulti.ics",
+ MeetURL: "https://meet.example/room",
+ ExDates: []string{"20260618T100000Z"},
+ }
+ patch := &Event{
+ Summary: "Updated title",
+ Start: "20260612T100000Z",
+ End: "20260612T110000Z",
+ }
+ merged := MergeEvent(existing, patch)
+ if merged.UID != "abc@ulti" {
+ t.Fatalf("UID = %q", merged.UID)
+ }
+ if merged.Summary != "Updated title" {
+ t.Fatalf("Summary = %q", merged.Summary)
+ }
+ if merged.MeetURL != "https://meet.example/room" {
+ t.Fatalf("MeetURL should be preserved, got %q", merged.MeetURL)
+ }
+ if len(merged.ExDates) != 1 {
+ t.Fatalf("ExDates = %v", merged.ExDates)
+ }
+}
+
+func TestMergeEventUIDFromPath(t *testing.T) {
+ existing := &Event{
+ Summary: "Keep",
+ Path: "/remote.php/dav/calendars/user/personal/fallback@ulti.ics",
+ }
+ patch := &Event{Summary: "New"}
+ merged := MergeEvent(existing, patch)
+ if merged.UID != "fallback@ulti" {
+ t.Fatalf("UID = %q", merged.UID)
+ }
+}
+
func TestBuildICSAllDay(t *testing.T) {
ics := buildICS(&Event{UID: "ad", Summary: "Férié", Start: "20260714", End: "20260715", AllDay: true})
if !strings.Contains(ics, "DTSTART;VALUE=DATE:20260714") {
@@ -155,3 +197,35 @@ func TestBuildICSAllDay(t *testing.T) {
t.Fatal("AllDay should round-trip")
}
}
+
+func TestParseCalendarListNormalizesCloudPrefix(t *testing.T) {
+ basePath := "/remote.php/dav/calendars/user@example.com/"
+ raw := `
+
+
+ /cloud/remote.php/dav/calendars/user@example.com/
+ Root
+
+
+ /cloud/remote.php/dav/calendars/user@example.com/personal/
+
+ Personal
+ #1a73e8
+
+
+`
+
+ cals, err := parseCalendarList(strings.NewReader(raw), basePath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(cals) != 1 {
+ t.Fatalf("len = %d, want 1", len(cals))
+ }
+ if cals[0].ID != "personal" {
+ t.Fatalf("ID = %q, want personal", cals[0].ID)
+ }
+ if cals[0].Path != "/remote.php/dav/calendars/user@example.com/personal/" {
+ t.Fatalf("Path = %q", cals[0].Path)
+ }
+}
diff --git a/internal/nextcloud/client.go b/internal/nextcloud/client.go
index 0c92bd4..41fbe84 100644
--- a/internal/nextcloud/client.go
+++ b/internal/nextcloud/client.go
@@ -58,8 +58,15 @@ func (c *Client) webDAVDestination(davPath string) string {
return SameServerDestinationHeader(davPath)
}
+func joinBaseURL(baseURL, path string) string {
+ if !strings.HasPrefix(path, "/") {
+ path = "/" + path
+ }
+ return baseURL + path
+}
+
func (c *Client) doRequest(ctx context.Context, method, path string, body io.Reader, headers map[string]string) (*http.Response, error) {
- url := c.baseURL + path
+ url := joinBaseURL(c.baseURL, path)
req, err := http.NewRequestWithContext(ctx, method, url, body)
if err != nil {
return nil, err
@@ -84,7 +91,7 @@ func (c *Client) doAsUser(ctx context.Context, method, path string, body io.Read
return nil, err
}
- url := c.baseURL + path
+ url := joinBaseURL(c.baseURL, path)
req, err := http.NewRequestWithContext(ctx, method, url, body)
if err != nil {
return nil, err
diff --git a/internal/nextcloud/client_test.go b/internal/nextcloud/client_test.go
new file mode 100644
index 0000000..1ca0b63
--- /dev/null
+++ b/internal/nextcloud/client_test.go
@@ -0,0 +1,16 @@
+package nextcloud
+
+import "testing"
+
+func TestJoinBaseURL(t *testing.T) {
+ base := "http://nextcloud:80"
+ got := joinBaseURL(base, "remote.php/dav/calendars/user/personal/event.ics")
+ want := "http://nextcloud:80/remote.php/dav/calendars/user/personal/event.ics"
+ if got != want {
+ t.Fatalf("joinBaseURL = %q, want %q", got, want)
+ }
+ got = joinBaseURL(base, "/remote.php/dav/calendars/user/personal/event.ics")
+ if got != want {
+ t.Fatalf("joinBaseURL with slash = %q, want %q", got, want)
+ }
+}
diff --git a/internal/nextcloud/drive.go b/internal/nextcloud/drive.go
index cc75935..edb3703 100644
--- a/internal/nextcloud/drive.go
+++ b/internal/nextcloud/drive.go
@@ -15,18 +15,27 @@ import (
"time"
)
+type FileCapabilities struct {
+ Share bool `json:"share"`
+ Trash bool `json:"trash"`
+ Preview bool `json:"preview"`
+}
+
type FileInfo struct {
- Path string `json:"path"`
- Name string `json:"name"`
- Type string `json:"type"` // "file" or "directory"
- Size int64 `json:"size"`
- MimeType string `json:"mime_type"`
- LastModified string `json:"last_modified"`
- ETag string `json:"etag"`
- FileID int64 `json:"file_id,omitempty"`
- IsFavorite bool `json:"is_favorite"`
- IsShared bool `json:"is_shared"`
- Source string `json:"source,omitempty"`
+ Path string `json:"path"`
+ Name string `json:"name"`
+ Type string `json:"type"` // "file" or "directory"
+ Size int64 `json:"size"`
+ MimeType string `json:"mime_type"`
+ LastModified string `json:"last_modified"`
+ ETag string `json:"etag"`
+ FileID int64 `json:"file_id,omitempty"`
+ IsFavorite bool `json:"is_favorite"`
+ IsShared bool `json:"is_shared"`
+ Source string `json:"source,omitempty"`
+ RootKind string `json:"root_kind,omitempty"`
+ RootID string `json:"root_id,omitempty"`
+ Capabilities *FileCapabilities `json:"capabilities,omitempty"`
}
type ShareInfo struct {
diff --git a/internal/nextcloud/external_storage.go b/internal/nextcloud/external_storage.go
new file mode 100644
index 0000000..376aa62
--- /dev/null
+++ b/internal/nextcloud/external_storage.go
@@ -0,0 +1,278 @@
+package nextcloud
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "strconv"
+ "strings"
+)
+
+type ExternalMount struct {
+ ID int `json:"id"`
+ MountPoint string `json:"mount_point"`
+ Backend string `json:"backend"`
+ Status int `json:"status"`
+}
+
+type WebDAVMountConfig struct {
+ Host string `json:"host"`
+ Root string `json:"root"`
+ User string `json:"user"`
+ Password string `json:"password"`
+ Secure bool `json:"secure"`
+}
+
+// CreateUserWebDAVMount registers a WebDAV external storage mount for a user.
+func (c *Client) CreateUserWebDAVMount(ctx context.Context, userID, mountPoint string, cfg WebDAVMountConfig) (int, error) {
+ return c.createExternalMount(ctx, mountPoint, "dav", "password::password", userID, map[string]string{
+ "host": cfg.Host,
+ "root": cfg.Root,
+ "user": cfg.User,
+ "password": cfg.Password,
+ "secure": boolString(cfg.Secure),
+ })
+}
+
+// CreateGlobalWebDAVMount registers an org-wide WebDAV mount (all users).
+func (c *Client) CreateGlobalWebDAVMount(ctx context.Context, mountPoint string, cfg WebDAVMountConfig) (int, error) {
+ return c.createExternalMount(ctx, mountPoint, "dav", "password::password", "", map[string]string{
+ "host": cfg.Host,
+ "root": cfg.Root,
+ "user": cfg.User,
+ "password": cfg.Password,
+ "secure": boolString(cfg.Secure),
+ })
+}
+
+func boolString(v bool) string {
+ if v {
+ return "true"
+ }
+ return "false"
+}
+
+func (c *Client) createExternalMount(ctx context.Context, mountPoint, backend, authBackend, userID string, config map[string]string) (int, error) {
+ form := url.Values{}
+ form.Set("mountPoint", mountPoint)
+ form.Set("backend", backend)
+ form.Set("authBackend", authBackend)
+ if userID != "" {
+ form.Set("user", userID)
+ }
+ for k, v := range config {
+ form.Set("config["+k+"]", v)
+ }
+ apiPath := "/index.php/apps/files_external/api/v1/mounts?format=json"
+ resp, err := c.doRequest(ctx, "POST", apiPath, strings.NewReader(form.Encode()), map[string]string{
+ "Content-Type": "application/x-www-form-urlencoded",
+ })
+ if err != nil {
+ return 0, err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
+ return 0, fmt.Errorf("create external mount: %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
+ }
+ var payload struct {
+ OCS struct {
+ Data struct {
+ ID int `json:"id"`
+ } `json:"data"`
+ } `json:"ocs"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
+ return 0, err
+ }
+ if payload.OCS.Data.ID <= 0 {
+ return 0, fmt.Errorf("external mount create returned empty id")
+ }
+ return payload.OCS.Data.ID, nil
+}
+
+func (c *Client) DeleteExternalMount(ctx context.Context, mountID int) error {
+ apiPath := fmt.Sprintf("/index.php/apps/files_external/api/v1/mounts/%d?format=json", mountID)
+ resp, err := c.doRequest(ctx, "DELETE", apiPath, nil, ocsJSONHeaders())
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ return &HTTPStatusError{Operation: "delete external mount", StatusCode: resp.StatusCode}
+ }
+ return nil
+}
+
+func (c *Client) ListUserExternalMounts(ctx context.Context, userID string) ([]ExternalMount, error) {
+ apiPath := "/index.php/apps/files_external/api/v1/mounts?format=json"
+ resp, err := c.DoAsUser(ctx, "GET", apiPath, nil, userID, ocsJSONHeaders())
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ return nil, &HTTPStatusError{Operation: "list external mounts", StatusCode: resp.StatusCode}
+ }
+ return decodeExternalMounts(resp.Body)
+}
+
+func (c *Client) ListGlobalExternalMounts(ctx context.Context) ([]ExternalMount, error) {
+ apiPath := "/index.php/apps/files_external/globalstorages?format=json"
+ resp, err := c.doRequest(ctx, "GET", apiPath, nil, ocsJSONHeaders())
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ return nil, &HTTPStatusError{Operation: "list global external mounts", StatusCode: resp.StatusCode}
+ }
+ return decodeExternalMounts(resp.Body)
+}
+
+func decodeExternalMounts(body io.Reader) ([]ExternalMount, error) {
+ var payload struct {
+ OCS struct {
+ Data json.RawMessage `json:"data"`
+ } `json:"ocs"`
+ }
+ if err := json.NewDecoder(body).Decode(&payload); err != nil {
+ return nil, err
+ }
+ raw := payload.OCS.Data
+ if len(raw) == 0 || string(raw) == "null" {
+ return nil, nil
+ }
+ var mounts []ExternalMount
+ if err := json.Unmarshal(raw, &mounts); err == nil {
+ return mounts, nil
+ }
+ var asMap map[string]ExternalMount
+ if err := json.Unmarshal(raw, &asMap); err != nil {
+ return nil, err
+ }
+ out := make([]ExternalMount, 0, len(asMap))
+ for _, m := range asMap {
+ out = append(out, m)
+ }
+ return out, nil
+}
+
+// CreateOAuthExternalMount creates a mount using OAuth2 backend (Google, Dropbox, etc.).
+func (c *Client) CreateOAuthExternalMount(ctx context.Context, userID, mountPoint, backend, authBackend string, oauthConfig map[string]string) (int, error) {
+ config := map[string]string{
+ "configured": "false",
+ "token": "",
+ }
+ for k, v := range oauthConfig {
+ config[k] = v
+ }
+ return c.createExternalMount(ctx, mountPoint, backend, authBackend, userID, config)
+}
+
+type OAuth2StepResult struct {
+ URL string
+ Token string
+}
+
+func (c *Client) StartExternalStorageOAuth2(ctx context.Context, userID, clientID, clientSecret, redirectURI string) (string, error) {
+ result, err := c.postExternalStorageOAuth2(ctx, userID, map[string]string{
+ "step": "1",
+ "client_id": clientID,
+ "client_secret": clientSecret,
+ "redirect": redirectURI,
+ })
+ if err != nil {
+ return "", err
+ }
+ if result.URL == "" {
+ return "", fmt.Errorf("oauth2 step 1: empty authorization url")
+ }
+ return result.URL, nil
+}
+
+func (c *Client) CompleteExternalStorageOAuth2(ctx context.Context, userID, clientID, clientSecret, redirectURI, code string) (string, error) {
+ result, err := c.postExternalStorageOAuth2(ctx, userID, map[string]string{
+ "step": "2",
+ "client_id": clientID,
+ "client_secret": clientSecret,
+ "redirect": redirectURI,
+ "code": code,
+ })
+ if err != nil {
+ return "", err
+ }
+ if result.Token == "" {
+ return "", fmt.Errorf("oauth2 step 2: empty token")
+ }
+ return result.Token, nil
+}
+
+func (c *Client) UpdateUserExternalMountOAuth(ctx context.Context, userID string, mountID int, clientID, clientSecret, token string) error {
+ form := url.Values{}
+ form.Set("client_id", clientID)
+ form.Set("client_secret", clientSecret)
+ form.Set("token", token)
+ form.Set("configured", "true")
+ apiPath := fmt.Sprintf("/index.php/apps/files_external/userstorages/%d?format=json", mountID)
+ resp, err := c.DoAsUser(ctx, "PUT", apiPath, strings.NewReader(form.Encode()), userID, map[string]string{
+ "Content-Type": "application/x-www-form-urlencoded",
+ })
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
+ return fmt.Errorf("update external mount oauth: %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
+ }
+ return nil
+}
+
+func (c *Client) postExternalStorageOAuth2(ctx context.Context, userID string, fields map[string]string) (OAuth2StepResult, error) {
+ form := url.Values{}
+ for k, v := range fields {
+ form.Set(k, v)
+ }
+ apiPath := "/index.php/apps/files_external/ajax/oauth2.php"
+ resp, err := c.DoAsUser(ctx, "POST", apiPath, strings.NewReader(form.Encode()), userID, map[string]string{
+ "Content-Type": "application/x-www-form-urlencoded",
+ })
+ if err != nil {
+ return OAuth2StepResult{}, err
+ }
+ defer resp.Body.Close()
+ body, err := io.ReadAll(io.LimitReader(resp.Body, 8192))
+ if err != nil {
+ return OAuth2StepResult{}, err
+ }
+ if resp.StatusCode != http.StatusOK {
+ return OAuth2StepResult{}, fmt.Errorf("oauth2 request: %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
+ }
+ var payload struct {
+ Status string `json:"status"`
+ Data struct {
+ URL string `json:"url"`
+ Token string `json:"token"`
+ Message string `json:"message"`
+ } `json:"data"`
+ }
+ if err := json.Unmarshal(body, &payload); err != nil {
+ return OAuth2StepResult{}, fmt.Errorf("oauth2 response decode: %w", err)
+ }
+ if payload.Status != "success" {
+ msg := strings.TrimSpace(payload.Data.Message)
+ if msg == "" {
+ msg = strings.TrimSpace(string(body))
+ }
+ return OAuth2StepResult{}, fmt.Errorf("oauth2 failed: %s", msg)
+ }
+ return OAuth2StepResult{URL: payload.Data.URL, Token: payload.Data.Token}, nil
+}
+
+func ParseMountID(raw string) (int, error) {
+ return strconv.Atoi(strings.TrimSpace(raw))
+}
diff --git a/internal/nextcloud/groupfolder_dav.go b/internal/nextcloud/groupfolder_dav.go
new file mode 100644
index 0000000..f15642f
--- /dev/null
+++ b/internal/nextcloud/groupfolder_dav.go
@@ -0,0 +1,150 @@
+package nextcloud
+
+import (
+ "context"
+ "io"
+ "net/http"
+ "strings"
+)
+
+func (c *Client) ListFilesAtDAV(ctx context.Context, userID, davPath, logicalPath string) ([]FileInfo, error) {
+ body := propfindBody
+ resp, err := c.DoAsUser(ctx, "PROPFIND", davPath, strings.NewReader(body), userID, map[string]string{
+ "Depth": "1",
+ "Content-Type": "application/xml",
+ })
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != 207 {
+ return nil, &HTTPStatusError{Operation: "propfind", StatusCode: resp.StatusCode}
+ }
+ return parsePropfindResponse(resp.Body, logicalPath)
+}
+
+func (c *Client) StatFileAtDAV(ctx context.Context, userID, davPath, logicalPath string) (FileInfo, error) {
+ body := propfindBody
+ resp, err := c.DoAsUser(ctx, "PROPFIND", davPath, strings.NewReader(body), userID, map[string]string{
+ "Depth": "0",
+ "Content-Type": "application/xml",
+ })
+ if err != nil {
+ return FileInfo{}, err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != 207 {
+ return FileInfo{}, &HTTPStatusError{Operation: "propfind", StatusCode: resp.StatusCode}
+ }
+ files, err := parsePropfindResponse(resp.Body, logicalPath)
+ if err != nil {
+ return FileInfo{}, err
+ }
+ if len(files) == 0 {
+ return FileInfo{}, &HTTPStatusError{Operation: "stat", StatusCode: http.StatusNotFound}
+ }
+ return files[0], nil
+}
+
+func (c *Client) UploadAtDAV(ctx context.Context, userID, davPath, contentType string, content io.Reader) error {
+ if contentType == "" {
+ contentType = "application/octet-stream"
+ }
+ resp, err := c.DoAsUser(ctx, "PUT", davPath, content, userID, map[string]string{
+ "Content-Type": contentType,
+ })
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusNoContent {
+ return &HTTPStatusError{Operation: "upload", StatusCode: resp.StatusCode}
+ }
+ return nil
+}
+
+func (c *Client) CreateFolderAtDAV(ctx context.Context, userID, davPath string) error {
+ resp, err := c.DoAsUser(ctx, "MKCOL", davPath, nil, userID, nil)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusCreated {
+ return &HTTPStatusError{Operation: "mkcol", StatusCode: resp.StatusCode}
+ }
+ return nil
+}
+
+func (c *Client) DeleteAtDAV(ctx context.Context, userID, davPath string) error {
+ resp, err := c.DoAsUser(ctx, "DELETE", davPath, nil, userID, nil)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK {
+ return &HTTPStatusError{Operation: "delete", StatusCode: resp.StatusCode}
+ }
+ return nil
+}
+
+func (c *Client) MoveAtDAV(ctx context.Context, userID, srcDAV, destDAV string) error {
+ resp, err := c.DoAsUser(ctx, "MOVE", srcDAV, nil, userID, map[string]string{
+ "Destination": c.webDAVDestination(destDAV),
+ "Overwrite": "T",
+ })
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusNoContent {
+ return &HTTPStatusError{Operation: "move", StatusCode: resp.StatusCode}
+ }
+ return nil
+}
+
+func (c *Client) CopyAtDAV(ctx context.Context, userID, srcDAV, destDAV string) error {
+ resp, err := c.DoAsUser(ctx, "COPY", srcDAV, nil, userID, map[string]string{
+ "Destination": c.webDAVDestination(destDAV),
+ "Overwrite": "T",
+ })
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusNoContent {
+ return &HTTPStatusError{Operation: "copy", StatusCode: resp.StatusCode}
+ }
+ return nil
+}
+
+func (c *Client) DownloadAtDAV(ctx context.Context, userID, davPath string) (io.ReadCloser, string, error) {
+ resp, err := c.DoAsUser(ctx, "GET", davPath, nil, userID, nil)
+ if err != nil {
+ return nil, "", err
+ }
+ if resp.StatusCode != http.StatusOK {
+ resp.Body.Close()
+ return nil, "", &HTTPStatusError{Operation: "download", StatusCode: resp.StatusCode}
+ }
+ contentType := resp.Header.Get("Content-Type")
+ if contentType == "" {
+ contentType = "application/octet-stream"
+ }
+ return resp.Body, contentType, nil
+}
+
+const propfindBody = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+`
diff --git a/internal/nextcloud/groupfolders.go b/internal/nextcloud/groupfolders.go
new file mode 100644
index 0000000..5b275ed
--- /dev/null
+++ b/internal/nextcloud/groupfolders.go
@@ -0,0 +1,252 @@
+package nextcloud
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "strconv"
+ "strings"
+)
+
+const groupFolderAllPermissions = 31
+
+type GroupFolder struct {
+ ID int `json:"id"`
+ MountPoint string `json:"mount_point"`
+ Groups map[string]int `json:"groups"`
+ Quota int64 `json:"quota"`
+ Size int64 `json:"size"`
+ ACL bool `json:"acl"`
+ Manage []string `json:"manage"`
+}
+
+// GroupFolderWebDAVPath builds the WebDAV URL for a group folder root or subpath.
+func GroupFolderWebDAVPath(folderID int, logicalPath string) string {
+ base := fmt.Sprintf("/remote.php/dav/groupfolders/%d", folderID)
+ logical := strings.Trim(logicalPath, "/")
+ if logical == "" {
+ return base
+ }
+ parts := strings.Split(logical, "/")
+ for i, p := range parts {
+ parts[i] = url.PathEscape(p)
+ }
+ return base + "/" + strings.Join(parts, "/")
+}
+
+func (c *Client) ListGroupFolders(ctx context.Context, userID string) ([]GroupFolder, error) {
+ resp, err := c.DoAsUser(ctx, "GET", "/index.php/apps/groupfolders/folders?format=json", nil, userID, ocsJSONHeaders())
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ return nil, &HTTPStatusError{Operation: "list groupfolders", StatusCode: resp.StatusCode}
+ }
+ return decodeGroupFoldersResponse(resp.Body)
+}
+
+func (c *Client) ListGroupFoldersAdmin(ctx context.Context) ([]GroupFolder, error) {
+ resp, err := c.doRequest(ctx, "GET", "/index.php/apps/groupfolders/folders?format=json", nil, ocsJSONHeaders())
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ return nil, &HTTPStatusError{Operation: "list groupfolders admin", StatusCode: resp.StatusCode}
+ }
+ return decodeGroupFoldersResponse(resp.Body)
+}
+
+func decodeGroupFoldersResponse(body io.Reader) ([]GroupFolder, error) {
+ var payload struct {
+ OCS struct {
+ Data json.RawMessage `json:"data"`
+ } `json:"ocs"`
+ }
+ if err := json.NewDecoder(body).Decode(&payload); err != nil {
+ return nil, err
+ }
+ raw := payload.OCS.Data
+ if len(raw) == 0 || string(raw) == "[]" || string(raw) == "null" {
+ return nil, nil
+ }
+
+ var asMap map[string]GroupFolder
+ if err := json.Unmarshal(raw, &asMap); err == nil && len(asMap) > 0 {
+ out := make([]GroupFolder, 0, len(asMap))
+ for _, item := range asMap {
+ out = append(out, item)
+ }
+ return out, nil
+ }
+
+ var asList []GroupFolder
+ if err := json.Unmarshal(raw, &asList); err != nil {
+ return nil, err
+ }
+ return asList, nil
+}
+
+func (c *Client) CreateGroupFolder(ctx context.Context, mountPoint string) (int, error) {
+ form := url.Values{}
+ form.Set("mountpoint", mountPoint)
+ resp, err := c.doRequest(ctx, "POST", "/index.php/apps/groupfolders/folders?format=json", strings.NewReader(form.Encode()), map[string]string{
+ "Content-Type": "application/x-www-form-urlencoded",
+ })
+ if err != nil {
+ return 0, err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ return 0, &HTTPStatusError{Operation: "create groupfolder", StatusCode: resp.StatusCode}
+ }
+ var payload struct {
+ OCS struct {
+ Data struct {
+ ID int `json:"id"`
+ } `json:"data"`
+ } `json:"ocs"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
+ return 0, err
+ }
+ if payload.OCS.Data.ID <= 0 {
+ return 0, fmt.Errorf("groupfolder create returned empty id")
+ }
+ return payload.OCS.Data.ID, nil
+}
+
+func (c *Client) DeleteGroupFolder(ctx context.Context, folderID int) error {
+ path := fmt.Sprintf("/index.php/apps/groupfolders/folders/%d?format=json", folderID)
+ resp, err := c.doRequest(ctx, "DELETE", path, nil, ocsJSONHeaders())
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ return &HTTPStatusError{Operation: "delete groupfolder", StatusCode: resp.StatusCode}
+ }
+ return nil
+}
+
+func (c *Client) AssignGroupToFolder(ctx context.Context, folderID int, groupID string, permissions int) error {
+ if permissions <= 0 {
+ permissions = groupFolderAllPermissions
+ }
+ form := url.Values{}
+ form.Set("group", groupID)
+ path := fmt.Sprintf("/index.php/apps/groupfolders/folders/%d/groups?format=json", folderID)
+ resp, err := c.doRequest(ctx, "POST", path, strings.NewReader(form.Encode()), map[string]string{
+ "Content-Type": "application/x-www-form-urlencoded",
+ })
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ return &HTTPStatusError{Operation: "assign group to folder", StatusCode: resp.StatusCode}
+ }
+
+ formPerm := url.Values{}
+ formPerm.Set("permissions", strconv.Itoa(permissions))
+ pathPerm := fmt.Sprintf("/index.php/apps/groupfolders/folders/%d/groups/%s?format=json", folderID, url.PathEscape(groupID))
+ resp2, err := c.doRequest(ctx, "POST", pathPerm, strings.NewReader(formPerm.Encode()), map[string]string{
+ "Content-Type": "application/x-www-form-urlencoded",
+ })
+ if err != nil {
+ return err
+ }
+ defer resp2.Body.Close()
+ if resp2.StatusCode != http.StatusOK {
+ return &HTTPStatusError{Operation: "set groupfolder permissions", StatusCode: resp2.StatusCode}
+ }
+ return nil
+}
+
+func (c *Client) SetGroupFolderQuota(ctx context.Context, folderID int, quotaBytes int64) error {
+ form := url.Values{}
+ form.Set("quota", strconv.FormatInt(quotaBytes, 10))
+ path := fmt.Sprintf("/index.php/apps/groupfolders/folders/%d/quota?format=json", folderID)
+ resp, err := c.doRequest(ctx, "POST", path, strings.NewReader(form.Encode()), map[string]string{
+ "Content-Type": "application/x-www-form-urlencoded",
+ })
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ return &HTTPStatusError{Operation: "set groupfolder quota", StatusCode: resp.StatusCode}
+ }
+ return nil
+}
+
+func (c *Client) RenameGroupFolder(ctx context.Context, folderID int, mountPoint string) error {
+ form := url.Values{}
+ form.Set("mountpoint", mountPoint)
+ path := fmt.Sprintf("/index.php/apps/groupfolders/folders/%d/mountpoint?format=json", folderID)
+ resp, err := c.doRequest(ctx, "POST", path, strings.NewReader(form.Encode()), map[string]string{
+ "Content-Type": "application/x-www-form-urlencoded",
+ })
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ return &HTTPStatusError{Operation: "rename groupfolder", StatusCode: resp.StatusCode}
+ }
+ return nil
+}
+
+func (c *Client) EnsureGroup(ctx context.Context, groupID string) error {
+ groupID = strings.TrimSpace(groupID)
+ if groupID == "" {
+ return fmt.Errorf("group id is empty")
+ }
+ exists, err := c.groupExists(ctx, groupID)
+ if err != nil {
+ return err
+ }
+ if exists {
+ return nil
+ }
+ form := url.Values{}
+ form.Set("groupid", groupID)
+ resp, err := c.doRequest(ctx, "POST", "/ocs/v1.php/cloud/groups", strings.NewReader(form.Encode()), map[string]string{
+ "Content-Type": "application/x-www-form-urlencoded",
+ })
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ return &HTTPStatusError{Operation: "create group", StatusCode: resp.StatusCode}
+ }
+ return nil
+}
+
+func (c *Client) groupExists(ctx context.Context, groupID string) (bool, error) {
+ path := "/ocs/v1.php/cloud/groups/" + url.PathEscape(groupID)
+ resp, err := c.doRequest(ctx, "GET", path, nil, ocsJSONHeaders())
+ if err != nil {
+ return false, err
+ }
+ defer resp.Body.Close()
+ switch resp.StatusCode {
+ case http.StatusOK:
+ return true, nil
+ case http.StatusNotFound:
+ return false, nil
+ default:
+ return false, &HTTPStatusError{Operation: "group exists", StatusCode: resp.StatusCode}
+ }
+}
+
+func OrgGroupID(orgSlug string) string {
+ slug := strings.TrimSpace(strings.ToLower(orgSlug))
+ slug = strings.ReplaceAll(slug, " ", "-")
+ return "org-" + slug
+}
diff --git a/internal/orgpolicy/agenda.go b/internal/orgpolicy/agenda.go
new file mode 100644
index 0000000..6b51fde
--- /dev/null
+++ b/internal/orgpolicy/agenda.go
@@ -0,0 +1,79 @@
+package orgpolicy
+
+import (
+ "context"
+ "encoding/json"
+ "strings"
+
+ "github.com/jackc/pgx/v5"
+)
+
+// PublicAgendaPolicy is exposed to authenticated users (no API keys).
+type PublicAgendaPolicy struct {
+ DefaultThemeMode string `json:"default_theme_mode"`
+ EnforceOrgTheme bool `json:"enforce_org_theme"`
+ DefaultVideoProvider string `json:"default_video_provider"`
+ EnforceOrgVideoProvider bool `json:"enforce_org_video_provider"`
+ ConfiguredVideoProviders []string `json:"configured_video_providers"`
+}
+
+func defaultAgendaPolicy() map[string]any {
+ return map[string]any{
+ "default_theme_mode": "system",
+ "enforce_org_theme": false,
+ "default_video_provider": "ultimeet",
+ "enforce_org_video_provider": false,
+ "video_provider_api_keys": map[string]any{},
+ }
+}
+
+func (l *Loader) PublicAgendaPolicy(ctx context.Context) (PublicAgendaPolicy, error) {
+ var raw []byte
+ err := l.db.QueryRow(ctx, `
+ SELECT settings FROM org_settings WHERE id = $1
+ `, orgSettingsSingletonID).Scan(&raw)
+ if err != nil && err != pgx.ErrNoRows {
+ return PublicAgendaPolicy{}, err
+ }
+
+ stored := map[string]any{}
+ if len(raw) > 0 {
+ if err := json.Unmarshal(raw, &stored); err != nil {
+ return PublicAgendaPolicy{}, err
+ }
+ }
+
+ agenda, _ := stored["agenda"].(map[string]any)
+ if agenda == nil {
+ agenda = defaultAgendaPolicy()
+ }
+
+ keys, _ := agenda["video_provider_api_keys"].(map[string]any)
+ configured := []string{"ultimeet"}
+ for _, provider := range []string{"google_meet", "zoom", "teams", "jitsi"} {
+ if v, ok := keys[provider].(string); ok && strings.TrimSpace(v) != "" {
+ configured = append(configured, provider)
+ }
+ }
+ defaultProvider, _ := agenda["default_video_provider"].(string)
+ if defaultProvider == "" {
+ defaultProvider = "ultimeet"
+ }
+ themeMode, _ := agenda["default_theme_mode"].(string)
+ if themeMode == "" {
+ themeMode = "system"
+ }
+
+ return PublicAgendaPolicy{
+ DefaultThemeMode: themeMode,
+ EnforceOrgTheme: boolField(agenda, "enforce_org_theme"),
+ DefaultVideoProvider: defaultProvider,
+ EnforceOrgVideoProvider: boolField(agenda, "enforce_org_video_provider"),
+ ConfiguredVideoProviders: configured,
+ }, nil
+}
+
+func boolField(m map[string]any, key string) bool {
+ v, ok := m[key].(bool)
+ return ok && v
+}
diff --git a/internal/orgpolicy/drive.go b/internal/orgpolicy/drive.go
new file mode 100644
index 0000000..9ed0b79
--- /dev/null
+++ b/internal/orgpolicy/drive.go
@@ -0,0 +1,171 @@
+package orgpolicy
+
+import (
+ "context"
+ "encoding/json"
+ "os"
+ "strings"
+
+ "github.com/jackc/pgx/v5"
+
+ "github.com/ultisuite/ulti-backend/internal/config"
+)
+
+// PublicDrivePolicy is exposed to authenticated users (no secrets).
+type PublicDrivePolicy struct {
+ ConfiguredMountOAuthProviders []string `json:"configured_mount_oauth_providers"`
+ MountOAuthRedirectURI string `json:"mount_oauth_redirect_uri"`
+}
+
+type MountOAuthCredentials struct {
+ Enabled bool
+ ClientID string
+ ClientSecret string
+}
+
+const (
+ MountOAuthProviderGoogle = "google"
+ MountOAuthProviderDropbox = "dropbox"
+ MountOAuthProviderMicrosoft = "microsoft"
+)
+
+func defaultMountOAuthSection() map[string]any {
+ return DefaultMountOAuthSection()
+}
+
+// DefaultMountOAuthSection is the default mount OAuth admin policy shape.
+func DefaultMountOAuthSection() map[string]any {
+ return map[string]any{
+ "redirect_uri": "",
+ "google": map[string]any{
+ "enabled": false,
+ "client_id": "",
+ "client_secret": "",
+ },
+ "dropbox": map[string]any{
+ "enabled": false,
+ "client_id": "",
+ "client_secret": "",
+ },
+ "microsoft": map[string]any{
+ "enabled": false,
+ "client_id": "",
+ "client_secret": "",
+ },
+ }
+}
+
+func (l *Loader) PublicDrivePolicy(ctx context.Context) (PublicDrivePolicy, error) {
+ mountOAuth, err := l.loadMountOAuthSection(ctx)
+ if err != nil {
+ return PublicDrivePolicy{}, err
+ }
+ configured := make([]string, 0, 3)
+ for _, provider := range []string{MountOAuthProviderGoogle, MountOAuthProviderDropbox, MountOAuthProviderMicrosoft} {
+ if creds := parseMountOAuthProvider(mountOAuth, provider); creds.Enabled {
+ configured = append(configured, provider)
+ }
+ }
+ return PublicDrivePolicy{
+ ConfiguredMountOAuthProviders: configured,
+ MountOAuthRedirectURI: resolveMountOAuthRedirectURI(mountOAuth, newConfigRef(l.cfg)),
+ }, nil
+}
+
+func (l *Loader) MountOAuthCredentials(ctx context.Context, provider string) (MountOAuthCredentials, error) {
+ mountOAuth, err := l.loadMountOAuthSection(ctx)
+ if err != nil {
+ return MountOAuthCredentials{}, err
+ }
+ return parseMountOAuthProvider(mountOAuth, provider), nil
+}
+
+func (l *Loader) MountOAuthRedirectURI(ctx context.Context) (string, error) {
+ mountOAuth, err := l.loadMountOAuthSection(ctx)
+ if err != nil {
+ return "", err
+ }
+ return resolveMountOAuthRedirectURI(mountOAuth, newConfigRef(l.cfg)), nil
+}
+
+func (l *Loader) loadMountOAuthSection(ctx context.Context) (map[string]any, error) {
+ var raw []byte
+ err := l.db.QueryRow(ctx, `
+ SELECT settings FROM org_settings WHERE id = $1
+ `, orgSettingsSingletonID).Scan(&raw)
+ if err != nil && err != pgx.ErrNoRows {
+ return nil, err
+ }
+ stored := map[string]any{}
+ if len(raw) > 0 {
+ if err := json.Unmarshal(raw, &stored); err != nil {
+ return nil, err
+ }
+ }
+ filePolicies, _ := stored["file_policies"].(map[string]any)
+ if filePolicies == nil {
+ return defaultMountOAuthSection(), nil
+ }
+ mountOAuth, _ := filePolicies["mount_oauth"].(map[string]any)
+ if mountOAuth == nil {
+ return defaultMountOAuthSection(), nil
+ }
+ return mountOAuth, nil
+}
+
+func parseMountOAuthProvider(mountOAuth map[string]any, provider string) MountOAuthCredentials {
+ section, _ := mountOAuth[provider].(map[string]any)
+ if section == nil {
+ return MountOAuthCredentials{}
+ }
+ clientID := strings.TrimSpace(stringValue(section["client_id"]))
+ clientSecret := strings.TrimSpace(stringValue(section["client_secret"]))
+ enabled := boolValue(section["enabled"])
+ if !enabled {
+ return MountOAuthCredentials{}
+ }
+ if clientID == "" || clientSecret == "" {
+ return MountOAuthCredentials{}
+ }
+ return MountOAuthCredentials{
+ Enabled: true,
+ ClientID: clientID,
+ ClientSecret: clientSecret,
+ }
+}
+
+func resolveMountOAuthRedirectURI(mountOAuth map[string]any, cfg *configRef) string {
+ if uri := strings.TrimSpace(stringValue(mountOAuth["redirect_uri"])); uri != "" {
+ return uri
+ }
+ if cfg != nil {
+ if appURL := strings.TrimSpace(cfg.mailAppURL); appURL != "" {
+ return strings.TrimRight(appURL, "/") + "/drive/mounts/oauth/callback"
+ }
+ if ultidURL := strings.TrimSpace(cfg.ultidPublicURL); ultidURL != "" {
+ return strings.TrimRight(ultidURL, "/") + "/drive/mounts/oauth/callback"
+ }
+ }
+ if appURL := strings.TrimSpace(os.Getenv("MAIL_APP_URL")); appURL != "" {
+ return strings.TrimRight(appURL, "/") + "/drive/mounts/oauth/callback"
+ }
+ if appURL := strings.TrimSpace(os.Getenv("NEXT_PUBLIC_APP_URL")); appURL != "" {
+ return strings.TrimRight(appURL, "/") + "/drive/mounts/oauth/callback"
+ }
+ return "http://localhost:3004/drive/mounts/oauth/callback"
+}
+
+type configRef struct {
+ mailAppURL string
+ ultidPublicURL string
+}
+
+func newConfigRef(cfg *config.Config) *configRef {
+ if cfg == nil {
+ return nil
+ }
+ return &configRef{
+ mailAppURL: cfg.MailAppURL,
+ ultidPublicURL: cfg.UltidPublicURL,
+ }
+}
diff --git a/internal/orgpolicy/meet.go b/internal/orgpolicy/meet.go
new file mode 100644
index 0000000..0a622fc
--- /dev/null
+++ b/internal/orgpolicy/meet.go
@@ -0,0 +1,169 @@
+package orgpolicy
+
+import (
+ "context"
+ "encoding/json"
+
+ "github.com/jackc/pgx/v5"
+)
+
+// MeetPostActions configures transcript delivery after a meeting.
+type MeetPostActions struct {
+ EmailEnabled bool `json:"email_enabled"`
+ EmailRecipients string `json:"email_recipients"`
+ EmailCustomAddresses string `json:"email_custom_addresses"`
+ DriveEnabled bool `json:"drive_enabled"`
+ DriveFolderPath string `json:"drive_folder_path"`
+ LLMEnabled bool `json:"llm_enabled"`
+ LLMProviderID string `json:"llm_provider_id"`
+ LLMPrompt string `json:"llm_prompt"`
+ LLMThenEmail bool `json:"llm_then_email"`
+ LLMThenDrive bool `json:"llm_then_drive"`
+}
+
+// MeetPolicy is the organisation UltiMeet / transcription policy.
+type MeetPolicy struct {
+ TranscriptionEnabled bool `json:"transcription_enabled"`
+ TranscriptionMode string `json:"transcription_mode"`
+ TranscriptionEngine string `json:"transcription_engine"`
+ SkynetURL string `json:"skynet_url"`
+ WhisperModel string `json:"whisper_model"`
+ ExternalAPIURL string `json:"external_api_url"`
+ ExternalAPIKey string `json:"external_api_key"`
+ ExternalAPIProvider string `json:"external_api_provider"`
+ AutoStartTranscription bool `json:"auto_start_transcription"`
+ PostActions MeetPostActions `json:"post_actions"`
+}
+
+// PublicMeetPolicy is exposed to authenticated clients (no secrets).
+type PublicMeetPolicy struct {
+ TranscriptionEnabled bool `json:"transcription_enabled"`
+ TranscriptionMode string `json:"transcription_mode"`
+ AutoStartTranscription bool `json:"auto_start_transcription"`
+}
+
+func defaultMeetPolicy() map[string]any {
+ return map[string]any{
+ "transcription_enabled": false,
+ "transcription_mode": "live",
+ "transcription_engine": "faster_whisper_local",
+ "skynet_url": "http://skynet:8000",
+ "whisper_model": "tiny",
+ "external_api_url": "",
+ "external_api_provider": "openai_compatible",
+ "external_api_key": "",
+ "auto_start_transcription": false,
+ "post_actions": map[string]any{
+ "email_enabled": false,
+ "email_recipients": "organizer",
+ "email_custom_addresses": "",
+ "drive_enabled": true,
+ "drive_folder_path": "/UltiMeet/Transcripts",
+ "llm_enabled": false,
+ "llm_provider_id": "",
+ "llm_prompt": "Résume cette réunion en français : points clés, décisions et actions à suivre.",
+ "llm_then_email": true,
+ "llm_then_drive": true,
+ },
+ }
+}
+
+func (l *Loader) MeetPolicy(ctx context.Context) (MeetPolicy, error) {
+ var raw []byte
+ err := l.db.QueryRow(ctx, `
+ SELECT settings FROM org_settings WHERE id = $1
+ `, orgSettingsSingletonID).Scan(&raw)
+ if err != nil && err != pgx.ErrNoRows {
+ return MeetPolicy{}, err
+ }
+
+ stored := map[string]any{}
+ if len(raw) > 0 {
+ if err := json.Unmarshal(raw, &stored); err != nil {
+ return MeetPolicy{}, err
+ }
+ }
+
+ meet, _ := stored["meet"].(map[string]any)
+ if meet == nil {
+ meet = defaultMeetPolicy()
+ }
+
+ postRaw, _ := meet["post_actions"].(map[string]any)
+ if postRaw == nil {
+ postRaw, _ = defaultMeetPolicy()["post_actions"].(map[string]any)
+ }
+
+ mode, _ := meet["transcription_mode"].(string)
+ if mode == "" {
+ mode = "live"
+ }
+ engine, _ := meet["transcription_engine"].(string)
+ if engine == "" {
+ engine = "faster_whisper_local"
+ }
+ skynetURL, _ := meet["skynet_url"].(string)
+ if skynetURL == "" {
+ skynetURL = "http://skynet:8000"
+ }
+ whisperModel, _ := meet["whisper_model"].(string)
+ if whisperModel == "" {
+ whisperModel = "tiny"
+ }
+ provider, _ := meet["external_api_provider"].(string)
+ if provider == "" {
+ provider = "openai_compatible"
+ }
+ driveFolder, _ := postRaw["drive_folder_path"].(string)
+ if driveFolder == "" {
+ driveFolder = "/UltiMeet/Transcripts"
+ }
+ emailRecipients, _ := postRaw["email_recipients"].(string)
+ if emailRecipients == "" {
+ emailRecipients = "organizer"
+ }
+ llmPrompt, _ := postRaw["llm_prompt"].(string)
+ if llmPrompt == "" {
+ llmPrompt = "Résume cette réunion en français : points clés, décisions et actions à suivre."
+ }
+
+ return MeetPolicy{
+ TranscriptionEnabled: boolField(meet, "transcription_enabled"),
+ TranscriptionMode: mode,
+ TranscriptionEngine: engine,
+ SkynetURL: skynetURL,
+ WhisperModel: whisperModel,
+ ExternalAPIURL: stringValue(meet["external_api_url"]),
+ ExternalAPIKey: stringValue(meet["external_api_key"]),
+ ExternalAPIProvider: provider,
+ AutoStartTranscription: boolField(meet, "auto_start_transcription"),
+ PostActions: MeetPostActions{
+ EmailEnabled: boolField(postRaw, "email_enabled"),
+ EmailRecipients: emailRecipients,
+ EmailCustomAddresses: stringValue(postRaw["email_custom_addresses"]),
+ DriveEnabled: boolField(postRaw, "drive_enabled"),
+ DriveFolderPath: driveFolder,
+ LLMEnabled: boolField(postRaw, "llm_enabled"),
+ LLMProviderID: stringValue(postRaw["llm_provider_id"]),
+ LLMPrompt: llmPrompt,
+ LLMThenEmail: boolField(postRaw, "llm_then_email"),
+ LLMThenDrive: boolField(postRaw, "llm_then_drive"),
+ },
+ }, nil
+}
+
+func (l *Loader) PublicMeetPolicy(ctx context.Context) (PublicMeetPolicy, error) {
+ policy, err := l.MeetPolicy(ctx)
+ if err != nil {
+ return PublicMeetPolicy{}, err
+ }
+ return PublicMeetPolicy{
+ TranscriptionEnabled: policy.TranscriptionEnabled,
+ TranscriptionMode: policy.TranscriptionMode,
+ AutoStartTranscription: policy.AutoStartTranscription,
+ }, nil
+}
+
+func (p MeetPolicy) LiveTranscriptionJWT() bool {
+ return p.TranscriptionEnabled && p.TranscriptionMode == "live"
+}
diff --git a/internal/server/bootstrap.go b/internal/server/bootstrap.go
index 2875109..f9583e5 100644
--- a/internal/server/bootstrap.go
+++ b/internal/server/bootstrap.go
@@ -344,12 +344,18 @@ func New(ctx context.Context, cfg *config.Config, opts Options) (*App, error) {
TypesenseCollection: cfg.TypesenseCollection,
}).Search)
if driveHandler != nil {
- r.Mount("/api/v1/calendar", calendar.NewHandler(ncClient, meetCfg).Routes())
+ r.Mount("/api/v1/calendar", calendar.NewHandler(ncClient, meetCfg, orgPolicyLoader).Routes())
r.Mount("/api/v1/contacts", contactsHandler.Routes())
}
- if meetCfg != nil {
- r.Mount("/api/v1/meet", meetapi.NewHandler(meetCfg).Routes())
- }
+ r.Mount("/api/v1/meet", meetapi.NewHandler(
+ meetCfg,
+ cfg.JitsiEnabled,
+ cfg.JitsiPublicURL,
+ orgPolicyLoader,
+ pool,
+ ncClient,
+ cfg.MeetTranscriptWebhookSecret,
+ ).Routes())
if photosClient != nil {
r.Mount("/api/v1/photos", photosapi.NewHandler(photosClient, ncClient).Routes())
}
diff --git a/internal/users/avatar.go b/internal/users/avatar.go
new file mode 100644
index 0000000..7e944dd
--- /dev/null
+++ b/internal/users/avatar.go
@@ -0,0 +1,134 @@
+package users
+
+import (
+ "context"
+ "encoding/base64"
+ "errors"
+ "fmt"
+ "strings"
+
+ "github.com/jackc/pgx/v5"
+ "github.com/jackc/pgx/v5/pgxpool"
+)
+
+const maxAvatarBytes = 512 * 1024
+
+var (
+ ErrAvatarTooLarge = errors.New("avatar too large")
+ ErrAvatarInvalid = errors.New("avatar invalid")
+ ErrAvatarNotFound = errors.New("avatar not found")
+)
+
+var allowedAvatarMIME = map[string]struct{}{
+ "image/jpeg": {},
+ "image/png": {},
+ "image/gif": {},
+ "image/webp": {},
+}
+
+// GetAvatarURL returns the stored avatar URL/data URI for external_id.
+func GetAvatarURL(ctx context.Context, db *pgxpool.Pool, externalID string) (string, error) {
+ if db == nil || strings.TrimSpace(externalID) == "" {
+ return "", nil
+ }
+ var avatarURL *string
+ err := db.QueryRow(ctx, `
+ SELECT avatar_url FROM users WHERE external_id = $1
+ `, externalID).Scan(&avatarURL)
+ if errors.Is(err, pgx.ErrNoRows) {
+ return "", nil
+ }
+ if err != nil {
+ return "", err
+ }
+ if avatarURL == nil {
+ return "", nil
+ }
+ return strings.TrimSpace(*avatarURL), nil
+}
+
+// SetAvatarURL validates and stores avatar_url for external_id.
+func SetAvatarURL(ctx context.Context, db *pgxpool.Pool, externalID, avatarURL string) error {
+ if db == nil {
+ return fmt.Errorf("database not configured")
+ }
+ if strings.TrimSpace(externalID) == "" {
+ return fmt.Errorf("missing external id")
+ }
+ normalized, err := normalizeAvatarURL(avatarURL)
+ if err != nil {
+ return err
+ }
+ tag, err := db.Exec(ctx, `
+ UPDATE users
+ SET avatar_url = $2, updated_at = NOW()
+ WHERE external_id = $1
+ `, externalID, normalized)
+ if err != nil {
+ return err
+ }
+ if tag.RowsAffected() == 0 {
+ return pgx.ErrNoRows
+ }
+ return nil
+}
+
+// ClearAvatarURL removes the stored avatar for external_id.
+func ClearAvatarURL(ctx context.Context, db *pgxpool.Pool, externalID string) error {
+ if db == nil {
+ return fmt.Errorf("database not configured")
+ }
+ if strings.TrimSpace(externalID) == "" {
+ return fmt.Errorf("missing external id")
+ }
+ tag, err := db.Exec(ctx, `
+ UPDATE users
+ SET avatar_url = NULL, updated_at = NOW()
+ WHERE external_id = $1
+ `, externalID)
+ if err != nil {
+ return err
+ }
+ if tag.RowsAffected() == 0 {
+ return pgx.ErrNoRows
+ }
+ return nil
+}
+
+func normalizeAvatarURL(raw string) (string, error) {
+ trimmed := strings.TrimSpace(raw)
+ if trimmed == "" {
+ return "", ErrAvatarInvalid
+ }
+ if strings.HasPrefix(trimmed, "https://") || strings.HasPrefix(trimmed, "http://") {
+ if len(trimmed) > 2048 {
+ return "", ErrAvatarTooLarge
+ }
+ return trimmed, nil
+ }
+ if !strings.HasPrefix(trimmed, "data:") {
+ return "", ErrAvatarInvalid
+ }
+ comma := strings.Index(trimmed, ",")
+ if comma == -1 {
+ return "", ErrAvatarInvalid
+ }
+ meta := trimmed[:comma]
+ payload := strings.TrimSpace(trimmed[comma+1:])
+ if !strings.Contains(meta, ";base64") {
+ return "", ErrAvatarInvalid
+ }
+ mimePart := strings.TrimPrefix(meta, "data:")
+ mimePart = strings.Split(mimePart, ";")[0]
+ if _, ok := allowedAvatarMIME[strings.ToLower(mimePart)]; !ok {
+ return "", ErrAvatarInvalid
+ }
+ decoded, err := base64.StdEncoding.DecodeString(payload)
+ if err != nil {
+ return "", ErrAvatarInvalid
+ }
+ if len(decoded) == 0 || len(decoded) > maxAvatarBytes {
+ return "", ErrAvatarTooLarge
+ }
+ return trimmed, nil
+}
diff --git a/internal/users/avatar_test.go b/internal/users/avatar_test.go
new file mode 100644
index 0000000..99e7eee
--- /dev/null
+++ b/internal/users/avatar_test.go
@@ -0,0 +1,23 @@
+package users
+
+import "testing"
+
+func TestNormalizeAvatarURL(t *testing.T) {
+ tinyPNG := "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="
+
+ got, err := normalizeAvatarURL(tinyPNG)
+ if err != nil {
+ t.Fatalf("normalizeAvatarURL() error = %v", err)
+ }
+ if got != tinyPNG {
+ t.Fatalf("normalizeAvatarURL() = %q, want %q", got, tinyPNG)
+ }
+
+ if _, err := normalizeAvatarURL("not-a-data-uri"); err == nil {
+ t.Fatal("expected error for invalid avatar")
+ }
+
+ if _, err := normalizeAvatarURL("data:text/plain;base64,YQ=="); err == nil {
+ t.Fatal("expected error for non-image mime")
+ }
+}
diff --git a/migrations/000036_automation_agenda_scope.down.sql b/migrations/000036_automation_agenda_scope.down.sql
new file mode 100644
index 0000000..23e773d
--- /dev/null
+++ b/migrations/000036_automation_agenda_scope.down.sql
@@ -0,0 +1,5 @@
+ALTER TABLE webhook_templates
+ DROP COLUMN IF EXISTS agenda_scope;
+
+ALTER TABLE api_tokens
+ DROP COLUMN IF EXISTS agenda_scope;
diff --git a/migrations/000036_automation_agenda_scope.up.sql b/migrations/000036_automation_agenda_scope.up.sql
new file mode 100644
index 0000000..d253c74
--- /dev/null
+++ b/migrations/000036_automation_agenda_scope.up.sql
@@ -0,0 +1,5 @@
+ALTER TABLE webhook_templates
+ ADD COLUMN IF NOT EXISTS agenda_scope JSONB NOT NULL DEFAULT '{"all_calendars":true,"calendar_ids":[]}'::jsonb;
+
+ALTER TABLE api_tokens
+ ADD COLUMN IF NOT EXISTS agenda_scope JSONB NOT NULL DEFAULT '{"all_calendars":true,"calendar_ids":[]}'::jsonb;
diff --git a/migrations/000037_drive_org_folders_mounts.down.sql b/migrations/000037_drive_org_folders_mounts.down.sql
new file mode 100644
index 0000000..3966a6f
--- /dev/null
+++ b/migrations/000037_drive_org_folders_mounts.down.sql
@@ -0,0 +1,2 @@
+DROP TABLE IF EXISTS drive_mounts;
+DROP TABLE IF EXISTS drive_org_folders;
diff --git a/migrations/000037_drive_org_folders_mounts.up.sql b/migrations/000037_drive_org_folders_mounts.up.sql
new file mode 100644
index 0000000..68018ab
--- /dev/null
+++ b/migrations/000037_drive_org_folders_mounts.up.sql
@@ -0,0 +1,38 @@
+CREATE TABLE drive_org_folders (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ org_slug TEXT NOT NULL,
+ nc_folder_id INTEGER NOT NULL,
+ mount_point TEXT NOT NULL,
+ quota_bytes BIGINT,
+ auto_provisioned BOOLEAN NOT NULL DEFAULT false,
+ created_by TEXT NOT NULL DEFAULT '',
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ CONSTRAINT drive_org_folders_org_slug_uniq UNIQUE (org_slug),
+ CONSTRAINT drive_org_folders_nc_folder_id_uniq UNIQUE (nc_folder_id)
+);
+
+CREATE INDEX idx_drive_org_folders_org_slug ON drive_org_folders (org_slug);
+
+CREATE TABLE drive_mounts (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ scope TEXT NOT NULL CHECK (scope IN ('user', 'org')),
+ owner_user_id UUID REFERENCES users(id) ON DELETE CASCADE,
+ org_slug TEXT,
+ nc_mount_id INTEGER,
+ display_name TEXT NOT NULL,
+ backend_type TEXT NOT NULL,
+ mount_point TEXT NOT NULL,
+ status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'error', 'pending')),
+ last_error TEXT NOT NULL DEFAULT '',
+ config_encrypted BYTEA,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ CONSTRAINT drive_mounts_scope_owner_chk CHECK (
+ (scope = 'user' AND owner_user_id IS NOT NULL)
+ OR (scope = 'org' AND org_slug IS NOT NULL AND org_slug <> '')
+ )
+);
+
+CREATE INDEX idx_drive_mounts_owner ON drive_mounts (owner_user_id) WHERE scope = 'user';
+CREATE INDEX idx_drive_mounts_org ON drive_mounts (org_slug) WHERE scope = 'org';
diff --git a/migrations/000038_user_avatar.down.sql b/migrations/000038_user_avatar.down.sql
new file mode 100644
index 0000000..f00bf91
--- /dev/null
+++ b/migrations/000038_user_avatar.down.sql
@@ -0,0 +1 @@
+ALTER TABLE users DROP COLUMN IF EXISTS avatar_url;
diff --git a/migrations/000038_user_avatar.up.sql b/migrations/000038_user_avatar.up.sql
new file mode 100644
index 0000000..fe8b8de
--- /dev/null
+++ b/migrations/000038_user_avatar.up.sql
@@ -0,0 +1,2 @@
+ALTER TABLE users
+ ADD COLUMN IF NOT EXISTS avatar_url TEXT;
diff --git a/migrations/000039_meet_transcripts.down.sql b/migrations/000039_meet_transcripts.down.sql
new file mode 100644
index 0000000..e11b572
--- /dev/null
+++ b/migrations/000039_meet_transcripts.down.sql
@@ -0,0 +1 @@
+DROP TABLE IF EXISTS meet_transcript_jobs;
diff --git a/migrations/000039_meet_transcripts.up.sql b/migrations/000039_meet_transcripts.up.sql
new file mode 100644
index 0000000..1fb4dc9
--- /dev/null
+++ b/migrations/000039_meet_transcripts.up.sql
@@ -0,0 +1,17 @@
+CREATE TABLE IF NOT EXISTS meet_transcript_jobs (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ room_id TEXT NOT NULL,
+ organizer_user_id TEXT,
+ organizer_email TEXT,
+ mode TEXT NOT NULL DEFAULT 'live',
+ status TEXT NOT NULL DEFAULT 'pending',
+ raw_transcript TEXT NOT NULL DEFAULT '',
+ processed_transcript TEXT NOT NULL DEFAULT '',
+ participant_emails JSONB NOT NULL DEFAULT '[]'::jsonb,
+ metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+CREATE INDEX IF NOT EXISTS meet_transcript_jobs_room_id_idx ON meet_transcript_jobs (room_id);
+CREATE INDEX IF NOT EXISTS meet_transcript_jobs_status_idx ON meet_transcript_jobs (status);