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_SECRET — defini dans la section Secrets
JITSI_PUBLIC_URL=https://{{DOMAIN}}/meet
# Secret partagé avec Jigasi pour POST /api/v1/meet/transcripts
MEET_TRANSCRIPT_WEBHOOK_SECRET=changeme-meet-transcript-secret
# Modèle Faster Whisper (Skynet) : tiny, base, small…
SKYNET_WHISPER_MODEL=tiny
JICOFO_AUTH_PASSWORD={{JITSI_INTERNAL_AUTH_PASSWORD}}
JVB_AUTH_PASSWORD={{JITSI_INTERNAL_AUTH_PASSWORD}}

View File

@ -104,7 +104,7 @@ Un seul **nginx** expose lentrée HTTP (`:80`) et route :
| `/auth/*` | Authentik |
| `/meet/*` | Jitsi (si `JITSI_ENABLED=true`) |
| `/cloud/*` | Nextcloud nginx+FPM (si `NEXTCLOUD_ENABLED=true`) |
| `/mail/*`, `/drive/*`, `/contacts`, `/admin/*` | Suite frontend (`MAIL_FRONTEND_UPSTREAM`, défaut `host.docker.internal:3004` ; Docker : `suite-frontend:3000`) |
| `/mail/*`, `/drive/*`, `/contacts`, `/agenda`, `/compte`, `/admin/*` | Suite frontend (`MAIL_FRONTEND_UPSTREAM`, défaut `host.docker.internal:3004` ; Docker : `suite-frontend:3000`) |
Nextcloud : FPM + nginx dédié ; ultid appelle `NEXTCLOUD_URL` en interne (`http://nextcloud:80`).
Caddy retiré : un seul proxy évite la double couche ; TLS plus tard (certbot, Traefik, ou `listen 443` nginx).

View File

@ -69,3 +69,45 @@ services:
- ulti-net
depends_on:
- jitsi-prosody
skynet:
build:
context: ./skynet
dockerfile: Dockerfile
restart: unless-stopped
environment:
ENABLED_MODULES: streaming_whisper
BYPASS_AUTHORIZATION: "1"
WHISPER_MODEL_NAME: ${SKYNET_WHISPER_MODEL:-tiny}
WHISPER_MODEL_PATH: /models/streaming-whisper
BEAM_SIZE: "1"
volumes:
- skynet-models:/models
networks:
- ulti-net
jitsi-jigasi:
image: jitsi/jigasi:stable-9823
restart: unless-stopped
environment:
<<: *jitsi-env
XMPP_DOMAIN: meet.jitsi
XMPP_MUC_DOMAIN: muc.meet.jitsi
XMPP_INTERNAL_MUC_DOMAIN: internal-muc.meet.jitsi
JIGASI_BREWERY_MUC: jigasibrewery@internal-muc.meet.jitsi
JIGASI_ENABLE_SDES_SRTP: "0"
ENABLE_TRANSCRIPTIONS: "1"
JIGASI_TRANSCRIBER_CUSTOM_SERVICE: org.jitsi.jigasi.transcription.WhisperTranscriptionService
JIGASI_TRANSCRIBER_WHISPER_URL: ws://skynet:8000/streaming-whisper/ws
JIGASI_TRANSCRIBER_SEND_JSON: "true"
JIGASI_TRANSCRIBER_BASE_URL: http://ultid:8080/api/v1/meet/transcripts/
volumes:
- ./jigasi:/config:ro
networks:
- ulti-net
depends_on:
- jitsi-prosody
- skynet
volumes:
skynet-models:

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 contacts || true
$OCC app:enable groupfolders || true
$OCC app:enable files_external || true
$OCC config:app:set files_external allow_user_mounting --value=1 || true
$OCC app:enable user_oidc || true
# Configure OIDC (Authentik)

View File

