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