feat(transcription): integrate Faster Whisper for Jitsi transcriptions
Some checks are pending
CI / Go tests (push) Waiting to run
CI / Integration tests (push) Waiting to run
CI / DB migrations (push) Waiting to run

- Added support for Faster Whisper transcription via Jigasi and Skynet.
- Updated .env.example to include new environment variables for transcription settings.
- Enhanced Jitsi Docker Compose configuration to include Skynet and Jigasi services.
- Introduced new API endpoints for managing organizational folders in the drive service.
- Updated Nextcloud initialization script to enable external file mounting.
- Improved error handling and response structures in the drive API.
- Added new properties for organization settings related to transcription and agenda management.
This commit is contained in:
R3D347HR4Y 2026-06-12 19:10:18 +02:00
parent 1fda9e7bac
commit 1d063237b9
66 changed files with 4764 additions and 130 deletions

View File

@ -189,6 +189,10 @@ JITSI_DOMAIN=meet.jitsi
JITSI_APP_ID=ulti JITSI_APP_ID=ulti
# JITSI_APP_SECRET — defini dans la section Secrets # JITSI_APP_SECRET — defini dans la section Secrets
JITSI_PUBLIC_URL=https://{{DOMAIN}}/meet 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}} JICOFO_AUTH_PASSWORD={{JITSI_INTERNAL_AUTH_PASSWORD}}
JVB_AUTH_PASSWORD={{JITSI_INTERNAL_AUTH_PASSWORD}} JVB_AUTH_PASSWORD={{JITSI_INTERNAL_AUTH_PASSWORD}}

View File

@ -104,7 +104,7 @@ Un seul **nginx** expose lentrée HTTP (`:80`) et route :
| `/auth/*` | Authentik | | `/auth/*` | Authentik |
| `/meet/*` | Jitsi (si `JITSI_ENABLED=true`) | | `/meet/*` | Jitsi (si `JITSI_ENABLED=true`) |
| `/cloud/*` | Nextcloud nginx+FPM (si `NEXTCLOUD_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`). 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). Caddy retiré : un seul proxy évite la double couche ; TLS plus tard (certbot, Traefik, ou `listen 443` nginx).

View File

@ -69,3 +69,45 @@ services:
- ulti-net - ulti-net
depends_on: depends_on:
- jitsi-prosody - 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:

View File

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

View File

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

View File

@ -18,6 +18,8 @@ $OCC app:enable files || true
$OCC app:enable calendar || true $OCC app:enable calendar || true
$OCC app:enable contacts || true $OCC app:enable contacts || true
$OCC app:enable groupfolders || 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 $OCC app:enable user_oidc || true
# Configure OIDC (Authentik) # Configure OIDC (Authentik)

View File

@ -107,6 +107,10 @@ server {
proxy_set_header Upgrade $http_upgrade; proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade; proxy_set_header Connection $connection_upgrade;
proxy_read_timeout 86400; proxy_read_timeout 86400;
proxy_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
# Permet lembed 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/ { location /meet/ {
@ -248,7 +252,7 @@ server {
proxy_set_header Connection $connection_upgrade; 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 # Prod: set MAIL_FRONTEND_UPSTREAM=suite-frontend:3000
# Démos publiques de la landing (zéro rétention) — frontend Next. # Démos publiques de la landing (zéro rétention) — frontend Next.
location ^~ /demo { location ^~ /demo {
@ -357,6 +361,19 @@ server {
proxy_set_header Connection $connection_upgrade; 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 # Réglages du compte Ulti
location ^~ /compte { location ^~ /compte {
resolver 127.0.0.11 valid=10s ipv6=off; resolver 127.0.0.11 valid=10s ipv6=off;
@ -398,6 +415,18 @@ server {
proxy_set_header Connection $connection_upgrade; 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/ { location ^~ /brand/ {
resolver 127.0.0.11 valid=10s ipv6=off; resolver 127.0.0.11 valid=10s ipv6=off;
set $mail_upstream ${MAIL_FRONTEND_UPSTREAM}; set $mail_upstream ${MAIL_FRONTEND_UPSTREAM};

View File

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

View File

@ -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}/test", h.TestIdentityProvider)
r.With(write).Post("/org/identity-providers/{providerID}/sync", h.SyncIdentityProvider) r.With(write).Post("/org/identity-providers/{providerID}/sync", h.SyncIdentityProvider)
h.registerDriveAdminRoutes(r, read, write)
return r return r
} }

View File

@ -10,6 +10,7 @@ import (
"github.com/ultisuite/ulti-backend/internal/config" "github.com/ultisuite/ulti-backend/internal/config"
"github.com/ultisuite/ulti-backend/internal/authentik" "github.com/ultisuite/ulti-backend/internal/authentik"
"github.com/ultisuite/ulti-backend/internal/orgpolicy"
) )
const orgSettingsSingletonID = 1 const orgSettingsSingletonID = 1
@ -59,6 +60,7 @@ func defaultOrgPolicy() map[string]any {
"virus_scan_enabled": false, "virus_scan_enabled": false,
"virustotal_api_key": "", "virustotal_api_key": "",
"retention_trash_days": 30, "retention_trash_days": 30,
"mount_oauth": orgpolicy.DefaultMountOAuthSection(),
}, },
"llm": map[string]any{ "llm": map[string]any{
"default_provider_id": "", "default_provider_id": "",
@ -121,6 +123,36 @@ func defaultOrgPolicy() map[string]any {
"chat_sync_enabled": true, "chat_sync_enabled": true,
"chat_nc_path": "/.ultimail/ai/chats", "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{ "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": "mail-automation", "name": "Automatisations mail", "description": "Règles, webhooks et tri IA sur la réception.", "enabled": true, "version": "1.0.0"},
map[string]any{"id": "contact-discovery", "name": "Découverte contacts", "description": "Enrichissement IA et signatures détectées.", "enabled": true, "version": "1.0.0"}, map[string]any{"id": "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 { if patchIDP, ok := patch["identity_providers"].(map[string]any); ok {
mergeIdentityProviderSecrets(existing, patchIDP, merged) 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 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) { func mergeLLMProviderSecrets(existing, patchLLM, merged map[string]any) {
patchProviders, _ := patchLLM["providers"].([]any) patchProviders, _ := patchLLM["providers"].([]any)
if len(patchProviders) == 0 { 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", "meilisearch_api_key")
maskStringField(cloned, "search", "typesense_api_key") maskStringField(cloned, "search", "typesense_api_key")
maskStringField(cloned, "file_policies", "virustotal_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 llm, ok := cloned["llm"].(map[string]any); ok {
if providers, ok := llm["providers"].([]any); ok { if providers, ok := llm["providers"].([]any); ok {
for i, p := range providers { for i, p := range providers {
@ -477,6 +640,28 @@ func secretConfigured(policy map[string]any, section, key string) bool {
return strings.TrimSpace(v) != "" 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 { func buildOrgSecretsStatus(policy map[string]any, cfg *config.Config) map[string]any {
secrets := map[string]any{ secrets := map[string]any{
"nextcloud_admin_password": 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{ "virustotal_api_key": map[string]any{
"configured": secretConfigured(policy, "file_policies", "virustotal_api_key") || strings.TrimSpace(cfg.VirusTotalAPIKey) != "", "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 { if idpSecrets := buildIdentityProviderSecretsStatus(policy); len(idpSecrets) > 0 {
secrets["identity_providers"] = idpSecrets secrets["identity_providers"] = idpSecrets

View File

@ -16,6 +16,7 @@ import (
"github.com/ultisuite/ulti-backend/internal/auth" "github.com/ultisuite/ulti-backend/internal/auth"
meetpkg "github.com/ultisuite/ulti-backend/internal/meet" meetpkg "github.com/ultisuite/ulti-backend/internal/meet"
"github.com/ultisuite/ulti-backend/internal/nextcloud" "github.com/ultisuite/ulti-backend/internal/nextcloud"
"github.com/ultisuite/ulti-backend/internal/orgpolicy"
"github.com/ultisuite/ulti-backend/internal/permission" "github.com/ultisuite/ulti-backend/internal/permission"
) )
@ -24,9 +25,9 @@ type Handler struct {
logger *slog.Logger 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{ return &Handler{
svc: NewService(nc, meetCfg), svc: NewService(nc, meetCfg, policy),
logger: slog.Default().With("component", "calendar-api"), logger: slog.Default().With("component", "calendar-api"),
} }
} }
@ -78,6 +79,15 @@ func (h *Handler) retryOnDAVMissing(
return op(refreshed) 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) { func (h *Handler) ListCalendars(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context()) claims := middleware.ClaimsFromContext(r.Context())
ncUser, ok := h.nextcloudUser(w, r, claims) ncUser, ok := h.nextcloudUser(w, r, claims)
@ -91,8 +101,7 @@ func (h *Handler) ListCalendars(w http.ResponseWriter, r *http.Request) {
return listErr return listErr
}) })
if err != nil { if err != nil {
h.logger.Error("list calendars", "error", err) h.writeCalendarServiceError(w, r, "list calendars", err)
apivalidate.WriteInternal(w, r)
return return
} }
apiresponse.WriteJSON(w, http.StatusOK, map[string]any{"calendars": cals}) 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) return h.svc.CreateCalendar(r.Context(), userID, normalized.ID, normalized.DisplayName, normalized.Color)
}) })
if err != nil { if err != nil {
h.logger.Error("create calendar", "error", err) h.writeCalendarServiceError(w, r, "create calendar", err)
apivalidate.WriteInternal(w, r)
return return
} }
apiresponse.WriteJSON(w, http.StatusCreated, map[string]any{"id": normalized.ID}) 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)) return h.svc.UpdateCalendar(r.Context(), userID, calID, strings.TrimSpace(req.DisplayName), strings.TrimSpace(req.Color))
}) })
if err != nil { if err != nil {
h.logger.Error("update calendar", "error", err) h.writeCalendarServiceError(w, r, "update calendar", err)
apivalidate.WriteInternal(w, r)
return return
} }
w.WriteHeader(http.StatusNoContent) 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) return h.svc.DeleteCalendar(r.Context(), userID, calID)
}) })
if err != nil { if err != nil {
h.logger.Error("delete calendar", "error", err) h.writeCalendarServiceError(w, r, "delete calendar", err)
apivalidate.WriteInternal(w, r)
return return
} }
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
@ -188,8 +194,7 @@ func (h *Handler) ListEvents(w http.ResponseWriter, r *http.Request) {
return listErr return listErr
}) })
if err != nil { if err != nil {
h.logger.Error("list events", "error", err) h.writeCalendarServiceError(w, r, "list events", err)
apivalidate.WriteInternal(w, r)
return return
} }
apiresponse.WriteJSON(w, http.StatusOK, result) apiresponse.WriteJSON(w, http.StatusOK, result)
@ -219,8 +224,7 @@ func (h *Handler) FreeBusy(w http.ResponseWriter, r *http.Request) {
return fbErr return fbErr
}) })
if err != nil { if err != nil {
h.logger.Error("free/busy", "error", err) h.writeCalendarServiceError(w, r, "free/busy", err)
apivalidate.WriteInternal(w, r)
return return
} }
apiresponse.WriteJSON(w, http.StatusOK, result) 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) return h.svc.CreateEvent(r.Context(), userID, calID, &event)
}) })
if err != nil { if err != nil {
h.logger.Error("create event", "error", err) h.writeCalendarServiceError(w, r, "create event", err)
apivalidate.WriteInternal(w, r)
return return
} }
w.WriteHeader(http.StatusCreated) 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) apiresponse.WriteError(w, r, http.StatusPreconditionFailed, "etag_mismatch", "etag does not match current resource version", nil)
return return
} }
h.logger.Error("update event", "error", err) h.writeCalendarServiceError(w, r, "update event", err)
apivalidate.WriteInternal(w, r)
return return
} }
apiresponse.WriteJSON(w, http.StatusOK, map[string]any{"etag": etag}) 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) return h.svc.DeleteEvent(r.Context(), userID, eventPath)
}) })
if err != nil { if err != nil {
h.logger.Error("delete event", "error", err) h.writeCalendarServiceError(w, r, "delete event", err)
apivalidate.WriteInternal(w, r)
return return
} }
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)