@ -107,6 +107,10 @@ server {
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_read_timeout 86400;
proxy_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
# Permet 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/ {
@ -248,7 +252,7 @@ server {
proxy_set_header Connection $connection_upgrade;
}
# Ulti Suite frontend (mail + drive + contacts) — dev: pnpm dev on host (MAIL_FRONTEND_UPSTREAM=host.docker.internal:3004)
# Ulti Suite frontend (mail + drive + contacts + agenda + compte) — dev: pnpm dev on host (MAIL_FRONTEND_UPSTREAM=host.docker.internal:3004)
# Prod: set MAIL_FRONTEND_UPSTREAM=suite-frontend:3000
# Démos publiques de la landing (zéro rétention) — frontend Next.
location ^~ /demo {
@ -357,6 +361,19 @@ server {
proxy_set_header Connection $connection_upgrade;
}
location ^~ /agenda {
resolver 127.0.0.11 valid=10s ipv6=off;
set $mail_upstream ${MAIL_FRONTEND_UPSTREAM};
proxy_pass http://$mail_upstream;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
}
# Réglages du compte Ulti
location ^~ /compte {
resolver 127.0.0.11 valid=10s ipv6=off;
@ -398,6 +415,18 @@ server {
proxy_set_header Connection $connection_upgrade;
}
# next/font (Geist, etc.) — separate from /_next/ static chunks
location ^~ /__nextjs_font/ {
resolver 127.0.0.11 valid=10s ipv6=off;
set $mail_upstream ${MAIL_FRONTEND_UPSTREAM};
proxy_pass http://$mail_upstream;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location ^~ /brand/ {
resolver 127.0.0.11 valid=10s ipv6=off;
set $mail_upstream ${MAIL_FRONTEND_UPSTREAM};

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}/sync", h.SyncIdentityProvider)
h.registerDriveAdminRoutes(r, read, write)
return r
}

View File

@ -10,6 +10,7 @@ import (
"github.com/ultisuite/ulti-backend/internal/config"
"github.com/ultisuite/ulti-backend/internal/authentik"
"github.com/ultisuite/ulti-backend/internal/orgpolicy"
)
const orgSettingsSingletonID = 1
@ -59,6 +60,7 @@ func defaultOrgPolicy() map[string]any {
"virus_scan_enabled": false,
"virustotal_api_key": "",
"retention_trash_days": 30,
"mount_oauth": orgpolicy.DefaultMountOAuthSection(),
},
"llm": map[string]any{
"default_provider_id": "",
@ -121,6 +123,36 @@ func defaultOrgPolicy() map[string]any {
"chat_sync_enabled": true,
"chat_nc_path": "/.ultimail/ai/chats",
},
"agenda": map[string]any{
"default_theme_mode": "system",
"enforce_org_theme": false,
"default_video_provider": "ultimeet",
"enforce_org_video_provider": false,
"video_provider_api_keys": map[string]any{},
},
"meet": map[string]any{
"transcription_enabled": false,
"transcription_mode": "live",
"transcription_engine": "faster_whisper_local",
"skynet_url": "http://skynet:8000",
"whisper_model": "tiny",
"external_api_url": "",
"external_api_provider": "openai_compatible",
"external_api_key": "",
"auto_start_transcription": false,
"post_actions": map[string]any{
"email_enabled": false,
"email_recipients": "organizer",
"email_custom_addresses": "",
"drive_enabled": true,
"drive_folder_path": "/UltiMeet/Transcripts",
"llm_enabled": false,
"llm_provider_id": "",
"llm_prompt": "Résume cette réunion en français : points clés, décisions et actions à suivre.",
"llm_then_email": true,
"llm_then_drive": true,
},
},
"plugins": []any{
map[string]any{"id": "mail-automation", "name": "Automatisations mail", "description": "Règles, webhooks et tri IA sur la réception.", "enabled": true, "version": "1.0.0"},
map[string]any{"id": "contact-discovery", "name": "Découverte contacts", "description": "Enrichissement IA et signatures détectées.", "enabled": true, "version": "1.0.0"},
@ -230,9 +262,112 @@ func mergeOrgSecrets(existing, patch map[string]any) map[string]any {
if patchIDP, ok := patch["identity_providers"].(map[string]any); ok {
mergeIdentityProviderSecrets(existing, patchIDP, merged)
}
if patchAgenda, ok := patch["agenda"].(map[string]any); ok {
mergeAgendaProviderSecrets(existing, patchAgenda, merged)
}
if patchMeet, ok := patch["meet"].(map[string]any); ok {
mergeMeetSecrets(existing, patchMeet, merged)
}
if patchFilePolicies, ok := patch["file_policies"].(map[string]any); ok {
mergeMountOAuthSecrets(existing, patchFilePolicies, merged)
}
return merged
}
func mergeMountOAuthSecrets(existing, patchFilePolicies, merged map[string]any) {
patchMountOAuth, _ := patchFilePolicies["mount_oauth"].(map[string]any)
if patchMountOAuth == nil {
return
}
existingFilePolicies, _ := existing["file_policies"].(map[string]any)
existingMountOAuth, _ := existingFilePolicies["mount_oauth"].(map[string]any)
mergedFilePolicies, _ := merged["file_policies"].(map[string]any)
if mergedFilePolicies == nil {
return
}
out := map[string]any{}
for k, v := range patchMountOAuth {
if k == "redirect_uri" {
out[k] = v
continue
}
providerPatch, ok := v.(map[string]any)
if !ok {
out[k] = v
continue
}
mergedProvider := map[string]any{}
for pk, pv := range providerPatch {
mergedProvider[pk] = pv
}
if secret, _ := providerPatch["client_secret"].(string); strings.TrimSpace(secret) == "" {
if existingMountOAuth != nil {
if existingProvider, ok := existingMountOAuth[k].(map[string]any); ok {
if prev, ok := existingProvider["client_secret"].(string); ok && prev != "" {
mergedProvider["client_secret"] = prev
}
}
}
}
out[k] = mergedProvider
}
mergedFilePolicies["mount_oauth"] = out
merged["file_policies"] = mergedFilePolicies
}
func mergeMeetSecrets(existing, patchMeet, merged map[string]any) {
if strings.TrimSpace(stringValueMap(patchMeet, "external_api_key")) != "" {
return
}
existingMeet, _ := existing["meet"].(map[string]any)
prev := stringValueMap(existingMeet, "external_api_key")
if prev == "" {
return
}
mergedMeet, _ := merged["meet"].(map[string]any)
if mergedMeet == nil {
return
}
mergedMeet["external_api_key"] = prev
merged["meet"] = mergedMeet
}
func stringValueMap(m map[string]any, key string) string {
if m == nil {
return ""
}
s, _ := m[key].(string)
return s
}
func mergeAgendaProviderSecrets(existing, patchAgenda, merged map[string]any) {
patchKeys, _ := patchAgenda["video_provider_api_keys"].(map[string]any)
if len(patchKeys) == 0 {
return
}
existingAgenda, _ := existing["agenda"].(map[string]any)
existingKeys, _ := existingAgenda["video_provider_api_keys"].(map[string]any)
mergedAgenda, _ := merged["agenda"].(map[string]any)
if mergedAgenda == nil {
return
}
outKeys := map[string]any{}
for k, v := range patchKeys {
s, _ := v.(string)
if strings.TrimSpace(s) == "" {
if existingKeys != nil {
if prev, ok := existingKeys[k].(string); ok && prev != "" {
outKeys[k] = prev
continue
}
}
}
outKeys[k] = v
}
mergedAgenda["video_provider_api_keys"] = outKeys
merged["agenda"] = mergedAgenda
}
func mergeLLMProviderSecrets(existing, patchLLM, merged map[string]any) {
patchProviders, _ := patchLLM["providers"].([]any)
if len(patchProviders) == 0 {
@ -384,6 +519,34 @@ func maskOrgPolicy(policy map[string]any) map[string]any {
maskStringField(cloned, "search", "meilisearch_api_key")
maskStringField(cloned, "search", "typesense_api_key")
maskStringField(cloned, "file_policies", "virustotal_api_key")
if filePolicies, ok := cloned["file_policies"].(map[string]any); ok {
if mountOAuth, ok := filePolicies["mount_oauth"].(map[string]any); ok {
for _, provider := range []string{"google", "dropbox", "microsoft"} {
if section, ok := mountOAuth[provider].(map[string]any); ok {
if secret, _ := section["client_secret"].(string); strings.TrimSpace(secret) != "" {
section["client_secret"] = ""
}
mountOAuth[provider] = section
}
}
filePolicies["mount_oauth"] = mountOAuth
}
}
if agenda, ok := cloned["agenda"].(map[string]any); ok {
if keys, ok := agenda["video_provider_api_keys"].(map[string]any); ok {
for k, v := range keys {
if s, ok := v.(string); ok && strings.TrimSpace(s) != "" {
keys[k] = ""
}
}
agenda["video_provider_api_keys"] = keys
}
}
if meet, ok := cloned["meet"].(map[string]any); ok {
if v, _ := meet["external_api_key"].(string); strings.TrimSpace(v) != "" {
meet["external_api_key"] = ""
}
}
if llm, ok := cloned["llm"].(map[string]any); ok {
if providers, ok := llm["providers"].([]any); ok {
for i, p := range providers {
@ -477,6 +640,28 @@ func secretConfigured(policy map[string]any, section, key string) bool {
return strings.TrimSpace(v) != ""
}
func mountOAuthProviderSecretConfigured(policy map[string]any, provider string) bool {
fp, ok := policy["file_policies"].(map[string]any)
if !ok {
return false
}
mo, ok := fp["mount_oauth"].(map[string]any)
if !ok {
return false
}
section, ok := mo[provider].(map[string]any)
if !ok {
return false
}
enabled, _ := section["enabled"].(bool)
if !enabled {
return false
}
clientID, _ := section["client_id"].(string)
clientSecret, _ := section["client_secret"].(string)
return strings.TrimSpace(clientID) != "" && strings.TrimSpace(clientSecret) != ""
}
func buildOrgSecretsStatus(policy map[string]any, cfg *config.Config) map[string]any {
secrets := map[string]any{
"nextcloud_admin_password": map[string]any{
@ -497,6 +682,15 @@ func buildOrgSecretsStatus(policy map[string]any, cfg *config.Config) map[string
"virustotal_api_key": map[string]any{
"configured": secretConfigured(policy, "file_policies", "virustotal_api_key") || strings.TrimSpace(cfg.VirusTotalAPIKey) != "",
},
"mount_oauth_google": map[string]any{
"configured": mountOAuthProviderSecretConfigured(policy, "google"),
},
"mount_oauth_dropbox": map[string]any{
"configured": mountOAuthProviderSecretConfigured(policy, "dropbox"),
},
"mount_oauth_microsoft": map[string]any{
"configured": mountOAuthProviderSecretConfigured(policy, "microsoft"),
},
}
if idpSecrets := buildIdentityProviderSecretsStatus(policy); len(idpSecrets) > 0 {
secrets["identity_providers"] = idpSecrets

View File

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

View File

@ -12,17 +12,19 @@ import (
"github.com/ultisuite/ulti-backend/internal/auth"
meetpkg "github.com/ultisuite/ulti-backend/internal/meet"
"github.com/ultisuite/ulti-backend/internal/nextcloud"
"github.com/ultisuite/ulti-backend/internal/orgpolicy"
)
type Service struct {
nc *nextcloud.Client
meetCfg *meetpkg.Config
policy *orgpolicy.Loader
}
var ErrMeetDisabled = errors.New("meet is disabled")
func NewService(nc *nextcloud.Client, meetCfg *meetpkg.Config) *Service {
return &Service{nc: nc, meetCfg: meetCfg}
func NewService(nc *nextcloud.Client, meetCfg *meetpkg.Config, policy *orgpolicy.Loader) *Service {
return &Service{nc: nc, meetCfg: meetCfg, policy: policy}
}
func (s *Service) EnsureNextcloudUser(ctx context.Context, claims *auth.Claims) (string, error) {
@ -99,10 +101,23 @@ func (s *Service) CreateEvent(ctx context.Context, userID, calID string, event *
}
func (s *Service) UpdateEvent(ctx context.Context, userID, eventPath, ifMatch string, event *nextcloud.Event) (string, error) {
if strings.TrimSpace(event.Organizer) == "" {
event.Organizer = userID
existing, err := s.nc.GetEvent(ctx, userID, eventPath)
if err != nil {
return "", err
}
return s.nc.UpdateEvent(ctx, userID, eventPath, ifMatch, event)
merged := nextcloud.MergeEvent(existing, event)
if strings.TrimSpace(merged.Organizer) == "" {
merged.Organizer = userID
}
merged.Sequence = existing.Sequence + 1
if merged.Sequence < 1 {
merged.Sequence = 1
}
match := strings.TrimSpace(ifMatch)
if match == "" || match == "*" {
match = strings.TrimSpace(existing.ETag)
}
return s.nc.UpdateEvent(ctx, userID, eventPath, match, merged)
}
func (s *Service) DeleteEvent(ctx context.Context, userID, eventPath string) error {
@ -144,12 +159,18 @@ func (s *Service) CreateMeetLink(ctx context.Context, userID, userName, userEmai
if roomID == "" {
roomID = fmt.Sprintf("event-%d", time.Now().Unix())
}
tokenOpts := meetpkg.TokenOptions{}
if s.policy != nil {
if p, err := s.policy.MeetPolicy(ctx); err == nil && p.LiveTranscriptionJWT() {
tokenOpts.Transcription = true
}
}
token, err := s.meetCfg.GenerateToken(roomID, &meetpkg.UserInfo{
ID: userID,
Name: userName,
Email: userEmail,
IsMod: true,
}, 24*time.Hour)
}, 24*time.Hour, tokenOpts)
if err != nil {
return "", "", err
}

View File

@ -108,6 +108,8 @@ func (h *Handler) Routes() chi.Router {
r.With(write).Put("/shares/{shareID}", h.UpdateShare)
r.With(write).Delete("/shares/{shareID}", h.DeleteShare)
h.registerOrgAndMountRoutes(r, read, write)
return r
}
@ -348,7 +350,23 @@ func (h *Handler) Move(w http.ResponseWriter, r *http.Request) {
return
}
if err := h.svc.Move(r.Context(), ncUser, req.Source, req.Destination); err != nil {
srcRef, err := pathRefFromMoveSource(&req)
if err != nil {
writeDriveError(w, r, err)
return
}
destRef, err := pathRefFromMoveDestination(&req)
if err != nil {
writeDriveError(w, r, err)
return
}
if usesAlternateRoot(srcRef) || usesAlternateRoot(destRef) {
if err := h.svc.MoveAtRoot(r.Context(), ncUser, srcRef, destRef); err != nil {
h.logger.Error("move", "error", err)
writeDriveError(w, r, err)
return
}
} else if err := h.svc.Move(r.Context(), ncUser, req.Source, req.Destination); err != nil {
h.logger.Error("move", "error", err)
writeDriveError(w, r, err)
return
@ -376,7 +394,23 @@ func (h *Handler) Copy(w http.ResponseWriter, r *http.Request) {
return
}
if err := h.svc.Copy(r.Context(), ncUser, req.Source, req.Destination); err != nil {
srcRef, err := pathRefFromCopySource(&req)
if err != nil {
writeDriveError(w, r, err)
return
}
destRef, err := pathRefFromCopyDestination(&req)
if err != nil {
writeDriveError(w, r, err)
return
}
if usesAlternateRoot(srcRef) || usesAlternateRoot(destRef) {
if err := h.svc.CopyAtRoot(r.Context(), ncUser, srcRef, destRef); err != nil {
h.logger.Error("copy", "error", err)
writeDriveError(w, r, err)
return
}
} else if err := h.svc.Copy(r.Context(), ncUser, req.Source, req.Destination); err != nil {
h.logger.Error("copy", "error", err)
writeDriveError(w, r, err)
return
@ -404,7 +438,18 @@ func (h *Handler) Rename(w http.ResponseWriter, r *http.Request) {
return
}
if err := h.svc.Rename(r.Context(), ncUser, req.Path, req.NewName); err != nil {
ref, err := pathRefFromRename(&req)
if err != nil {
writeDriveError(w, r, err)
return
}
if usesAlternateRoot(ref) {
if err := h.svc.RenameAtRoot(r.Context(), ncUser, ref, req.NewName); err != nil {
h.logger.Error("rename", "error", err)
writeDriveError(w, r, err)
return
}
} else if err := h.svc.Rename(r.Context(), ncUser, req.Path, req.NewName); err != nil {
h.logger.Error("rename", "error", err)
writeDriveError(w, r, err)
return
@ -513,6 +558,11 @@ func (h *Handler) CreateShare(w http.ResponseWriter, r *http.Request) {
apivalidate.WriteValidationError(w, r, verr)
return
}
shareRef, err := pathRefFromShare(&req)
if err != nil {
writeDriveError(w, r, err)
return
}
permissions := req.Permissions
if permissions == 0 && strings.TrimSpace(req.Role) != "" {
@ -521,7 +571,7 @@ func (h *Handler) CreateShare(w http.ResponseWriter, r *http.Request) {
}
}
share, err := h.svc.CreateShare(r.Context(), ncUser, req.Path, req, permissions)
share, err := h.svc.CreateShareAtRoot(r.Context(), ncUser, shareRef, req, permissions)
if err != nil {
h.logger.Error("create share", "error", err)
writeDriveError(w, r, err)
@ -583,7 +633,14 @@ func (h *Handler) ListShares(w http.ResponseWriter, r *http.Request) {
))
return
}
shares, err := h.svc.ListShares(r.Context(), ncUser, filePath)
rootKind := r.URL.Query().Get("root")
rootID := r.URL.Query().Get("root_id")
ref, err := pathRefFromParts(rootKind, rootID, filePath)
if err != nil {
writeDriveError(w, r, err)
return
}
shares, err := h.svc.ListSharesAtRoot(r.Context(), ncUser, ref)
if err != nil {
writeDriveError(w, r, err)
return
@ -749,7 +806,12 @@ func (h *Handler) SetFavorite(w http.ResponseWriter, r *http.Request) {
apivalidate.WriteValidationError(w, r, verr)
return
}
if err := h.svc.SetFavorite(r.Context(), ncUser, req.Path, req.Favorite); err != nil {
favRef, err := pathRefFromFavorite(&req)
if err != nil {
writeDriveError(w, r, err)
return
}
if err := h.svc.SetFavoriteAtRoot(r.Context(), ncUser, favRef, req.Favorite); err != nil {
writeDriveError(w, r, err)
return
}
@ -798,6 +860,8 @@ func writeDriveError(w http.ResponseWriter, r *http.Request, err error) {
apiresponse.WriteError(w, r, http.StatusUnprocessableEntity, "drive.malware_detected", "malware detected in file", nil)
case errors.Is(err, ErrInvalid):
apiresponse.WriteError(w, r, http.StatusBadRequest, apiresponse.CodeInvalidRequest, "invalid request body", nil)
case errors.Is(err, ErrOAuthNotConfigured):
apiresponse.WriteError(w, r, http.StatusBadRequest, "drive.oauth_not_configured", "cloud storage OAuth is not configured for this organization", nil)
default:
apivalidate.WriteInternal(w, r)
}

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

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
import (
"net/url"
"strings"
"github.com/ultisuite/ulti-backend/internal/api/apivalidate"
"github.com/ultisuite/ulti-backend/internal/nextcloud"
)
const maxJSONRequestBody = 32 << 10
type moveRequest struct {
Source string `json:"source"`
Destination string `json:"destination"`
Source string `json:"source"`
Destination string `json:"destination"`
SourceRoot string `json:"source_root,omitempty"`
SourceRootID string `json:"source_root_id,omitempty"`
DestinationRoot string `json:"destination_root,omitempty"`
DestinationRootID string `json:"destination_root_id,omitempty"`
}
type copyRequest struct {
Source string `json:"source"`
Destination string `json:"destination"`
Source string `json:"source"`
Destination string `json:"destination"`
SourceRoot string `json:"source_root,omitempty"`
SourceRootID string `json:"source_root_id,omitempty"`
DestinationRoot string `json:"destination_root,omitempty"`
DestinationRootID string `json:"destination_root_id,omitempty"`
}
type renameRequest struct {
Path string `json:"path"`
NewName string `json:"new_name"`
Root string `json:"root,omitempty"`
RootID string `json:"root_id,omitempty"`
}
type favoriteRequest struct {
Path string `json:"path"`
Favorite bool `json:"favorite"`
Root string `json:"root,omitempty"`
RootID string `json:"root_id,omitempty"`
}
type createMountRequest struct {
Scope string `json:"scope"`
OrgSlug string `json:"org_slug,omitempty"`
DisplayName string `json:"display_name"`
BackendType string `json:"backend_type"`
WebDAV *nextcloud.WebDAVMountConfig `json:"webdav,omitempty"`
OAuthBackend string `json:"oauth_backend,omitempty"`
OAuthAuth string `json:"oauth_auth,omitempty"`
}
func validateMoveRequest(req *moveRequest) *apivalidate.ValidationError {
@ -77,6 +106,8 @@ type createShareRequest struct {
ShareWith string `json:"share_with"`
Note string `json:"note"`
SendMail *bool `json:"send_mail"`
Root string `json:"root,omitempty"`
RootID string `json:"root_id,omitempty"`
}
func sharePermissionsForRole(role string) (int, bool) {
@ -144,11 +175,6 @@ func validateDeleteTrashRequest(req *deleteTrashRequest) *apivalidate.Validation
return nil
}
type favoriteRequest struct {
Path string `json:"path"`
Favorite bool `json:"favorite"`
}
func validateFavoriteRequest(req *favoriteRequest) *apivalidate.ValidationError {
if strings.TrimSpace(req.Path) == "" {
return apivalidate.NewValidationError(apivalidate.FieldDetail{
@ -194,3 +220,24 @@ func validatePath(path string) *apivalidate.ValidationError {
}
return nil
}
const mountOAuthCallbackPath = "/drive/mounts/oauth/callback"
func validateMountOAuthRedirectURI(raw string) error {
raw = strings.TrimSpace(raw)
if raw == "" {
return ErrInvalid
}
parsed, err := url.Parse(raw)
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return ErrInvalid
}
if parsed.Scheme != "http" && parsed.Scheme != "https" {
return ErrInvalid
}
path := strings.TrimRight(parsed.Path, "/")
if path != mountOAuthCallbackPath {
return ErrInvalid
}
return nil
}

View File

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

View File

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

View File

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

View File

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

View File

@ -48,3 +48,15 @@ func marshalWebhookContactsScope(scope *webhookContactsScope) ([]byte, error) {
}
return json.Marshal(out)
}
func marshalWebhookAgendaScope(scope *webhookAgendaScope) ([]byte, error) {
out := automation.AgendaScope{AllCalendars: true}
if scope != nil {
out.AllCalendars = scope.AllCalendars
out.CalendarIDs = scope.CalendarIDs
if out.CalendarIDs == nil {
out.CalendarIDs = []string{}
}
}
return json.Marshal(out)
}

View File

@ -3,35 +3,78 @@ package meet
import (
"log/slog"
"net/http"
"strings"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/ultisuite/ulti-backend/internal/api/apiresponse"
"github.com/ultisuite/ulti-backend/internal/api/apivalidate"
"github.com/ultisuite/ulti-backend/internal/api/middleware"
"github.com/ultisuite/ulti-backend/internal/auth"
meetpkg "github.com/ultisuite/ulti-backend/internal/meet"
"github.com/ultisuite/ulti-backend/internal/nextcloud"
"github.com/ultisuite/ulti-backend/internal/orgpolicy"
)
type Handler struct {
svc *Service
logger *slog.Logger
svc *Service
enabled bool
publicURL string
policy *orgpolicy.Loader
transcripts *TranscriptProcessor
transcriptSecret string
logger *slog.Logger
}
func NewHandler(meetCfg *meetpkg.Config) *Handler {
func NewHandler(
meetCfg *meetpkg.Config,
enabled bool,
publicURL string,
policy *orgpolicy.Loader,
db *pgxpool.Pool,
nc *nextcloud.Client,
transcriptSecret string,
) *Handler {
return &Handler{
svc: NewService(meetCfg),
logger: slog.Default().With("component", "meet-api"),
svc: NewService(meetCfg, policy),
enabled: enabled && meetCfg != nil,
publicURL: strings.TrimRight(strings.TrimSpace(publicURL), "/"),
policy: policy,
transcripts: NewTranscriptProcessor(db, nc, policy),
transcriptSecret: strings.TrimSpace(transcriptSecret),
logger: slog.Default().With("component", "meet-api"),
}
}
func (h *Handler) Routes() chi.Router {
r := chi.NewRouter()
r.Post("/rooms", h.CreateRoom)
r.Post("/rooms/{roomID}/token", h.GetToken)
r.Get("/config", h.GetConfig)
r.Post("/transcripts", h.ReceiveTranscript)
r.Group(func(r chi.Router) {
r.Use(middleware.RequireFullAccount)
r.Post("/rooms", h.CreateRoom)
r.Post("/rooms/{roomID}/token", h.GetToken)
})
return r
}
func (h *Handler) GetConfig(w http.ResponseWriter, r *http.Request) {
resp := map[string]any{
"enabled": h.enabled,
"public_url": h.publicURL,
"brand_name": "UltiMeet",
}
if h.policy != nil {
if pub, err := h.policy.PublicMeetPolicy(r.Context()); err == nil {
resp["transcription_enabled"] = pub.TranscriptionEnabled
resp["transcription_mode"] = pub.TranscriptionMode
resp["auto_start_transcription"] = pub.AutoStartTranscription
}
}
apiresponse.WriteJSON(w, http.StatusOK, resp)
}
func meetUser(claims *auth.Claims) *meetpkg.UserInfo {
return &meetpkg.UserInfo{
ID: claims.Sub,
@ -41,6 +84,10 @@ func meetUser(claims *auth.Claims) *meetpkg.UserInfo {
}
func (h *Handler) CreateRoom(w http.ResponseWriter, r *http.Request) {
if !h.enabled {
apiresponse.WriteError(w, r, http.StatusConflict, "meet_disabled", "meet is disabled", nil)
return
}
claims := middleware.ClaimsFromContext(r.Context())
var req createRoomRequest
@ -56,7 +103,7 @@ func (h *Handler) CreateRoom(w http.ResponseWriter, r *http.Request) {
user := meetUser(claims)
user.IsMod = true
token, err := h.svc.CreateRoom(req.Name, user)
token, err := h.svc.CreateRoom(r.Context(), req.Name, user)
if err != nil {
h.logger.Error("create room token", "error", err)
apivalidate.WriteInternal(w, r)
@ -66,6 +113,10 @@ func (h *Handler) CreateRoom(w http.ResponseWriter, r *http.Request) {
}
func (h *Handler) GetToken(w http.ResponseWriter, r *http.Request) {
if !h.enabled {
apiresponse.WriteError(w, r, http.StatusConflict, "meet_disabled", "meet is disabled", nil)
return
}
claims := middleware.ClaimsFromContext(r.Context())
roomID := chi.URLParam(r, "roomID")
if verr := validateRoomID(roomID); verr != nil {
@ -76,7 +127,7 @@ func (h *Handler) GetToken(w http.ResponseWriter, r *http.Request) {
user := meetUser(claims)
user.IsMod = false
token, err := h.svc.GetToken(roomID, user)
token, err := h.svc.GetToken(r.Context(), roomID, user)
if err != nil {
h.logger.Error("get room token", "error", err)
apivalidate.WriteInternal(w, r)
@ -84,3 +135,50 @@ func (h *Handler) GetToken(w http.ResponseWriter, r *http.Request) {
}
apiresponse.WriteJSON(w, http.StatusOK, token)
}
func (h *Handler) ReceiveTranscript(w http.ResponseWriter, r *http.Request) {
if h.transcriptSecret != "" {
authHeader := strings.TrimSpace(r.Header.Get("Authorization"))
if authHeader != "Bearer "+h.transcriptSecret {
apiresponse.WriteError(w, r, http.StatusUnauthorized, "auth.unauthorized", "unauthorized", nil)
return
}
}
var req transcriptRequest
if err := apivalidate.DecodeJSON(w, r, 2<<20, &req); err != nil {
return
}
if verr := validateTranscriptRequest(&req); verr != nil {
apivalidate.WriteValidationError(w, r, verr)
return
}
claims := middleware.ClaimsFromContext(r.Context())
organizerUserID := strings.TrimSpace(req.OrganizerUserID)
organizerEmail := strings.TrimSpace(req.OrganizerEmail)
if claims != nil {
if organizerUserID == "" {
organizerUserID = claims.Sub
}
if organizerEmail == "" {
organizerEmail = claims.Email
}
}
err := h.transcripts.Handle(r.Context(), transcriptJobInput{
RoomID: strings.TrimSpace(req.RoomID),
OrganizerUserID: organizerUserID,
OrganizerEmail: organizerEmail,
ParticipantEmails: req.ParticipantEmails,
RawTranscript: req.Transcript,
Mode: strings.TrimSpace(req.Mode),
QueuedAudioURL: strings.TrimSpace(req.QueuedAudioURL),
})
if err != nil {
h.logger.Error("process transcript", "error", err)
apiresponse.WriteError(w, r, http.StatusBadRequest, "transcript_failed", err.Error(), nil)
return
}
apiresponse.WriteJSON(w, http.StatusAccepted, map[string]any{"ok": true})
}

View File

@ -1,30 +1,46 @@
package meet
import (
"context"
"strings"
"time"
"github.com/google/uuid"
meetpkg "github.com/ultisuite/ulti-backend/internal/meet"
"github.com/ultisuite/ulti-backend/internal/orgpolicy"
)
type Service struct {
cfg *meetpkg.Config
cfg *meetpkg.Config
policy *orgpolicy.Loader
}
func NewService(cfg *meetpkg.Config) *Service {
return &Service{cfg: cfg}
func NewService(cfg *meetpkg.Config, policy *orgpolicy.Loader) *Service {
return &Service{cfg: cfg, policy: policy}
}
func (s *Service) CreateRoom(name string, user *meetpkg.UserInfo) (*meetpkg.RoomToken, error) {
func (s *Service) tokenOpts(ctx context.Context, user *meetpkg.UserInfo) meetpkg.TokenOptions {
opts := meetpkg.TokenOptions{}
if s.policy == nil {
return opts
}
p, err := s.policy.MeetPolicy(ctx)
if err != nil || !p.LiveTranscriptionJWT() {
return opts
}
opts.Transcription = user.IsMod || p.AutoStartTranscription
return opts
}
func (s *Service) CreateRoom(ctx context.Context, name string, user *meetpkg.UserInfo) (*meetpkg.RoomToken, error) {
roomID := uuid.New().String()[:8]
if strings.TrimSpace(name) != "" {
roomID = strings.TrimSpace(name)
}
return s.cfg.GenerateToken(roomID, user, 24*time.Hour)
return s.cfg.GenerateToken(roomID, user, 24*time.Hour, s.tokenOpts(ctx, user))
}
func (s *Service) GetToken(roomID string, user *meetpkg.UserInfo) (*meetpkg.RoomToken, error) {
return s.cfg.GenerateToken(roomID, user, 4*time.Hour)
func (s *Service) GetToken(ctx context.Context, roomID string, user *meetpkg.UserInfo) (*meetpkg.RoomToken, error) {
return s.cfg.GenerateToken(roomID, user, 4*time.Hour, s.tokenOpts(ctx, user))
}

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
import (
"encoding/json"
"errors"
"log/slog"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/ultisuite/ulti-backend/internal/api/apiresponse"
@ -12,23 +15,28 @@ import (
"github.com/ultisuite/ulti-backend/internal/api/middleware"
"github.com/ultisuite/ulti-backend/internal/permission"
platformusers "github.com/ultisuite/ulti-backend/internal/users"
"github.com/ultisuite/ulti-backend/internal/orgpolicy"
)
type Handler struct {
db *pgxpool.Pool
logger *slog.Logger
db *pgxpool.Pool
logger *slog.Logger
orgPolicy *orgpolicy.Loader
}
func NewHandler(db *pgxpool.Pool) *Handler {
return &Handler{
db: db,
logger: slog.Default().With("component", "users-api"),
db: db,
orgPolicy: orgpolicy.NewLoader(db, nil),
logger: slog.Default().With("component", "users-api"),
}
}
func (h *Handler) Routes() chi.Router {
r := chi.NewRouter()
r.Get("/me", h.Me)
r.Put("/me/avatar", h.PutAvatar)
r.Delete("/me/avatar", h.DeleteAvatar)
return r
}
@ -47,7 +55,28 @@ func (h *Handler) Me(w http.ResponseWriter, r *http.Request) {
}
role := permission.DeriveAccountRole(state.PlatformAdmin, state.Status)
apiresponse.WriteJSON(w, http.StatusOK, map[string]any{
orgAgenda, err := h.orgPolicy.PublicAgendaPolicy(r.Context())
if err != nil {
h.logger.Warn("read org agenda policy", "error", err)
orgAgenda = orgpolicy.PublicAgendaPolicy{
DefaultThemeMode: "system",
DefaultVideoProvider: "ultimeet",
ConfiguredVideoProviders: []string{"ultimeet"},
}
}
orgDrive, err := h.orgPolicy.PublicDrivePolicy(r.Context())
if err != nil {
h.logger.Warn("read org drive policy", "error", err)
orgDrive = orgpolicy.PublicDrivePolicy{}
}
avatarURL, err := platformusers.GetAvatarURL(r.Context(), h.db, claims.Sub)
if err != nil {
h.logger.Warn("read user avatar", "error", err)
}
payload := map[string]any{
"sub": claims.Sub,
"email": claims.Email,
"name": claims.Name,
@ -55,5 +84,67 @@ func (h *Handler) Me(w http.ResponseWriter, r *http.Request) {
"platform_admin": state.PlatformAdmin,
"role": role,
"groups": claims.Groups,
"org_agenda": orgAgenda,
"org_drive": orgDrive,
}
if avatarURL != "" {
payload["avatar_url"] = avatarURL
}
apiresponse.WriteJSON(w, http.StatusOK, payload)
}
func (h *Handler) PutAvatar(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
if claims == nil {
apiresponse.WriteError(w, r, http.StatusUnauthorized, apiresponse.CodeAuthUnauthorized, "unauthorized", nil)
return
}
var body struct {
AvatarURL string `json:"avatar_url"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
apiresponse.WriteError(w, r, http.StatusBadRequest, apiresponse.CodeInvalidRequest, "invalid json body", nil)
return
}
if err := platformusers.SetAvatarURL(r.Context(), h.db, claims.Sub, body.AvatarURL); err != nil {
switch {
case errors.Is(err, platformusers.ErrAvatarTooLarge):
apiresponse.WriteError(w, r, http.StatusBadRequest, apiresponse.CodeInvalidRequest, "avatar too large (max 512 KiB)", nil)
case errors.Is(err, platformusers.ErrAvatarInvalid):
apiresponse.WriteError(w, r, http.StatusBadRequest, apiresponse.CodeInvalidRequest, "invalid avatar image", nil)
case errors.Is(err, pgx.ErrNoRows):
apivalidate.WriteNotFound(w, r, "user not found")
default:
h.logger.Error("set user avatar", "error", err)
apivalidate.WriteInternal(w, r)
}
return
}
apiresponse.WriteJSON(w, http.StatusOK, map[string]any{
"avatar_url": body.AvatarURL,
})
}
func (h *Handler) DeleteAvatar(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
if claims == nil {
apiresponse.WriteError(w, r, http.StatusUnauthorized, apiresponse.CodeAuthUnauthorized, "unauthorized", nil)
return
}
if err := platformusers.ClearAvatarURL(r.Context(), h.db, claims.Sub); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
apivalidate.WriteNotFound(w, r, "user not found")
return
}
h.logger.Error("clear user avatar", "error", err)
apivalidate.WriteInternal(w, r)
return
}
apiresponse.WriteJSON(w, http.StatusOK, map[string]any{"ok": true})
}

View File

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

View File

@ -82,6 +82,9 @@ func RequirementForRequest(method, fullPath, typesQuery string) (Requirement, bo
case strings.HasPrefix(path, "/api/v1/drive/"):
return driveRequirement(method, path)
case strings.HasPrefix(path, "/api/v1/calendar/"):
return calendarRequirement(method, path)
case strings.HasPrefix(path, "/api/v1/richtext/"):
return richtextRequirement(method, path)
@ -202,6 +205,30 @@ func driveRequirement(method, path string) (Requirement, bool) {
}
}
func calendarRequirement(method, path string) (Requirement, bool) {
write := method != http.MethodGet && method != http.MethodHead
switch {
case strings.HasSuffix(path, "/freebusy"):
return Requirement{Resource: "agenda.freebusy", Write: false}, true
case strings.Contains(path, "/events/response/"):
return Requirement{Resource: "agenda.response", Write: true}, true
case strings.Contains(path, "/events/meet-link/"):
return Requirement{Resource: "agenda.events.write", Write: true}, true
case strings.Contains(path, "/events/"):
if write {
return Requirement{Resource: "agenda.events.write", Write: true}, true
}
return Requirement{Resource: "agenda.events", Write: false}, true
case method == http.MethodDelete:
return Requirement{Resource: "agenda.events.delete", Write: true}, true
case write:
return Requirement{Resource: "agenda.calendars", Write: true}, true
default:
return Requirement{Resource: "agenda.calendars", Write: false}, true
}
}
func searchRequirement(typesQuery string) (Requirement, bool) {
types := parseSearchTypes(typesQuery)
if len(types) == 0 {

View File

@ -33,6 +33,15 @@ func AllowsDrivePath(auth *AuthContext, rawPath string) bool {
if target == "" {
return true
}
// Scoped paths: org:{id}:/path or mount:{id}:/path
if strings.HasPrefix(target, "org:") || strings.HasPrefix(target, "mount:") {
for _, allowed := range auth.DriveScope.FolderPaths {
if driveScopePrefixMatch(target, allowed) {
return true
}
}
return false
}
for _, allowed := range auth.DriveScope.FolderPaths {
if drivePathWithinScope(target, allowed) {
return true
@ -41,6 +50,14 @@ func AllowsDrivePath(auth *AuthContext, rawPath string) bool {
return false
}
func driveScopePrefixMatch(target, allowed string) bool {
allowed = strings.TrimSpace(allowed)
if allowed == "" || allowed == "/" {
return true
}
return target == allowed || strings.HasPrefix(target, allowed+":") || strings.HasPrefix(target, allowed+"/")
}
func NormalizeDriveScopePath(rawPath string) string {
rawPath = strings.TrimSpace(rawPath)
if rawPath == "" {
@ -67,3 +84,18 @@ func drivePathWithinScope(target, allowed string) bool {
}
return strings.HasPrefix(target, allowed+"/")
}
func AllowsAgendaCalendar(auth *AuthContext, calendarID string) bool {
if auth == nil || calendarID == "" {
return true
}
if auth.AgendaScope.AllCalendars {
return true
}
for _, id := range auth.AgendaScope.CalendarIDs {
if id == calendarID {
return true
}
}
return false
}

View File

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

View File

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

View File

@ -19,6 +19,11 @@ type ContactsScope struct {
BookIDs []string `json:"book_ids"`
}
type AgendaScope struct {
AllCalendars bool `json:"all_calendars"`
CalendarIDs []string `json:"calendar_ids"`
}
func AllowsMailScope(scope MailScope, accountID string) bool {
if accountID == "" {
return true
@ -79,3 +84,18 @@ func AllowsContactsScope(scope ContactsScope, bookID string) bool {
}
return false
}
func AllowsAgendaScope(scope AgendaScope, calendarID string) bool {
if calendarID == "" {
return true
}
if scope.AllCalendars {
return true
}
for _, id := range scope.CalendarIDs {
if id == calendarID {
return true
}
}
return false
}

View File

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

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 {
fieldValue = evt.ContactOrg
}
case "calendar_event_title":
if evt != nil {
fieldValue = evt.CalendarEventTitle
}
case "calendar_event_location":
if evt != nil {
fieldValue = evt.CalendarEventLocation
}
case "calendar_event_organizer":
if evt != nil {
fieldValue = evt.CalendarEventOrganizer
}
case "calendar_event_attendee":
if evt != nil {
fieldValue = evt.CalendarEventAttendee
}
case "calendar_event_all_day":
if evt != nil {
if evt.CalendarEventAllDay {
fieldValue = "true"
} else {
fieldValue = "false"
}
}
case "calendar_event_has_video":
if evt != nil {
if evt.CalendarEventHasVideo {
fieldValue = "true"
} else {
fieldValue = "false"
}
}
default:
return false
}
@ -433,6 +465,9 @@ func (e *Engine) executeAction(ctx context.Context, action Action, msg *Message,
case "contact_add_label", "contact_remove_label", "contact_delete":
e.logger.Info("deferred contact action", "type", action.Type, "value", action.Value)
return nil
case "calendar_add_attendee", "calendar_update_title", "calendar_cancel_event", "calendar_notify_attendees":
e.logger.Info("deferred calendar action", "type", action.Type, "value", action.Value)
return nil
default:
return fmt.Errorf("unknown action type: %s", action.Type)
}

View File

@ -29,6 +29,10 @@ const (
TriggerContactCreated TriggerType = "contact_created"
TriggerContactUpdated TriggerType = "contact_updated"
TriggerContactDeleted TriggerType = "contact_deleted"
TriggerCalendarEventCreated TriggerType = "calendar_event_created"
TriggerCalendarEventUpdated TriggerType = "calendar_event_updated"
TriggerCalendarEventDeleted TriggerType = "calendar_event_deleted"
TriggerCalendarEventResponse TriggerType = "calendar_event_response"
)
type Trigger struct {
@ -38,6 +42,7 @@ type Trigger struct {
AccountID string `json:"account_id,omitempty"`
FolderPath string `json:"folder_path,omitempty"`
ContactLabel string `json:"contact_label,omitempty"`
CalendarID string `json:"calendar_id,omitempty"`
}
type TriggerGroup struct {
@ -130,6 +135,7 @@ type EventContext struct {
Label string
FolderPath string
ContactLabel string
CalendarID string
// Drive payload (when domain is drive)
DriveFileName string
DriveFilePath string
@ -143,6 +149,16 @@ type EventContext struct {
ContactEmail string
ContactPhone string
ContactOrg string
// Calendar payload (when domain is agenda)
CalendarEventTitle string
CalendarEventLocation string
CalendarEventOrganizer string
CalendarEventAttendee string
CalendarEventStart string
CalendarEventEnd string
CalendarEventAllDay bool
CalendarEventHasVideo bool
CalendarEventUID string
}
func ParseWorkflow(raw []byte) (*Workflow, error) {
@ -276,6 +292,14 @@ func matchTrigger(t Trigger, msg *Message, evt *EventContext) bool {
return false
}
return true
case TriggerCalendarEventCreated, TriggerCalendarEventUpdated, TriggerCalendarEventDeleted, TriggerCalendarEventResponse:
if evt == nil || evt.Type != t.Type {
return false
}
if t.CalendarID != "" && evt.CalendarID != "" && t.CalendarID != evt.CalendarID {
return false
}
return true
default:
return false
}

View File

@ -272,6 +272,24 @@ func workflowFieldValue(field string, msg *Message, evt *EventContext, execCtx *
return evt.ContactOrg
case "contact_label":
return evt.ContactLabel
case "calendar_event_title":
return evt.CalendarEventTitle
case "calendar_event_location":
return evt.CalendarEventLocation
case "calendar_event_organizer":
return evt.CalendarEventOrganizer
case "calendar_event_attendee":
return evt.CalendarEventAttendee
case "calendar_event_all_day":
if evt.CalendarEventAllDay {
return "true"
}
return "false"
case "calendar_event_has_video":
if evt.CalendarEventHasVideo {
return "true"
}
return "false"
default:
return ""
}

View File

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

View File

@ -7,6 +7,7 @@ import (
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
)
@ -32,6 +33,7 @@ type Event struct {
Attendees []EventAttendee `json:"attendees,omitempty"`
MeetURL string `json:"meet_url,omitempty"`
Color string `json:"color,omitempty"`
Sequence int `json:"sequence,omitempty"`
RRule string `json:"rrule,omitempty"`
ExDates []string `json:"exdates,omitempty"`
RawICS string `json:"raw_ics,omitempty"`
@ -215,6 +217,7 @@ func (c *Client) CreateEvent(ctx context.Context, userID, calendarPath string, e
}
func (c *Client) GetEvent(ctx context.Context, userID, eventPath string) (*Event, error) {
eventPath = normalizeDAVHref(eventPath)
resp, err := c.DoAsUser(ctx, "GET", eventPath, nil, userID, nil)
if err != nil {
return nil, err
@ -234,7 +237,68 @@ func (c *Client) GetEvent(ctx context.Context, userID, eventPath string) (*Event
return &event, nil
}
func uidFromEventPath(eventPath string) string {
eventPath = strings.TrimSuffix(strings.TrimSpace(eventPath), "/")
if idx := strings.LastIndex(eventPath, "/"); idx >= 0 {
eventPath = eventPath[idx+1:]
}
return strings.TrimSuffix(eventPath, ".ics")
}
// MergeEvent overlays patch onto existing. Patch wins for editable fields; UID and
// exdates fall back to existing when absent so CalDAV PUT keeps a valid master.
func MergeEvent(existing, patch *Event) *Event {
if existing == nil {
return patch
}
if patch == nil {
out := *existing
return &out
}
merged := *existing
merged.Summary = patch.Summary
merged.Description = patch.Description
merged.Location = patch.Location
if strings.TrimSpace(patch.Start) != "" {
merged.Start = patch.Start
merged.AllDay = patch.AllDay
}
if strings.TrimSpace(patch.End) != "" {
merged.End = patch.End
}
if strings.TrimSpace(patch.UID) != "" {
merged.UID = patch.UID
}
if len(patch.Attendees) > 0 {
merged.Attendees = patch.Attendees
}
if strings.TrimSpace(patch.Organizer) != "" {
merged.Organizer = patch.Organizer
}
if strings.TrimSpace(patch.MeetURL) != "" {
merged.MeetURL = patch.MeetURL
}
if strings.TrimSpace(patch.Color) != "" {
merged.Color = patch.Color
}
if strings.TrimSpace(patch.RRule) != "" {
merged.RRule = patch.RRule
}
if patch.ExDates != nil {
merged.ExDates = patch.ExDates
}
if strings.TrimSpace(merged.UID) == "" {
merged.UID = uidFromEventPath(existing.Path)
}
return &merged
}
func (c *Client) UpdateEvent(ctx context.Context, userID, eventPath, ifMatch string, event *Event) (string, error) {
eventPath = normalizeDAVHref(eventPath)
if strings.TrimSpace(event.UID) == "" {
event.UID = uidFromEventPath(eventPath)
}
ics := buildICS(event)
headers := map[string]string{
"Content-Type": "text/calendar; charset=utf-8",
@ -258,6 +322,7 @@ func (c *Client) UpdateEvent(ctx context.Context, userID, eventPath, ifMatch str
}
func (c *Client) DeleteEvent(ctx context.Context, userID, eventPath string) error {
eventPath = normalizeDAVHref(eventPath)
resp, err := c.DoAsUser(ctx, "DELETE", eventPath, nil, userID, nil)
if err != nil {
return err
@ -484,9 +549,6 @@ func buildICS(event *Event) string {
b.WriteString(fmt.Sprintf("URL:%s\r\n", strings.TrimSpace(event.MeetURL)))
b.WriteString(fmt.Sprintf("X-ULTI-MEET-URL:%s\r\n", strings.TrimSpace(event.MeetURL)))
}
if strings.TrimSpace(event.Color) != "" {
b.WriteString(fmt.Sprintf("COLOR:%s\r\n", strings.TrimSpace(event.Color)))
}
if strings.TrimSpace(event.RRule) != "" {
b.WriteString(fmt.Sprintf("RRULE:%s\r\n", strings.TrimSpace(event.RRule)))
}
@ -503,6 +565,9 @@ func buildICS(event *Event) string {
}
writeDateProp(&b, "DTSTART", event.Start, event.AllDay)
writeDateProp(&b, "DTEND", event.End, event.AllDay)
if event.Sequence > 0 {
b.WriteString(fmt.Sprintf("SEQUENCE:%d\r\n", event.Sequence))
}
b.WriteString(fmt.Sprintf("DTSTAMP:%s\r\n", time.Now().UTC().Format("20060102T150405Z")))
b.WriteString("END:VEVENT\r\n")
b.WriteString("END:VCALENDAR\r\n")
@ -515,9 +580,11 @@ func parseCalendarList(body io.Reader, basePath string) ([]Calendar, error) {
return nil, err
}
basePath = normalizeDAVHref(basePath)
calendars := make([]Calendar, 0)
for _, r := range ms.Responses {
if r.Href == basePath {
href := normalizeDAVHref(r.Href)
if href == basePath {
continue
}
name := r.Propstat.Prop.DisplayName
@ -525,10 +592,10 @@ func parseCalendarList(body io.Reader, basePath string) ([]Calendar, error) {
continue
}
calendars = append(calendars, Calendar{
ID: strings.TrimSuffix(strings.TrimPrefix(r.Href, basePath), "/"),
ID: strings.TrimSuffix(strings.TrimPrefix(href, basePath), "/"),
DisplayName: name,
Color: r.Propstat.Prop.CalendarColor,
Path: r.Href,
Path: href,
})
}
return calendars, nil
@ -545,7 +612,7 @@ func parseEventList(body io.Reader) ([]Event, error) {
ics := r.Propstat.Prop.CalendarData
event := parseICS(ics)
event.RawICS = ics
event.Path = r.Href
event.Path = normalizeDAVHref(r.Href)
event.ETag = strings.TrimSpace(r.Propstat.Prop.ETag)
events = append(events, event)
}
@ -679,6 +746,10 @@ func parseICS(ics string) Event {
e.Color = value
case "RRULE":
e.RRule = value
case "SEQUENCE":
if seq, err := strconv.Atoi(strings.TrimSpace(value)); err == nil {
e.Sequence = seq
}
case "EXDATE":
for _, ex := range strings.Split(value, ",") {
normalized, _ := normalizeICSDate(ex, params)

View File

@ -131,8 +131,8 @@ func TestBuildICSRoundTrip(t *testing.T) {
if parsed.RRule != event.RRule {
t.Fatalf("RRule = %q", parsed.RRule)
}
if parsed.Color != event.Color {
t.Fatalf("Color = %q", parsed.Color)
if parsed.Color != "" {
t.Fatalf("Color should not be serialized in ICS, got %q", parsed.Color)
}
if len(parsed.ExDates) != 1 || parsed.ExDates[0] != "20260618T100000Z" {
t.Fatalf("ExDates = %v", parsed.ExDates)
@ -145,6 +145,48 @@ func TestBuildICSRoundTrip(t *testing.T) {
}
}
func TestMergeEventPreservesUID(t *testing.T) {
existing := &Event{
UID: "abc@ulti",
Summary: "Original",
Start: "20260611T100000Z",
End: "20260611T110000Z",
Path: "/remote.php/dav/calendars/user/personal/abc@ulti.ics",
MeetURL: "https://meet.example/room",
ExDates: []string{"20260618T100000Z"},
}
patch := &Event{
Summary: "Updated title",
Start: "20260612T100000Z",
End: "20260612T110000Z",
}
merged := MergeEvent(existing, patch)
if merged.UID != "abc@ulti" {
t.Fatalf("UID = %q", merged.UID)
}
if merged.Summary != "Updated title" {
t.Fatalf("Summary = %q", merged.Summary)
}
if merged.MeetURL != "https://meet.example/room" {
t.Fatalf("MeetURL should be preserved, got %q", merged.MeetURL)
}
if len(merged.ExDates) != 1 {
t.Fatalf("ExDates = %v", merged.ExDates)
}
}
func TestMergeEventUIDFromPath(t *testing.T) {
existing := &Event{
Summary: "Keep",
Path: "/remote.php/dav/calendars/user/personal/fallback@ulti.ics",
}
patch := &Event{Summary: "New"}
merged := MergeEvent(existing, patch)
if merged.UID != "fallback@ulti" {
t.Fatalf("UID = %q", merged.UID)
}
}
func TestBuildICSAllDay(t *testing.T) {
ics := buildICS(&Event{UID: "ad", Summary: "Férié", Start: "20260714", End: "20260715", AllDay: true})
if !strings.Contains(ics, "DTSTART;VALUE=DATE:20260714") {
@ -155,3 +197,35 @@ func TestBuildICSAllDay(t *testing.T) {
t.Fatal("AllDay should round-trip")
}
}
func TestParseCalendarListNormalizesCloudPrefix(t *testing.T) {
basePath := "/remote.php/dav/calendars/user@example.com/"
raw := `<?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)
}
func joinBaseURL(baseURL, path string) string {
if !strings.HasPrefix(path, "/") {
path = "/" + path
}
return baseURL + path
}
func (c *Client) doRequest(ctx context.Context, method, path string, body io.Reader, headers map[string]string) (*http.Response, error) {
url := c.baseURL + path
url := joinBaseURL(c.baseURL, path)
req, err := http.NewRequestWithContext(ctx, method, url, body)
if err != nil {
return nil, err
@ -84,7 +91,7 @@ func (c *Client) doAsUser(ctx context.Context, method, path string, body io.Read
return nil, err
}
url := c.baseURL + path
url := joinBaseURL(c.baseURL, path)
req, err := http.NewRequestWithContext(ctx, method, url, body)
if err != nil {
return nil, err

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"
)
type FileCapabilities struct {
Share bool `json:"share"`
Trash bool `json:"trash"`
Preview bool `json:"preview"`
}
type FileInfo struct {
Path string `json:"path"`
Name string `json:"name"`
Type string `json:"type"` // "file" or "directory"
Size int64 `json:"size"`
MimeType string `json:"mime_type"`
LastModified string `json:"last_modified"`
ETag string `json:"etag"`
FileID int64 `json:"file_id,omitempty"`
IsFavorite bool `json:"is_favorite"`
IsShared bool `json:"is_shared"`
Source string `json:"source,omitempty"`
Path string `json:"path"`
Name string `json:"name"`
Type string `json:"type"` // "file" or "directory"
Size int64 `json:"size"`
MimeType string `json:"mime_type"`
LastModified string `json:"last_modified"`
ETag string `json:"etag"`
FileID int64 `json:"file_id,omitempty"`
IsFavorite bool `json:"is_favorite"`
IsShared bool `json:"is_shared"`
Source string `json:"source,omitempty"`
RootKind string `json:"root_kind,omitempty"`
RootID string `json:"root_id,omitempty"`
Capabilities *FileCapabilities `json:"capabilities,omitempty"`
}
type ShareInfo struct {

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,
}).Search)
if driveHandler != nil {
r.Mount("/api/v1/calendar", calendar.NewHandler(ncClient, meetCfg).Routes())
r.Mount("/api/v1/calendar", calendar.NewHandler(ncClient, meetCfg, orgPolicyLoader).Routes())
r.Mount("/api/v1/contacts", contactsHandler.Routes())
}
if meetCfg != nil {
r.Mount("/api/v1/meet", meetapi.NewHandler(meetCfg).Routes())
}
r.Mount("/api/v1/meet", meetapi.NewHandler(
meetCfg,
cfg.JitsiEnabled,
cfg.JitsiPublicURL,
orgPolicyLoader,
pool,
ncClient,
cfg.MeetTranscriptWebhookSecret,
).Routes())
if photosClient != nil {
r.Mount("/api/v1/photos", photosapi.NewHandler(photosClient, ncClient).Routes())
}

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