View File

@ -12,17 +12,19 @@ import (
"github.com/ultisuite/ulti-backend/internal/auth" "github.com/ultisuite/ulti-backend/internal/auth"
meetpkg "github.com/ultisuite/ulti-backend/internal/meet" meetpkg "github.com/ultisuite/ulti-backend/internal/meet"
"github.com/ultisuite/ulti-backend/internal/nextcloud" "github.com/ultisuite/ulti-backend/internal/nextcloud"
"github.com/ultisuite/ulti-backend/internal/orgpolicy"
) )
type Service struct { type Service struct {
nc *nextcloud.Client nc *nextcloud.Client
meetCfg *meetpkg.Config meetCfg *meetpkg.Config
policy *orgpolicy.Loader
} }
var ErrMeetDisabled = errors.New("meet is disabled") var ErrMeetDisabled = errors.New("meet is disabled")
func NewService(nc *nextcloud.Client, meetCfg *meetpkg.Config) *Service { func NewService(nc *nextcloud.Client, meetCfg *meetpkg.Config, policy *orgpolicy.Loader) *Service {
return &Service{nc: nc, meetCfg: meetCfg} return &Service{nc: nc, meetCfg: meetCfg, policy: policy}
} }
func (s *Service) EnsureNextcloudUser(ctx context.Context, claims *auth.Claims) (string, error) { 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) { func (s *Service) UpdateEvent(ctx context.Context, userID, eventPath, ifMatch string, event *nextcloud.Event) (string, error) {
if strings.TrimSpace(event.Organizer) == "" { existing, err := s.nc.GetEvent(ctx, userID, eventPath)
event.Organizer = userID 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 { 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 == "" { if roomID == "" {
roomID = fmt.Sprintf("event-%d", time.Now().Unix()) 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{ token, err := s.meetCfg.GenerateToken(roomID, &meetpkg.UserInfo{
ID: userID, ID: userID,
Name: userName, Name: userName,
Email: userEmail, Email: userEmail,
IsMod: true, IsMod: true,
}, 24*time.Hour) }, 24*time.Hour, tokenOpts)
if err != nil { if err != nil {
return "", "", err return "", "", err
} }

View File

@ -108,6 +108,8 @@ func (h *Handler) Routes() chi.Router {
r.With(write).Put("/shares/{shareID}", h.UpdateShare) r.With(write).Put("/shares/{shareID}", h.UpdateShare)
r.With(write).Delete("/shares/{shareID}", h.DeleteShare) r.With(write).Delete("/shares/{shareID}", h.DeleteShare)
h.registerOrgAndMountRoutes(r, read, write)
return r return r
} }
@ -348,7 +350,23 @@ func (h *Handler) Move(w http.ResponseWriter, r *http.Request) {
return 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) h.logger.Error("move", "error", err)
writeDriveError(w, r, err) writeDriveError(w, r, err)
return return
@ -376,7 +394,23 @@ func (h *Handler) Copy(w http.ResponseWriter, r *http.Request) {
return 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) h.logger.Error("copy", "error", err)
writeDriveError(w, r, err) writeDriveError(w, r, err)
return return
@ -404,7 +438,18 @@ func (h *Handler) Rename(w http.ResponseWriter, r *http.Request) {
return 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) h.logger.Error("rename", "error", err)
writeDriveError(w, r, err) writeDriveError(w, r, err)
return return
@ -513,6 +558,11 @@ func (h *Handler) CreateShare(w http.ResponseWriter, r *http.Request) {
apivalidate.WriteValidationError(w, r, verr) apivalidate.WriteValidationError(w, r, verr)
return return
} }
shareRef, err := pathRefFromShare(&req)
if err != nil {
writeDriveError(w, r, err)
return
}
permissions := req.Permissions permissions := req.Permissions
if permissions == 0 && strings.TrimSpace(req.Role) != "" { 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 { if err != nil {
h.logger.Error("create share", "error", err) h.logger.Error("create share", "error", err)
writeDriveError(w, r, err) writeDriveError(w, r, err)
@ -583,7 +633,14 @@ func (h *Handler) ListShares(w http.ResponseWriter, r *http.Request) {
)) ))
return 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 { if err != nil {
writeDriveError(w, r, err) writeDriveError(w, r, err)
return return
@ -749,7 +806,12 @@ func (h *Handler) SetFavorite(w http.ResponseWriter, r *http.Request) {
apivalidate.WriteValidationError(w, r, verr) apivalidate.WriteValidationError(w, r, verr)
return 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) writeDriveError(w, r, err)
return 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) apiresponse.WriteError(w, r, http.StatusUnprocessableEntity, "drive.malware_detected", "malware detected in file", nil)
case errors.Is(err, ErrInvalid): case errors.Is(err, ErrInvalid):
apiresponse.WriteError(w, r, http.StatusBadRequest, apiresponse.CodeInvalidRequest, "invalid request body", nil) 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: default:
apivalidate.WriteInternal(w, r) apivalidate.WriteInternal(w, r)
} }

View File

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

View File

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

View File

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

View File

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

View File

@ -18,6 +18,7 @@ import (
"github.com/ultisuite/ulti-backend/internal/api/query" "github.com/ultisuite/ulti-backend/internal/api/query"
"github.com/ultisuite/ulti-backend/internal/auth" "github.com/ultisuite/ulti-backend/internal/auth"
"github.com/ultisuite/ulti-backend/internal/automation" "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/filescan"
"github.com/ultisuite/ulti-backend/internal/mail/rules" "github.com/ultisuite/ulti-backend/internal/mail/rules"
"github.com/ultisuite/ulti-backend/internal/nextcloud" "github.com/ultisuite/ulti-backend/internal/nextcloud"
@ -38,6 +39,7 @@ type Service struct {
nc *nextcloud.Client nc *nextcloud.Client
hub *realtime.Hub hub *realtime.Hub
db *pgxpool.Pool db *pgxpool.Pool
store *drivestore.Store
automation driveAutomation automation driveAutomation
scanner *filescan.Scanner scanner *filescan.Scanner
maxUploadBytes int64 maxUploadBytes int64
@ -49,13 +51,17 @@ type driveAutomation interface {
} }
func NewService(nc *nextcloud.Client, hub *realtime.Hub, db *pgxpool.Pool) *Service { func NewService(nc *nextcloud.Client, hub *realtime.Hub, db *pgxpool.Pool) *Service {
return &Service{ s := &Service{
nc: nc, nc: nc,
hub: hub, hub: hub,
db: db, db: db,
maxUploadBytes: envInt64("ULTID_DRIVE_MAX_UPLOAD_BYTES", 0), maxUploadBytes: envInt64("ULTID_DRIVE_MAX_UPLOAD_BYTES", 0),
quotaReserveByte: envInt64("ULTID_DRIVE_QUOTA_RESERVED_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) { func (s *Service) EnsureNextcloudUser(ctx context.Context, claims *auth.Claims) (string, error) {

View File

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

View File

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

View File

@ -1,26 +1,55 @@
package drive package drive
import ( import (
"net/url"
"strings" "strings"
"github.com/ultisuite/ulti-backend/internal/api/apivalidate" "github.com/ultisuite/ulti-backend/internal/api/apivalidate"
"github.com/ultisuite/ulti-backend/internal/nextcloud"
) )
const maxJSONRequestBody = 32 << 10 const maxJSONRequestBody = 32 << 10
type moveRequest struct { type moveRequest struct {
Source string `json:"source"` Source string `json:"source"`
Destination string `json:"destination"` 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 { type copyRequest struct {
Source string `json:"source"` Source string `json:"source"`
Destination string `json:"destination"` 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 { type renameRequest struct {
Path string `json:"path"` Path string `json:"path"`
NewName string `json:"new_name"` 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 { func validateMoveRequest(req *moveRequest) *apivalidate.ValidationError {
@ -77,6 +106,8 @@ type createShareRequest struct {
ShareWith string `json:"share_with"` ShareWith string `json:"share_with"`
Note string `json:"note"` Note string `json:"note"`
SendMail *bool `json:"send_mail"` SendMail *bool `json:"send_mail"`
Root string `json:"root,omitempty"`
RootID string `json:"root_id,omitempty"`
} }
func sharePermissionsForRole(role string) (int, bool) { func sharePermissionsForRole(role string) (int, bool) {
@ -144,11 +175,6 @@ func validateDeleteTrashRequest(req *deleteTrashRequest) *apivalidate.Validation
return nil return nil
} }
type favoriteRequest struct {
Path string `json:"path"`
Favorite bool `json:"favorite"`
}
func validateFavoriteRequest(req *favoriteRequest) *apivalidate.ValidationError { func validateFavoriteRequest(req *favoriteRequest) *apivalidate.ValidationError {
if strings.TrimSpace(req.Path) == "" { if strings.TrimSpace(req.Path) == "" {
return apivalidate.NewValidationError(apivalidate.FieldDetail{ return apivalidate.NewValidationError(apivalidate.FieldDetail{
@ -194,3 +220,24 @@ func validatePath(path string) *apivalidate.ValidationError {
} }
return nil 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
}

View File

@ -20,6 +20,7 @@ type createApiTokenRequest struct {
Permissions []apitokens.PermissionGrant `json:"permissions"` Permissions []apitokens.PermissionGrant `json:"permissions"`
MailScope apitokens.MailScope `json:"mail_scope"` MailScope apitokens.MailScope `json:"mail_scope"`
DriveScope apitokens.DriveScope `json:"drive_scope"` DriveScope apitokens.DriveScope `json:"drive_scope"`
AgendaScope apitokens.AgendaScope `json:"agenda_scope"`
ExpiresAt *time.Time `json:"expires_at,omitempty"` ExpiresAt *time.Time `json:"expires_at,omitempty"`
} }
@ -75,6 +76,7 @@ func (h *Handler) CreateApiToken(w http.ResponseWriter, r *http.Request) {
req.Permissions, req.Permissions,
normalizeMailScope(req.MailScope), normalizeMailScope(req.MailScope),
normalizeDriveScope(req.DriveScope), normalizeDriveScope(req.DriveScope),
normalizeAgendaScope(req.AgendaScope),
req.ExpiresAt, req.ExpiresAt,
) )
if err != nil { if err != nil {
@ -129,6 +131,13 @@ func normalizeDriveScope(scope apitokens.DriveScope) apitokens.DriveScope {
return scope 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 { func (h *Handler) db() *pgxpool.Pool {
if s, ok := h.svc.(*Service); ok { if s, ok := h.svc.(*Service); ok {
return s.DB() return s.DB()

View File

@ -724,7 +724,7 @@ func (s *Service) ListWebhooks(ctx context.Context, externalID string, params qu
} }
rows, err := s.db.Query(ctx, ` 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 FROM webhook_templates
WHERE user_id = (SELECT id FROM users WHERE external_id = $1) WHERE user_id = (SELECT id FROM users WHERE external_id = $1)
ORDER BY created_at ASC 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 id, name, url, method, bodyTemplate string
var version int var version int
var isActive bool var isActive bool
var eventTypes, mailScope, driveScope, contactsScope []byte var eventTypes, mailScope, driveScope, contactsScope, agendaScope []byte
if err := rows.Scan(&id, &name, &url, &method, &version, &isActive, &bodyTemplate, &eventTypes, &mailScope, &driveScope, &contactsScope); err != nil { if err := rows.Scan(&id, &name, &url, &method, &version, &isActive, &bodyTemplate, &eventTypes, &mailScope, &driveScope, &contactsScope, &agendaScope); err != nil {
return WebhooksList{}, err return WebhooksList{}, err
} }
webhooks = append(webhooks, map[string]any{ 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), "mail_scope": jsonRawOrEmptyObject(mailScope),
"drive_scope": jsonRawOrEmptyObject(driveScope), "drive_scope": jsonRawOrEmptyObject(driveScope),
"contacts_scope": jsonRawOrEmptyObject(contactsScope), "contacts_scope": jsonRawOrEmptyObject(contactsScope),
"agenda_scope": jsonRawOrEmptyObject(agendaScope),
}) })
} }
if err := rows.Err(); err != nil { if err := rows.Err(); err != nil {
@ -795,20 +796,24 @@ func (s *Service) CreateWebhook(ctx context.Context, externalID string, req *cre
if err != nil { if err != nil {
return "", err return "", err
} }
agendaScopeJSON, err := marshalWebhookAgendaScope(req.AgendaScope)
if err != nil {
return "", err
}
var id string var id string
err = s.db.QueryRow(ctx, ` err = s.db.QueryRow(ctx, `
INSERT INTO webhook_templates ( INSERT INTO webhook_templates (
user_id, name, url, method, headers, body_template, version, signing_secret, max_retries, 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 ( VALUES (
(SELECT id FROM users WHERE external_id = $1), $2, $3, $4, $5, $6, 1, $7, $8, (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 RETURNING id
`, externalID, req.Name, req.URL, method, headersJSON, req.BodyTemplate, req.SigningSecret, maxRetries, `, 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 { if err != nil {
return "", err return "", err
} }

View File

@ -35,6 +35,10 @@ func (s *Service) UpdateWebhook(ctx context.Context, externalID, webhookID strin
if err != nil { if err != nil {
return err return err
} }
agendaScopeJSON, err := marshalWebhookAgendaScope(req.AgendaScope)
if err != nil {
return err
}
err = tx.QueryRow(ctx, ` err = tx.QueryRow(ctx, `
UPDATE webhook_templates UPDATE webhook_templates
@ -50,13 +54,14 @@ func (s *Service) UpdateWebhook(ctx context.Context, externalID, webhookID strin
mail_scope = $9, mail_scope = $9,
drive_scope = $10, drive_scope = $10,
contacts_scope = $11, contacts_scope = $11,
agenda_scope = $12,
version = version + 1, version = version + 1,
updated_at = NOW() updated_at = NOW()
WHERE id = $12 WHERE id = $13
AND user_id = (SELECT id FROM users WHERE external_id = $13) AND user_id = (SELECT id FROM users WHERE external_id = $14)
RETURNING version RETURNING version
`, req.Name, req.URL, method, headersJSON, req.BodyTemplate, req.SigningSecret, maxRetries, `, 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 err != nil {
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
return ErrNotFound return ErrNotFound

View File

@ -609,6 +609,7 @@ type createWebhookRequest struct {
MailScope *webhookMailScope `json:"mail_scope"` MailScope *webhookMailScope `json:"mail_scope"`
DriveScope *webhookDriveScope `json:"drive_scope"` DriveScope *webhookDriveScope `json:"drive_scope"`
ContactsScope *webhookContactsScope `json:"contacts_scope"` ContactsScope *webhookContactsScope `json:"contacts_scope"`
AgendaScope *webhookAgendaScope `json:"agenda_scope"`
} }
type webhookMailScope struct { type webhookMailScope struct {
@ -626,6 +627,11 @@ type webhookContactsScope struct {
BookIDs []string `json:"book_ids"` BookIDs []string `json:"book_ids"`
} }
type webhookAgendaScope struct {
AllCalendars bool `json:"all_calendars"`
CalendarIDs []string `json:"calendar_ids"`
}
type updateWebhookRequest struct { type updateWebhookRequest struct {
Name string `json:"name"` Name string `json:"name"`
URL string `json:"url"` URL string `json:"url"`
@ -638,6 +644,7 @@ type updateWebhookRequest struct {
MailScope *webhookMailScope `json:"mail_scope"` MailScope *webhookMailScope `json:"mail_scope"`
DriveScope *webhookDriveScope `json:"drive_scope"` DriveScope *webhookDriveScope `json:"drive_scope"`
ContactsScope *webhookContactsScope `json:"contacts_scope"` ContactsScope *webhookContactsScope `json:"contacts_scope"`
AgendaScope *webhookAgendaScope `json:"agenda_scope"`
} }
type previewWebhookMessageRequest struct { type previewWebhookMessageRequest struct {

View File

@ -48,3 +48,15 @@ func marshalWebhookContactsScope(scope *webhookContactsScope) ([]byte, error) {
} }
return json.Marshal(out) 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)
}

View File

@ -3,35 +3,78 @@ package meet
import ( import (
"log/slog" "log/slog"
"net/http" "net/http"
"strings"
"github.com/go-chi/chi/v5" "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/apiresponse"
"github.com/ultisuite/ulti-backend/internal/api/apivalidate" "github.com/ultisuite/ulti-backend/internal/api/apivalidate"
"github.com/ultisuite/ulti-backend/internal/api/middleware" "github.com/ultisuite/ulti-backend/internal/api/middleware"
"github.com/ultisuite/ulti-backend/internal/auth" "github.com/ultisuite/ulti-backend/internal/auth"
meetpkg "github.com/ultisuite/ulti-backend/internal/meet" 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 { type Handler struct {
svc *Service svc *Service
logger *slog.Logger 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{ return &Handler{
svc: NewService(meetCfg), svc: NewService(meetCfg, policy),
logger: slog.Default().With("component", "meet-api"), 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 { func (h *Handler) Routes() chi.Router {
r := chi.NewRouter() r := chi.NewRouter()
r.Post("/rooms", h.CreateRoom) r.Get("/config", h.GetConfig)
r.Post("/rooms/{roomID}/token", h.GetToken) 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 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 { func meetUser(claims *auth.Claims) *meetpkg.UserInfo {
return &meetpkg.UserInfo{ return &meetpkg.UserInfo{
ID: claims.Sub, ID: claims.Sub,
@ -41,6 +84,10 @@ func meetUser(claims *auth.Claims) *meetpkg.UserInfo {
} }
func (h *Handler) CreateRoom(w http.ResponseWriter, r *http.Request) { 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()) claims := middleware.ClaimsFromContext(r.Context())
var req createRoomRequest var req createRoomRequest
@ -56,7 +103,7 @@ func (h *Handler) CreateRoom(w http.ResponseWriter, r *http.Request) {
user := meetUser(claims) user := meetUser(claims)
user.IsMod = true user.IsMod = true
token, err := h.svc.CreateRoom(req.Name, user) token, err := h.svc.CreateRoom(r.Context(), req.Name, user)
if err != nil { if err != nil {
h.logger.Error("create room token", "error", err) h.logger.Error("create room token", "error", err)
apivalidate.WriteInternal(w, r) 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) { 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()) claims := middleware.ClaimsFromContext(r.Context())
roomID := chi.URLParam(r, "roomID") roomID := chi.URLParam(r, "roomID")
if verr := validateRoomID(roomID); verr != nil { if verr := validateRoomID(roomID); verr != nil {
@ -76,7 +127,7 @@ func (h *Handler) GetToken(w http.ResponseWriter, r *http.Request) {
user := meetUser(claims) user := meetUser(claims)
user.IsMod = false user.IsMod = false
token, err := h.svc.GetToken(roomID, user) token, err := h.svc.GetToken(r.Context(), roomID, user)
if err != nil { if err != nil {
h.logger.Error("get room token", "error", err) h.logger.Error("get room token", "error", err)
apivalidate.WriteInternal(w, r) apivalidate.WriteInternal(w, r)
@ -84,3 +135,50 @@ func (h *Handler) GetToken(w http.ResponseWriter, r *http.Request) {
} }
apiresponse.WriteJSON(w, http.StatusOK, token) 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})
}

View File

@ -1,30 +1,46 @@
package meet package meet
import ( import (
"context"
"strings" "strings"
"time" "time"
"github.com/google/uuid" "github.com/google/uuid"
meetpkg "github.com/ultisuite/ulti-backend/internal/meet" meetpkg "github.com/ultisuite/ulti-backend/internal/meet"
"github.com/ultisuite/ulti-backend/internal/orgpolicy"
) )
type Service struct { type Service struct {
cfg *meetpkg.Config cfg *meetpkg.Config
policy *orgpolicy.Loader
} }
func NewService(cfg *meetpkg.Config) *Service { func NewService(cfg *meetpkg.Config, policy *orgpolicy.Loader) *Service {
return &Service{cfg: cfg} 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] roomID := uuid.New().String()[:8]
if strings.TrimSpace(name) != "" { if strings.TrimSpace(name) != "" {
roomID = 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) { func (s *Service) GetToken(ctx context.Context, roomID string, user *meetpkg.UserInfo) (*meetpkg.RoomToken, error) {
return s.cfg.GenerateToken(roomID, user, 4*time.Hour) return s.cfg.GenerateToken(roomID, user, 4*time.Hour, s.tokenOpts(ctx, user))
} }

View File

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

View File

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

View File

@ -1,10 +1,13 @@
package users package users
import ( import (
"encoding/json"
"errors"
"log/slog" "log/slog"
"net/http" "net/http"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
"github.com/ultisuite/ulti-backend/internal/api/apiresponse" "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/api/middleware"
"github.com/ultisuite/ulti-backend/internal/permission" "github.com/ultisuite/ulti-backend/internal/permission"
platformusers "github.com/ultisuite/ulti-backend/internal/users" platformusers "github.com/ultisuite/ulti-backend/internal/users"
"github.com/ultisuite/ulti-backend/internal/orgpolicy"
) )
type Handler struct { type Handler struct {
db *pgxpool.Pool db *pgxpool.Pool
logger *slog.Logger logger *slog.Logger
orgPolicy *orgpolicy.Loader
} }
func NewHandler(db *pgxpool.Pool) *Handler { func NewHandler(db *pgxpool.Pool) *Handler {
return &Handler{ return &Handler{
db: db, db: db,
logger: slog.Default().With("component", "users-api"), orgPolicy: orgpolicy.NewLoader(db, nil),
logger: slog.Default().With("component", "users-api"),
} }
} }
func (h *Handler) Routes() chi.Router { func (h *Handler) Routes() chi.Router {
r := chi.NewRouter() r := chi.NewRouter()
r.Get("/me", h.Me) r.Get("/me", h.Me)
r.Put("/me/avatar", h.PutAvatar)
r.Delete("/me/avatar", h.DeleteAvatar)
return r return r
} }
@ -47,7 +55,28 @@ func (h *Handler) Me(w http.ResponseWriter, r *http.Request) {
} }
role := permission.DeriveAccountRole(state.PlatformAdmin, state.Status) 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, "sub": claims.Sub,
"email": claims.Email, "email": claims.Email,
"name": claims.Name, "name": claims.Name,
@ -55,5 +84,67 @@ func (h *Handler) Me(w http.ResponseWriter, r *http.Request) {
"platform_admin": state.PlatformAdmin, "platform_admin": state.PlatformAdmin,
"role": role, "role": role,
"groups": claims.Groups, "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})
}

View File

@ -33,7 +33,7 @@ func CreateChatSession(ctx context.Context, db *pgxpool.Pool, externalID, email
expiresAt := time.Now().UTC().Add(in.TTL) expiresAt := time.Now().UTC().Add(in.TTL)
perms, mailScope, driveScope := chatSessionGrants(in) perms, mailScope, driveScope := chatSessionGrants(in)
name := fmt.Sprintf("UltiAI session %s", time.Now().UTC().Format("2006-01-02 15:04")) 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) { func chatSessionGrants(in ChatSessionInput) ([]PermissionGrant, MailScope, DriveScope) {

View File

@ -82,6 +82,9 @@ func RequirementForRequest(method, fullPath, typesQuery string) (Requirement, bo
case strings.HasPrefix(path, "/api/v1/drive/"): case strings.HasPrefix(path, "/api/v1/drive/"):
return driveRequirement(method, path) return driveRequirement(method, path)
case strings.HasPrefix(path, "/api/v1/calendar/"):
return calendarRequirement(method, path)
case strings.HasPrefix(path, "/api/v1/richtext/"): case strings.HasPrefix(path, "/api/v1/richtext/"):
return richtextRequirement(method, path) 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) { func searchRequirement(typesQuery string) (Requirement, bool) {
types := parseSearchTypes(typesQuery) types := parseSearchTypes(typesQuery)
if len(types) == 0 { if len(types) == 0 {

View File

@ -33,6 +33,15 @@ func AllowsDrivePath(auth *AuthContext, rawPath string) bool {
if target == "" { if target == "" {
return true 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 { for _, allowed := range auth.DriveScope.FolderPaths {
if drivePathWithinScope(target, allowed) { if drivePathWithinScope(target, allowed) {
return true return true
@ -41,6 +50,14 @@ func AllowsDrivePath(auth *AuthContext, rawPath string) bool {
return false 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 { func NormalizeDriveScopePath(rawPath string) string {
rawPath = strings.TrimSpace(rawPath) rawPath = strings.TrimSpace(rawPath)
if rawPath == "" { if rawPath == "" {
@ -67,3 +84,18 @@ func drivePathWithinScope(target, allowed string) bool {
} }
return strings.HasPrefix(target, allowed+"/") 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
}

View File

@ -44,6 +44,11 @@ type DriveScope struct {
FolderPaths []string `json:"folder_paths"` FolderPaths []string `json:"folder_paths"`
} }
type AgendaScope struct {
AllCalendars bool `json:"all_calendars"`
CalendarIDs []string `json:"calendar_ids"`
}
type Token struct { type Token struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
@ -51,6 +56,7 @@ type Token struct {
Permissions []PermissionGrant `json:"permissions"` Permissions []PermissionGrant `json:"permissions"`
MailScope MailScope `json:"mail_scope"` MailScope MailScope `json:"mail_scope"`
DriveScope DriveScope `json:"drive_scope"` DriveScope DriveScope `json:"drive_scope"`
AgendaScope AgendaScope `json:"agenda_scope"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
LastUsedAt *time.Time `json:"last_used_at,omitempty"` LastUsedAt *time.Time `json:"last_used_at,omitempty"`
ExpiresAt *time.Time `json:"expires_at,omitempty"` ExpiresAt *time.Time `json:"expires_at,omitempty"`
@ -70,6 +76,7 @@ type AuthContext struct {
Permissions []PermissionGrant Permissions []PermissionGrant
MailScope MailScope MailScope MailScope
DriveScope DriveScope DriveScope DriveScope
AgendaScope AgendaScope
} }
func HashSecret(secret string) []byte { 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) { func List(ctx context.Context, db *pgxpool.Pool, externalID string) ([]Token, error) {
rows, err := db.Query(ctx, ` 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 t.created_at, t.last_used_at, t.expires_at
FROM api_tokens t FROM api_tokens t
JOIN users u ON u.id = t.user_id 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() 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() secret, prefix, err := generateSecret()
if err != nil { if err != nil {
return CreatedToken{}, err return CreatedToken{}, err
@ -131,24 +138,29 @@ func Create(ctx context.Context, db *pgxpool.Pool, externalID string, name strin
if err != nil { if err != nil {
return CreatedToken{}, err return CreatedToken{}, err
} }
agendaJSON, err := json.Marshal(agendaScope)
if err != nil {
return CreatedToken{}, err
}
var item Token var item Token
err = db.QueryRow(ctx, ` err = db.QueryRow(ctx, `
INSERT INTO api_tokens ( 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 ( VALUES (
(SELECT id FROM users WHERE external_id = $1), (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 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, expiresAt).Scan( `, externalID, name, prefix, HashSecret(secret), permJSON, mailJSON, driveJSON, agendaJSON, expiresAt).Scan(
&item.ID, &item.ID,
&item.Name, &item.Name,
&item.TokenPrefix, &item.TokenPrefix,
&permJSON, &permJSON,
&mailJSON, &mailJSON,
&driveJSON, &driveJSON,
&agendaJSON,
&item.CreatedAt, &item.CreatedAt,
&item.LastUsedAt, &item.LastUsedAt,
&item.ExpiresAt, &item.ExpiresAt,
@ -156,7 +168,7 @@ func Create(ctx context.Context, db *pgxpool.Pool, externalID string, name strin
if err != nil { if err != nil {
return CreatedToken{}, err 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 return CreatedToken{}, err
} }
@ -189,7 +201,7 @@ func Authenticate(ctx context.Context, db *pgxpool.Pool, secret string) (*AuthCo
hash := HashSecret(secret) hash := HashSecret(secret)
row := db.QueryRow(ctx, ` row := db.QueryRow(ctx, `
SELECT t.id, u.id::text, u.external_id, u.email, COALESCE(u.name, ''), 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 FROM api_tokens t
JOIN users u ON u.id = t.user_id JOIN users u ON u.id = t.user_id
WHERE t.secret_hash = $1 WHERE t.secret_hash = $1
@ -197,7 +209,7 @@ func Authenticate(ctx context.Context, db *pgxpool.Pool, secret string) (*AuthCo
`, hash) `, hash)
var auth AuthContext var auth AuthContext
var permJSON, mailJSON, driveJSON []byte var permJSON, mailJSON, driveJSON, agendaJSON []byte
var expiresAt *time.Time var expiresAt *time.Time
var revokedAt *time.Time var revokedAt *time.Time
if err := row.Scan( if err := row.Scan(
@ -209,6 +221,7 @@ func Authenticate(ctx context.Context, db *pgxpool.Pool, secret string) (*AuthCo
&permJSON, &permJSON,
&mailJSON, &mailJSON,
&driveJSON, &driveJSON,
&agendaJSON,
&expiresAt, &expiresAt,
&revokedAt, &revokedAt,
); err != nil { ); 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 { if err := json.Unmarshal(driveJSON, &auth.DriveScope); err != nil {
return nil, err return nil, err
} }
if err := json.Unmarshal(agendaJSON, &auth.AgendaScope); err != nil {
return nil, err
}
_, _ = db.Exec(ctx, ` _, _ = db.Exec(ctx, `
UPDATE api_tokens SET last_used_at = now(), updated_at = now() WHERE id = $1 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) { func scanToken(rows rowScanner) (Token, error) {
var item Token var item Token
var permJSON, mailJSON, driveJSON []byte var permJSON, mailJSON, driveJSON, agendaJSON []byte
if err := rows.Scan( if err := rows.Scan(
&item.ID, &item.ID,
&item.Name, &item.Name,
@ -274,19 +290,20 @@ func scanToken(rows rowScanner) (Token, error) {
&permJSON, &permJSON,
&mailJSON, &mailJSON,
&driveJSON, &driveJSON,
&agendaJSON,
&item.CreatedAt, &item.CreatedAt,
&item.LastUsedAt, &item.LastUsedAt,
&item.ExpiresAt, &item.ExpiresAt,
); err != nil { ); err != nil {
return Token{}, err 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 Token{}, err
} }
return item, nil 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 { if err := json.Unmarshal(permJSON, &item.Permissions); err != nil {
return err return err
} }
@ -296,5 +313,12 @@ func decodeTokenJSON(permJSON, mailJSON, driveJSON []byte, item *Token) error {
if err := json.Unmarshal(driveJSON, &item.DriveScope); err != nil { if err := json.Unmarshal(driveJSON, &item.DriveScope); err != nil {
return err 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 return nil
} }

View File

@ -73,7 +73,7 @@ func (d *Dispatcher) OnMailCreated(ctx context.Context, userID, accountID, messa
msg.ID = messageID msg.ID = messageID
} }
d.runRules(ctx, userID, msg, evt) 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) { 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) evt := driveEventContext(trigger, payload)
msg := &rules.Message{} msg := &rules.Message{}
d.runRules(ctx, userID, msg, evt) 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) { 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) evt := contactEventContext(trigger, payload)
msg := &rules.Message{} msg := &rules.Message{}
d.runRules(ctx, userID, msg, evt) 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) { func (d *Dispatcher) runRules(ctx context.Context, userID string, msg *rules.Message, evt *rules.EventContext) {
@ -121,6 +121,7 @@ type webhookTemplateRow struct {
mailScope []byte mailScope []byte
driveScope []byte driveScope []byte
contactsScope []byte contactsScope []byte
agendaScope []byte
} }
func (d *Dispatcher) dispatchWebhooks( func (d *Dispatcher) dispatchWebhooks(
@ -132,12 +133,13 @@ func (d *Dispatcher) dispatchWebhooks(
accountID string, accountID string,
drivePath string, drivePath string,
bookID string, bookID string,
calendarID string,
) { ) {
if d.hooks == nil || d.db == nil { if d.hooks == nil || d.db == nil {
return return
} }
rows, err := d.db.Query(ctx, ` 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 FROM webhook_templates
WHERE user_id = $1 AND is_active = true WHERE user_id = $1 AND is_active = true
`, userID) `, userID)
@ -150,14 +152,14 @@ func (d *Dispatcher) dispatchWebhooks(
msgCtx := rules.WebhookContextFromEvent(evt, msg) msgCtx := rules.WebhookContextFromEvent(evt, msg)
for rows.Next() { for rows.Next() {
var row webhookTemplateRow 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) d.logger.Error("scan webhook template", "error", err)
continue continue
} }
if !webhookMatchesEvent(row, eventType) { if !webhookMatchesEvent(row, eventType) {
continue continue
} }
if !webhookMatchesScope(row, accountID, drivePath, bookID) { if !webhookMatchesScope(row, accountID, drivePath, bookID, calendarID) {
continue continue
} }
if err := d.hooks.Execute(ctx, row.id, msgCtx); err != nil { if err := d.hooks.Execute(ctx, row.id, msgCtx); err != nil {
@ -182,13 +184,15 @@ func webhookMatchesEvent(row webhookTemplateRow, eventType string) bool {
return false 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 mailScope MailScope
var driveScope DriveScope var driveScope DriveScope
var contactsScope ContactsScope var contactsScope ContactsScope
var agendaScope AgendaScope
_ = json.Unmarshal(row.mailScope, &mailScope) _ = json.Unmarshal(row.mailScope, &mailScope)
_ = json.Unmarshal(row.driveScope, &driveScope) _ = json.Unmarshal(row.driveScope, &driveScope)
_ = json.Unmarshal(row.contactsScope, &contactsScope) _ = json.Unmarshal(row.contactsScope, &contactsScope)
_ = json.Unmarshal(row.agendaScope, &agendaScope)
if accountID != "" { if accountID != "" {
return AllowsMailScope(mailScope, accountID) return AllowsMailScope(mailScope, accountID)
@ -199,6 +203,9 @@ func webhookMatchesScope(row webhookTemplateRow, accountID, drivePath, bookID st
if bookID != "" { if bookID != "" {
return AllowsContactsScope(contactsScope, bookID) return AllowsContactsScope(contactsScope, bookID)
} }
if calendarID != "" {
return AllowsAgendaScope(agendaScope, calendarID)
}
return true return true
} }

View File

@ -19,6 +19,11 @@ type ContactsScope struct {
BookIDs []string `json:"book_ids"` 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 { func AllowsMailScope(scope MailScope, accountID string) bool {
if accountID == "" { if accountID == "" {
return true return true
@ -79,3 +84,18 @@ func AllowsContactsScope(scope ContactsScope, bookID string) bool {
} }
return false 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
}

View File

@ -88,6 +88,7 @@ type Config struct {
JitsiAppID string JitsiAppID string
JitsiAppSecret string JitsiAppSecret string
JitsiPublicURL string JitsiPublicURL string
MeetTranscriptWebhookSecret string
// Immich // Immich
ImmichEnabled bool ImmichEnabled bool
@ -210,6 +211,7 @@ func Load() (*Config, error) {
JitsiAppID: envOrDefault("JITSI_APP_ID", "ulti"), JitsiAppID: envOrDefault("JITSI_APP_ID", "ulti"),
JitsiAppSecret: envOrDefaultSecret("JITSI_APP_SECRET", "changeme-jwt-secret"), JitsiAppSecret: envOrDefaultSecret("JITSI_APP_SECRET", "changeme-jwt-secret"),
JitsiPublicURL: envOrDefault("JITSI_PUBLIC_URL", "https://localhost/meet"), JitsiPublicURL: envOrDefault("JITSI_PUBLIC_URL", "https://localhost/meet"),
MeetTranscriptWebhookSecret: envOrDefaultSecret("MEET_TRANSCRIPT_WEBHOOK_SECRET", ""),
ImmichEnabled: envBool("IMMICH_ENABLED", true), ImmichEnabled: envBool("IMMICH_ENABLED", true),
ImmichAPIURL: envOrDefault("IMMICH_API_URL", "http://immich-server:2283/api"), ImmichAPIURL: envOrDefault("IMMICH_API_URL", "http://immich-server:2283/api"),

View File

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

View File

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

View File

@ -295,6 +295,38 @@ func matchCondition(cond Condition, msg *Message, evt *EventContext) bool {
if evt != nil { if evt != nil {
fieldValue = evt.ContactOrg 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: default:
return false 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": case "contact_add_label", "contact_remove_label", "contact_delete":
e.logger.Info("deferred contact action", "type", action.Type, "value", action.Value) e.logger.Info("deferred contact action", "type", action.Type, "value", action.Value)
return nil 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: default:
return fmt.Errorf("unknown action type: %s", action.Type) return fmt.Errorf("unknown action type: %s", action.Type)
} }

View File

@ -29,6 +29,10 @@ const (
TriggerContactCreated TriggerType = "contact_created" TriggerContactCreated TriggerType = "contact_created"
TriggerContactUpdated TriggerType = "contact_updated" TriggerContactUpdated TriggerType = "contact_updated"
TriggerContactDeleted TriggerType = "contact_deleted" 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 { type Trigger struct {
@ -38,6 +42,7 @@ type Trigger struct {
AccountID string `json:"account_id,omitempty"` AccountID string `json:"account_id,omitempty"`
FolderPath string `json:"folder_path,omitempty"` FolderPath string `json:"folder_path,omitempty"`
ContactLabel string `json:"contact_label,omitempty"` ContactLabel string `json:"contact_label,omitempty"`
CalendarID string `json:"calendar_id,omitempty"`
} }
type TriggerGroup struct { type TriggerGroup struct {
@ -130,6 +135,7 @@ type EventContext struct {
Label string Label string
FolderPath string FolderPath string
ContactLabel string ContactLabel string
CalendarID string
// Drive payload (when domain is drive) // Drive payload (when domain is drive)
DriveFileName string DriveFileName string
DriveFilePath string DriveFilePath string
@ -143,6 +149,16 @@ type EventContext struct {
ContactEmail string ContactEmail string
ContactPhone string ContactPhone string
ContactOrg 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) { func ParseWorkflow(raw []byte) (*Workflow, error) {
@ -276,6 +292,14 @@ func matchTrigger(t Trigger, msg *Message, evt *EventContext) bool {
return false return false
} }
return true 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: default:
return false return false
} }

View File

@ -272,6 +272,24 @@ func workflowFieldValue(field string, msg *Message, evt *EventContext, execCtx *
return evt.ContactOrg return evt.ContactOrg
case "contact_label": case "contact_label":
return evt.ContactLabel 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: default:
return "" return ""
} }

View File

@ -30,6 +30,11 @@ type UserInfo struct {
IsMod bool `json:"is_moderator"` IsMod bool `json:"is_moderator"`
} }
// TokenOptions toggles JWT feature flags for Jitsi.
type TokenOptions struct {
Transcription bool
}
func NewConfig(appID, appSecret, domain string) *Config { func NewConfig(appID, appSecret, domain string) *Config {
return &Config{ return &Config{
AppID: appID, 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() now := time.Now()
exp := now.Add(ttl) exp := now.Add(ttl)
@ -47,6 +52,24 @@ func (c *Config) GenerateToken(room string, user *UserInfo, ttl time.Duration) (
"typ": "JWT", "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{ payload := map[string]any{
"iss": c.AppID, "iss": c.AppID,
"sub": "meet.jitsi", "sub": "meet.jitsi",
@ -54,15 +77,7 @@ func (c *Config) GenerateToken(room string, user *UserInfo, ttl time.Duration) (
"iat": now.Unix(), "iat": now.Unix(),
"exp": exp.Unix(), "exp": exp.Unix(),
"room": room, "room": room,
"context": map[string]any{ "context": contextPayload,
"user": map[string]any{
"id": user.ID,
"name": user.Name,
"email": user.Email,
"avatar": user.Avatar,
"moderator": user.IsMod,
},
},
} }
token, err := signJWT(header, payload, c.AppSecret) token, err := signJWT(header, payload, c.AppSecret)

View File

@ -7,6 +7,7 @@ import (
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"strconv"
"strings" "strings"
"time" "time"
) )
@ -32,6 +33,7 @@ type Event struct {
Attendees []EventAttendee `json:"attendees,omitempty"` Attendees []EventAttendee `json:"attendees,omitempty"`
MeetURL string `json:"meet_url,omitempty"` MeetURL string `json:"meet_url,omitempty"`
Color string `json:"color,omitempty"` Color string `json:"color,omitempty"`
Sequence int `json:"sequence,omitempty"`
RRule string `json:"rrule,omitempty"` RRule string `json:"rrule,omitempty"`
ExDates []string `json:"exdates,omitempty"` ExDates []string `json:"exdates,omitempty"`
RawICS string `json:"raw_ics,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) { 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) resp, err := c.DoAsUser(ctx, "GET", eventPath, nil, userID, nil)
if err != nil { if err != nil {
return nil, err return nil, err
@ -234,7 +237,68 @@ func (c *Client) GetEvent(ctx context.Context, userID, eventPath string) (*Event
return &event, nil 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) { 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) ics := buildICS(event)
headers := map[string]string{ headers := map[string]string{
"Content-Type": "text/calendar; charset=utf-8", "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 { func (c *Client) DeleteEvent(ctx context.Context, userID, eventPath string) error {
eventPath = normalizeDAVHref(eventPath)
resp, err := c.DoAsUser(ctx, "DELETE", eventPath, nil, userID, nil) resp, err := c.DoAsUser(ctx, "DELETE", eventPath, nil, userID, nil)
if err != nil { if err != nil {
return err 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("URL:%s\r\n", strings.TrimSpace(event.MeetURL)))
b.WriteString(fmt.Sprintf("X-ULTI-MEET-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) != "" { if strings.TrimSpace(event.RRule) != "" {
b.WriteString(fmt.Sprintf("RRULE:%s\r\n", 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, "DTSTART", event.Start, event.AllDay)
writeDateProp(&b, "DTEND", event.End, 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(fmt.Sprintf("DTSTAMP:%s\r\n", time.Now().UTC().Format("20060102T150405Z")))
b.WriteString("END:VEVENT\r\n") b.WriteString("END:VEVENT\r\n")
b.WriteString("END:VCALENDAR\r\n") b.WriteString("END:VCALENDAR\r\n")
@ -515,9 +580,11 @@ func parseCalendarList(body io.Reader, basePath string) ([]Calendar, error) {
return nil, err return nil, err
} }
basePath = normalizeDAVHref(basePath)
calendars := make([]Calendar, 0) calendars := make([]Calendar, 0)
for _, r := range ms.Responses { for _, r := range ms.Responses {
if r.Href == basePath { href := normalizeDAVHref(r.Href)
if href == basePath {
continue continue
} }
name := r.Propstat.Prop.DisplayName name := r.Propstat.Prop.DisplayName
@ -525,10 +592,10 @@ func parseCalendarList(body io.Reader, basePath string) ([]Calendar, error) {
continue continue
} }
calendars = append(calendars, Calendar{ calendars = append(calendars, Calendar{
ID: strings.TrimSuffix(strings.TrimPrefix(r.Href, basePath), "/"), ID: strings.TrimSuffix(strings.TrimPrefix(href, basePath), "/"),
DisplayName: name, DisplayName: name,
Color: r.Propstat.Prop.CalendarColor, Color: r.Propstat.Prop.CalendarColor,
Path: r.Href, Path: href,
}) })
} }
return calendars, nil return calendars, nil
@ -545,7 +612,7 @@ func parseEventList(body io.Reader) ([]Event, error) {
ics := r.Propstat.Prop.CalendarData ics := r.Propstat.Prop.CalendarData
event := parseICS(ics) event := parseICS(ics)
event.RawICS = ics event.RawICS = ics
event.Path = r.Href event.Path = normalizeDAVHref(r.Href)
event.ETag = strings.TrimSpace(r.Propstat.Prop.ETag) event.ETag = strings.TrimSpace(r.Propstat.Prop.ETag)
events = append(events, event) events = append(events, event)
} }
@ -679,6 +746,10 @@ func parseICS(ics string) Event {
e.Color = value e.Color = value
case "RRULE": case "RRULE":
e.RRule = value e.RRule = value
case "SEQUENCE":
if seq, err := strconv.Atoi(strings.TrimSpace(value)); err == nil {
e.Sequence = seq
}
case "EXDATE": case "EXDATE":
for _, ex := range strings.Split(value, ",") { for _, ex := range strings.Split(value, ",") {
normalized, _ := normalizeICSDate(ex, params) normalized, _ := normalizeICSDate(ex, params)

View File

@ -131,8 +131,8 @@ func TestBuildICSRoundTrip(t *testing.T) {
if parsed.RRule != event.RRule { if parsed.RRule != event.RRule {
t.Fatalf("RRule = %q", parsed.RRule) t.Fatalf("RRule = %q", parsed.RRule)
} }
if parsed.Color != event.Color { if parsed.Color != "" {
t.Fatalf("Color = %q", parsed.Color) t.Fatalf("Color should not be serialized in ICS, got %q", parsed.Color)
} }
if len(parsed.ExDates) != 1 || parsed.ExDates[0] != "20260618T100000Z" { if len(parsed.ExDates) != 1 || parsed.ExDates[0] != "20260618T100000Z" {
t.Fatalf("ExDates = %v", parsed.ExDates) 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) { func TestBuildICSAllDay(t *testing.T) {
ics := buildICS(&Event{UID: "ad", Summary: "Férié", Start: "20260714", End: "20260715", AllDay: true}) ics := buildICS(&Event{UID: "ad", Summary: "Férié", Start: "20260714", End: "20260715", AllDay: true})
if !strings.Contains(ics, "DTSTART;VALUE=DATE:20260714") { if !strings.Contains(ics, "DTSTART;VALUE=DATE:20260714") {
@ -155,3 +197,35 @@ func TestBuildICSAllDay(t *testing.T) {
t.Fatal("AllDay should round-trip") t.Fatal("AllDay should round-trip")
} }
} }
func TestParseCalendarListNormalizesCloudPrefix(t *testing.T) {
basePath := "/remote.php/dav/calendars/user@example.com/"
raw := `<?xml version="1.0" encoding="UTF-8"?>
<d:multistatus xmlns:d="DAV:" xmlns:apple="http://apple.com/ns/ical/">
<d:response>
<d:href>/cloud/remote.php/dav/calendars/user@example.com/</d:href>
<d:propstat><d:prop><d:displayname>Root</d:displayname></d:prop></d:propstat>
</d:response>
<d:response>
<d:href>/cloud/remote.php/dav/calendars/user@example.com/personal/</d:href>
<d:propstat><d:prop>
<d:displayname>Personal</d:displayname>
<apple:calendar-color>#1a73e8</apple:calendar-color>
</d:prop></d:propstat>
</d:response>
</d:multistatus>`
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)
}
}

View File

@ -58,8 +58,15 @@ func (c *Client) webDAVDestination(davPath string) string {
return SameServerDestinationHeader(davPath) 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) { 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) req, err := http.NewRequestWithContext(ctx, method, url, body)
if err != nil { if err != nil {
return nil, err return nil, err
@ -84,7 +91,7 @@ func (c *Client) doAsUser(ctx context.Context, method, path string, body io.Read
return nil, err return nil, err
} }
url := c.baseURL + path url := joinBaseURL(c.baseURL, path)
req, err := http.NewRequestWithContext(ctx, method, url, body) req, err := http.NewRequestWithContext(ctx, method, url, body)
if err != nil { if err != nil {
return nil, err return nil, err

View File

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

View File

@ -15,18 +15,27 @@ import (
"time" "time"
) )
type FileCapabilities struct {
Share bool `json:"share"`
Trash bool `json:"trash"`
Preview bool `json:"preview"`
}
type FileInfo struct { type FileInfo struct {
Path string `json:"path"` Path string `json:"path"`
Name string `json:"name"` Name string `json:"name"`
Type string `json:"type"` // "file" or "directory" Type string `json:"type"` // "file" or "directory"
Size int64 `json:"size"` Size int64 `json:"size"`
MimeType string `json:"mime_type"` MimeType string `json:"mime_type"`
LastModified string `json:"last_modified"` LastModified string `json:"last_modified"`
ETag string `json:"etag"` ETag string `json:"etag"`
FileID int64 `json:"file_id,omitempty"` FileID int64 `json:"file_id,omitempty"`
IsFavorite bool `json:"is_favorite"` IsFavorite bool `json:"is_favorite"`
IsShared bool `json:"is_shared"` IsShared bool `json:"is_shared"`
Source string `json:"source,omitempty"` 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 { type ShareInfo struct {

View File

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

View File

@ -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 = `<?xml version="1.0" encoding="UTF-8"?>
<d:propfind xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns" xmlns:nc="http://nextcloud.org/ns">
<d:prop>
<d:getlastmodified/>
<d:getetag/>
<d:getcontenttype/>
<d:getcontentlength/>
<d:resourcetype/>
<oc:fileid/>
<oc:size/>
<oc:favorite/>
<oc:share-types/>
<d:displayname/>
</d:prop>
</d:propfind>`

View File

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

View File

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

171
internal/orgpolicy/drive.go Normal file
View File

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

169
internal/orgpolicy/meet.go Normal file
View File

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

View File

@ -344,12 +344,18 @@ func New(ctx context.Context, cfg *config.Config, opts Options) (*App, error) {
TypesenseCollection: cfg.TypesenseCollection, TypesenseCollection: cfg.TypesenseCollection,
}).Search) }).Search)
if driveHandler != nil { 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()) r.Mount("/api/v1/contacts", contactsHandler.Routes())
} }
if meetCfg != nil { r.Mount("/api/v1/meet", meetapi.NewHandler(
r.Mount("/api/v1/meet", meetapi.NewHandler(meetCfg).Routes()) meetCfg,
} cfg.JitsiEnabled,
cfg.JitsiPublicURL,
orgPolicyLoader,
pool,
ncClient,
cfg.MeetTranscriptWebhookSecret,
).Routes())
if photosClient != nil { if photosClient != nil {
r.Mount("/api/v1/photos", photosapi.NewHandler(photosClient, ncClient).Routes()) r.Mount("/api/v1/photos", photosapi.NewHandler(photosClient, ncClient).Routes())
} }

134
internal/users/avatar.go Normal file
View File

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

View File

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

View File

@ -0,0 +1,5 @@
ALTER TABLE webhook_templates
DROP COLUMN IF EXISTS agenda_scope;
ALTER TABLE api_tokens
DROP COLUMN IF EXISTS agenda_scope;

View File

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

View File

@ -0,0 +1,2 @@
DROP TABLE IF EXISTS drive_mounts;
DROP TABLE IF EXISTS drive_org_folders;

View File

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

View File

@ -0,0 +1 @@
ALTER TABLE users DROP COLUMN IF EXISTS avatar_url;

View File

@ -0,0 +1,2 @@
ALTER TABLE users
ADD COLUMN IF NOT EXISTS avatar_url TEXT;

View File

@ -0,0 +1 @@
DROP TABLE IF EXISTS meet_transcript_jobs;

View File

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