feat(transcription): integrate Faster Whisper for Jitsi transcriptions
- 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:
parent
1fda9e7bac
commit
1d063237b9
@ -189,6 +189,10 @@ JITSI_DOMAIN=meet.jitsi
|
|||||||
JITSI_APP_ID=ulti
|
JITSI_APP_ID=ulti
|
||||||
# JITSI_APP_SECRET — defini dans la section Secrets
|
# JITSI_APP_SECRET — defini dans la section Secrets
|
||||||
JITSI_PUBLIC_URL=https://{{DOMAIN}}/meet
|
JITSI_PUBLIC_URL=https://{{DOMAIN}}/meet
|
||||||
|
# Secret partagé avec Jigasi pour POST /api/v1/meet/transcripts
|
||||||
|
MEET_TRANSCRIPT_WEBHOOK_SECRET=changeme-meet-transcript-secret
|
||||||
|
# Modèle Faster Whisper (Skynet) : tiny, base, small…
|
||||||
|
SKYNET_WHISPER_MODEL=tiny
|
||||||
|
|
||||||
JICOFO_AUTH_PASSWORD={{JITSI_INTERNAL_AUTH_PASSWORD}}
|
JICOFO_AUTH_PASSWORD={{JITSI_INTERNAL_AUTH_PASSWORD}}
|
||||||
JVB_AUTH_PASSWORD={{JITSI_INTERNAL_AUTH_PASSWORD}}
|
JVB_AUTH_PASSWORD={{JITSI_INTERNAL_AUTH_PASSWORD}}
|
||||||
|
|||||||
@ -104,7 +104,7 @@ Un seul **nginx** expose l’entrée HTTP (`:80`) et route :
|
|||||||
| `/auth/*` | Authentik |
|
| `/auth/*` | Authentik |
|
||||||
| `/meet/*` | Jitsi (si `JITSI_ENABLED=true`) |
|
| `/meet/*` | Jitsi (si `JITSI_ENABLED=true`) |
|
||||||
| `/cloud/*` | Nextcloud nginx+FPM (si `NEXTCLOUD_ENABLED=true`) |
|
| `/cloud/*` | Nextcloud nginx+FPM (si `NEXTCLOUD_ENABLED=true`) |
|
||||||
| `/mail/*`, `/drive/*`, `/contacts`, `/admin/*` | Suite frontend (`MAIL_FRONTEND_UPSTREAM`, défaut `host.docker.internal:3004` ; Docker : `suite-frontend:3000`) |
|
| `/mail/*`, `/drive/*`, `/contacts`, `/agenda`, `/compte`, `/admin/*` | Suite frontend (`MAIL_FRONTEND_UPSTREAM`, défaut `host.docker.internal:3004` ; Docker : `suite-frontend:3000`) |
|
||||||
|
|
||||||
Nextcloud : FPM + nginx dédié ; ultid appelle `NEXTCLOUD_URL` en interne (`http://nextcloud:80`).
|
Nextcloud : FPM + nginx dédié ; ultid appelle `NEXTCLOUD_URL` en interne (`http://nextcloud:80`).
|
||||||
Caddy retiré : un seul proxy évite la double couche ; TLS plus tard (certbot, Traefik, ou `listen 443` nginx).
|
Caddy retiré : un seul proxy évite la double couche ; TLS plus tard (certbot, Traefik, ou `listen 443` nginx).
|
||||||
|
|||||||
@ -69,3 +69,45 @@ services:
|
|||||||
- ulti-net
|
- ulti-net
|
||||||
depends_on:
|
depends_on:
|
||||||
- jitsi-prosody
|
- jitsi-prosody
|
||||||
|
|
||||||
|
skynet:
|
||||||
|
build:
|
||||||
|
context: ./skynet
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
ENABLED_MODULES: streaming_whisper
|
||||||
|
BYPASS_AUTHORIZATION: "1"
|
||||||
|
WHISPER_MODEL_NAME: ${SKYNET_WHISPER_MODEL:-tiny}
|
||||||
|
WHISPER_MODEL_PATH: /models/streaming-whisper
|
||||||
|
BEAM_SIZE: "1"
|
||||||
|
volumes:
|
||||||
|
- skynet-models:/models
|
||||||
|
networks:
|
||||||
|
- ulti-net
|
||||||
|
|
||||||
|
jitsi-jigasi:
|
||||||
|
image: jitsi/jigasi:stable-9823
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
<<: *jitsi-env
|
||||||
|
XMPP_DOMAIN: meet.jitsi
|
||||||
|
XMPP_MUC_DOMAIN: muc.meet.jitsi
|
||||||
|
XMPP_INTERNAL_MUC_DOMAIN: internal-muc.meet.jitsi
|
||||||
|
JIGASI_BREWERY_MUC: jigasibrewery@internal-muc.meet.jitsi
|
||||||
|
JIGASI_ENABLE_SDES_SRTP: "0"
|
||||||
|
ENABLE_TRANSCRIPTIONS: "1"
|
||||||
|
JIGASI_TRANSCRIBER_CUSTOM_SERVICE: org.jitsi.jigasi.transcription.WhisperTranscriptionService
|
||||||
|
JIGASI_TRANSCRIBER_WHISPER_URL: ws://skynet:8000/streaming-whisper/ws
|
||||||
|
JIGASI_TRANSCRIBER_SEND_JSON: "true"
|
||||||
|
JIGASI_TRANSCRIBER_BASE_URL: http://ultid:8080/api/v1/meet/transcripts/
|
||||||
|
volumes:
|
||||||
|
- ./jigasi:/config:ro
|
||||||
|
networks:
|
||||||
|
- ulti-net
|
||||||
|
depends_on:
|
||||||
|
- jitsi-prosody
|
||||||
|
- skynet
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
skynet-models:
|
||||||
|
|||||||
5
deploy/jitsi/jigasi/sip-communicator.properties
Normal file
5
deploy/jitsi/jigasi/sip-communicator.properties
Normal 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/
|
||||||
23
deploy/jitsi/skynet/Dockerfile
Normal file
23
deploy/jitsi/skynet/Dockerfile
Normal 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"]
|
||||||
@ -18,6 +18,8 @@ $OCC app:enable files || true
|
|||||||
$OCC app:enable calendar || true
|
$OCC app:enable calendar || true
|
||||||
$OCC app:enable contacts || true
|
$OCC app:enable contacts || true
|
||||||
$OCC app:enable groupfolders || true
|
$OCC app:enable groupfolders || true
|
||||||
|
$OCC app:enable files_external || true
|
||||||
|
$OCC config:app:set files_external allow_user_mounting --value=1 || true
|
||||||
$OCC app:enable user_oidc || true
|
$OCC app:enable user_oidc || true
|
||||||
|
|
||||||
# Configure OIDC (Authentik)
|
# Configure OIDC (Authentik)
|
||||||
|
|||||||
@ -107,6 +107,10 @@ server {
|
|||||||
proxy_set_header Upgrade $http_upgrade;
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
proxy_set_header Connection $connection_upgrade;
|
proxy_set_header Connection $connection_upgrade;
|
||||||
proxy_read_timeout 86400;
|
proxy_read_timeout 86400;
|
||||||
|
proxy_hide_header X-Frame-Options;
|
||||||
|
proxy_hide_header Content-Security-Policy;
|
||||||
|
# Permet l’embed du portail Authentik dans la suite (même host + dev Next :3004).
|
||||||
|
add_header Content-Security-Policy "frame-ancestors 'self' http://localhost:3004 http://127.0.0.1:3004" always;
|
||||||
}
|
}
|
||||||
|
|
||||||
location /meet/ {
|
location /meet/ {
|
||||||
@ -248,7 +252,7 @@ server {
|
|||||||
proxy_set_header Connection $connection_upgrade;
|
proxy_set_header Connection $connection_upgrade;
|
||||||
}
|
}
|
||||||
|
|
||||||
# Ulti Suite frontend (mail + drive + contacts) — dev: pnpm dev on host (MAIL_FRONTEND_UPSTREAM=host.docker.internal:3004)
|
# Ulti Suite frontend (mail + drive + contacts + agenda + compte) — dev: pnpm dev on host (MAIL_FRONTEND_UPSTREAM=host.docker.internal:3004)
|
||||||
# Prod: set MAIL_FRONTEND_UPSTREAM=suite-frontend:3000
|
# Prod: set MAIL_FRONTEND_UPSTREAM=suite-frontend:3000
|
||||||
# Démos publiques de la landing (zéro rétention) — frontend Next.
|
# Démos publiques de la landing (zéro rétention) — frontend Next.
|
||||||
location ^~ /demo {
|
location ^~ /demo {
|
||||||
@ -357,6 +361,19 @@ server {
|
|||||||
proxy_set_header Connection $connection_upgrade;
|
proxy_set_header Connection $connection_upgrade;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
location ^~ /agenda {
|
||||||
|
resolver 127.0.0.11 valid=10s ipv6=off;
|
||||||
|
set $mail_upstream ${MAIL_FRONTEND_UPSTREAM};
|
||||||
|
proxy_pass http://$mail_upstream;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection $connection_upgrade;
|
||||||
|
}
|
||||||
|
|
||||||
# Réglages du compte Ulti
|
# Réglages du compte Ulti
|
||||||
location ^~ /compte {
|
location ^~ /compte {
|
||||||
resolver 127.0.0.11 valid=10s ipv6=off;
|
resolver 127.0.0.11 valid=10s ipv6=off;
|
||||||
@ -398,6 +415,18 @@ server {
|
|||||||
proxy_set_header Connection $connection_upgrade;
|
proxy_set_header Connection $connection_upgrade;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# next/font (Geist, etc.) — separate from /_next/ static chunks
|
||||||
|
location ^~ /__nextjs_font/ {
|
||||||
|
resolver 127.0.0.11 valid=10s ipv6=off;
|
||||||
|
set $mail_upstream ${MAIL_FRONTEND_UPSTREAM};
|
||||||
|
proxy_pass http://$mail_upstream;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
location ^~ /brand/ {
|
location ^~ /brand/ {
|
||||||
resolver 127.0.0.11 valid=10s ipv6=off;
|
resolver 127.0.0.11 valid=10s ipv6=off;
|
||||||
set $mail_upstream ${MAIL_FRONTEND_UPSTREAM};
|
set $mail_upstream ${MAIL_FRONTEND_UPSTREAM};
|
||||||
|
|||||||
128
internal/api/admin/drive_org_folders.go
Normal file
128
internal/api/admin/drive_org_folders.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -64,6 +64,8 @@ func (h *Handler) Routes() chi.Router {
|
|||||||
r.With(write).Post("/org/identity-providers/{providerID}/test", h.TestIdentityProvider)
|
r.With(write).Post("/org/identity-providers/{providerID}/test", h.TestIdentityProvider)
|
||||||
r.With(write).Post("/org/identity-providers/{providerID}/sync", h.SyncIdentityProvider)
|
r.With(write).Post("/org/identity-providers/{providerID}/sync", h.SyncIdentityProvider)
|
||||||
|
|
||||||
|
h.registerDriveAdminRoutes(r, read, write)
|
||||||
|
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import (
|
|||||||
|
|
||||||
"github.com/ultisuite/ulti-backend/internal/config"
|
"github.com/ultisuite/ulti-backend/internal/config"
|
||||||
"github.com/ultisuite/ulti-backend/internal/authentik"
|
"github.com/ultisuite/ulti-backend/internal/authentik"
|
||||||
|
"github.com/ultisuite/ulti-backend/internal/orgpolicy"
|
||||||
)
|
)
|
||||||
|
|
||||||
const orgSettingsSingletonID = 1
|
const orgSettingsSingletonID = 1
|
||||||
@ -59,6 +60,7 @@ func defaultOrgPolicy() map[string]any {
|
|||||||
"virus_scan_enabled": false,
|
"virus_scan_enabled": false,
|
||||||
"virustotal_api_key": "",
|
"virustotal_api_key": "",
|
||||||
"retention_trash_days": 30,
|
"retention_trash_days": 30,
|
||||||
|
"mount_oauth": orgpolicy.DefaultMountOAuthSection(),
|
||||||
},
|
},
|
||||||
"llm": map[string]any{
|
"llm": map[string]any{
|
||||||
"default_provider_id": "",
|
"default_provider_id": "",
|
||||||
@ -121,6 +123,36 @@ func defaultOrgPolicy() map[string]any {
|
|||||||
"chat_sync_enabled": true,
|
"chat_sync_enabled": true,
|
||||||
"chat_nc_path": "/.ultimail/ai/chats",
|
"chat_nc_path": "/.ultimail/ai/chats",
|
||||||
},
|
},
|
||||||
|
"agenda": map[string]any{
|
||||||
|
"default_theme_mode": "system",
|
||||||
|
"enforce_org_theme": false,
|
||||||
|
"default_video_provider": "ultimeet",
|
||||||
|
"enforce_org_video_provider": false,
|
||||||
|
"video_provider_api_keys": map[string]any{},
|
||||||
|
},
|
||||||
|
"meet": map[string]any{
|
||||||
|
"transcription_enabled": false,
|
||||||
|
"transcription_mode": "live",
|
||||||
|
"transcription_engine": "faster_whisper_local",
|
||||||
|
"skynet_url": "http://skynet:8000",
|
||||||
|
"whisper_model": "tiny",
|
||||||
|
"external_api_url": "",
|
||||||
|
"external_api_provider": "openai_compatible",
|
||||||
|
"external_api_key": "",
|
||||||
|
"auto_start_transcription": false,
|
||||||
|
"post_actions": map[string]any{
|
||||||
|
"email_enabled": false,
|
||||||
|
"email_recipients": "organizer",
|
||||||
|
"email_custom_addresses": "",
|
||||||
|
"drive_enabled": true,
|
||||||
|
"drive_folder_path": "/UltiMeet/Transcripts",
|
||||||
|
"llm_enabled": false,
|
||||||
|
"llm_provider_id": "",
|
||||||
|
"llm_prompt": "Résume cette réunion en français : points clés, décisions et actions à suivre.",
|
||||||
|
"llm_then_email": true,
|
||||||
|
"llm_then_drive": true,
|
||||||
|
},
|
||||||
|
},
|
||||||
"plugins": []any{
|
"plugins": []any{
|
||||||
map[string]any{"id": "mail-automation", "name": "Automatisations mail", "description": "Règles, webhooks et tri IA sur la réception.", "enabled": true, "version": "1.0.0"},
|
map[string]any{"id": "mail-automation", "name": "Automatisations mail", "description": "Règles, webhooks et tri IA sur la réception.", "enabled": true, "version": "1.0.0"},
|
||||||
map[string]any{"id": "contact-discovery", "name": "Découverte contacts", "description": "Enrichissement IA et signatures détectées.", "enabled": true, "version": "1.0.0"},
|
map[string]any{"id": "contact-discovery", "name": "Découverte contacts", "description": "Enrichissement IA et signatures détectées.", "enabled": true, "version": "1.0.0"},
|
||||||
@ -230,9 +262,112 @@ func mergeOrgSecrets(existing, patch map[string]any) map[string]any {
|
|||||||
if patchIDP, ok := patch["identity_providers"].(map[string]any); ok {
|
if patchIDP, ok := patch["identity_providers"].(map[string]any); ok {
|
||||||
mergeIdentityProviderSecrets(existing, patchIDP, merged)
|
mergeIdentityProviderSecrets(existing, patchIDP, merged)
|
||||||
}
|
}
|
||||||
|
if patchAgenda, ok := patch["agenda"].(map[string]any); ok {
|
||||||
|
mergeAgendaProviderSecrets(existing, patchAgenda, merged)
|
||||||
|
}
|
||||||
|
if patchMeet, ok := patch["meet"].(map[string]any); ok {
|
||||||
|
mergeMeetSecrets(existing, patchMeet, merged)
|
||||||
|
}
|
||||||
|
if patchFilePolicies, ok := patch["file_policies"].(map[string]any); ok {
|
||||||
|
mergeMountOAuthSecrets(existing, patchFilePolicies, merged)
|
||||||
|
}
|
||||||
return merged
|
return merged
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func mergeMountOAuthSecrets(existing, patchFilePolicies, merged map[string]any) {
|
||||||
|
patchMountOAuth, _ := patchFilePolicies["mount_oauth"].(map[string]any)
|
||||||
|
if patchMountOAuth == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
existingFilePolicies, _ := existing["file_policies"].(map[string]any)
|
||||||
|
existingMountOAuth, _ := existingFilePolicies["mount_oauth"].(map[string]any)
|
||||||
|
mergedFilePolicies, _ := merged["file_policies"].(map[string]any)
|
||||||
|
if mergedFilePolicies == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out := map[string]any{}
|
||||||
|
for k, v := range patchMountOAuth {
|
||||||
|
if k == "redirect_uri" {
|
||||||
|
out[k] = v
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
providerPatch, ok := v.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
out[k] = v
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
mergedProvider := map[string]any{}
|
||||||
|
for pk, pv := range providerPatch {
|
||||||
|
mergedProvider[pk] = pv
|
||||||
|
}
|
||||||
|
if secret, _ := providerPatch["client_secret"].(string); strings.TrimSpace(secret) == "" {
|
||||||
|
if existingMountOAuth != nil {
|
||||||
|
if existingProvider, ok := existingMountOAuth[k].(map[string]any); ok {
|
||||||
|
if prev, ok := existingProvider["client_secret"].(string); ok && prev != "" {
|
||||||
|
mergedProvider["client_secret"] = prev
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out[k] = mergedProvider
|
||||||
|
}
|
||||||
|
mergedFilePolicies["mount_oauth"] = out
|
||||||
|
merged["file_policies"] = mergedFilePolicies
|
||||||
|
}
|
||||||
|
|
||||||
|
func mergeMeetSecrets(existing, patchMeet, merged map[string]any) {
|
||||||
|
if strings.TrimSpace(stringValueMap(patchMeet, "external_api_key")) != "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
existingMeet, _ := existing["meet"].(map[string]any)
|
||||||
|
prev := stringValueMap(existingMeet, "external_api_key")
|
||||||
|
if prev == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mergedMeet, _ := merged["meet"].(map[string]any)
|
||||||
|
if mergedMeet == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mergedMeet["external_api_key"] = prev
|
||||||
|
merged["meet"] = mergedMeet
|
||||||
|
}
|
||||||
|
|
||||||
|
func stringValueMap(m map[string]any, key string) string {
|
||||||
|
if m == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
s, _ := m[key].(string)
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func mergeAgendaProviderSecrets(existing, patchAgenda, merged map[string]any) {
|
||||||
|
patchKeys, _ := patchAgenda["video_provider_api_keys"].(map[string]any)
|
||||||
|
if len(patchKeys) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
existingAgenda, _ := existing["agenda"].(map[string]any)
|
||||||
|
existingKeys, _ := existingAgenda["video_provider_api_keys"].(map[string]any)
|
||||||
|
mergedAgenda, _ := merged["agenda"].(map[string]any)
|
||||||
|
if mergedAgenda == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
outKeys := map[string]any{}
|
||||||
|
for k, v := range patchKeys {
|
||||||
|
s, _ := v.(string)
|
||||||
|
if strings.TrimSpace(s) == "" {
|
||||||
|
if existingKeys != nil {
|
||||||
|
if prev, ok := existingKeys[k].(string); ok && prev != "" {
|
||||||
|
outKeys[k] = prev
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
outKeys[k] = v
|
||||||
|
}
|
||||||
|
mergedAgenda["video_provider_api_keys"] = outKeys
|
||||||
|
merged["agenda"] = mergedAgenda
|
||||||
|
}
|
||||||
|
|
||||||
func mergeLLMProviderSecrets(existing, patchLLM, merged map[string]any) {
|
func mergeLLMProviderSecrets(existing, patchLLM, merged map[string]any) {
|
||||||
patchProviders, _ := patchLLM["providers"].([]any)
|
patchProviders, _ := patchLLM["providers"].([]any)
|
||||||
if len(patchProviders) == 0 {
|
if len(patchProviders) == 0 {
|
||||||
@ -384,6 +519,34 @@ func maskOrgPolicy(policy map[string]any) map[string]any {
|
|||||||
maskStringField(cloned, "search", "meilisearch_api_key")
|
maskStringField(cloned, "search", "meilisearch_api_key")
|
||||||
maskStringField(cloned, "search", "typesense_api_key")
|
maskStringField(cloned, "search", "typesense_api_key")
|
||||||
maskStringField(cloned, "file_policies", "virustotal_api_key")
|
maskStringField(cloned, "file_policies", "virustotal_api_key")
|
||||||
|
if filePolicies, ok := cloned["file_policies"].(map[string]any); ok {
|
||||||
|
if mountOAuth, ok := filePolicies["mount_oauth"].(map[string]any); ok {
|
||||||
|
for _, provider := range []string{"google", "dropbox", "microsoft"} {
|
||||||
|
if section, ok := mountOAuth[provider].(map[string]any); ok {
|
||||||
|
if secret, _ := section["client_secret"].(string); strings.TrimSpace(secret) != "" {
|
||||||
|
section["client_secret"] = ""
|
||||||
|
}
|
||||||
|
mountOAuth[provider] = section
|
||||||
|
}
|
||||||
|
}
|
||||||
|
filePolicies["mount_oauth"] = mountOAuth
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if agenda, ok := cloned["agenda"].(map[string]any); ok {
|
||||||
|
if keys, ok := agenda["video_provider_api_keys"].(map[string]any); ok {
|
||||||
|
for k, v := range keys {
|
||||||
|
if s, ok := v.(string); ok && strings.TrimSpace(s) != "" {
|
||||||
|
keys[k] = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
agenda["video_provider_api_keys"] = keys
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if meet, ok := cloned["meet"].(map[string]any); ok {
|
||||||
|
if v, _ := meet["external_api_key"].(string); strings.TrimSpace(v) != "" {
|
||||||
|
meet["external_api_key"] = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
if llm, ok := cloned["llm"].(map[string]any); ok {
|
if llm, ok := cloned["llm"].(map[string]any); ok {
|
||||||
if providers, ok := llm["providers"].([]any); ok {
|
if providers, ok := llm["providers"].([]any); ok {
|
||||||
for i, p := range providers {
|
for i, p := range providers {
|
||||||
@ -477,6 +640,28 @@ func secretConfigured(policy map[string]any, section, key string) bool {
|
|||||||
return strings.TrimSpace(v) != ""
|
return strings.TrimSpace(v) != ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func mountOAuthProviderSecretConfigured(policy map[string]any, provider string) bool {
|
||||||
|
fp, ok := policy["file_policies"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
mo, ok := fp["mount_oauth"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
section, ok := mo[provider].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
enabled, _ := section["enabled"].(bool)
|
||||||
|
if !enabled {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
clientID, _ := section["client_id"].(string)
|
||||||
|
clientSecret, _ := section["client_secret"].(string)
|
||||||
|
return strings.TrimSpace(clientID) != "" && strings.TrimSpace(clientSecret) != ""
|
||||||
|
}
|
||||||
|
|
||||||
func buildOrgSecretsStatus(policy map[string]any, cfg *config.Config) map[string]any {
|
func buildOrgSecretsStatus(policy map[string]any, cfg *config.Config) map[string]any {
|
||||||
secrets := map[string]any{
|
secrets := map[string]any{
|
||||||
"nextcloud_admin_password": map[string]any{
|
"nextcloud_admin_password": map[string]any{
|
||||||
@ -497,6 +682,15 @@ func buildOrgSecretsStatus(policy map[string]any, cfg *config.Config) map[string
|
|||||||
"virustotal_api_key": map[string]any{
|
"virustotal_api_key": map[string]any{
|
||||||
"configured": secretConfigured(policy, "file_policies", "virustotal_api_key") || strings.TrimSpace(cfg.VirusTotalAPIKey) != "",
|
"configured": secretConfigured(policy, "file_policies", "virustotal_api_key") || strings.TrimSpace(cfg.VirusTotalAPIKey) != "",
|
||||||
},
|
},
|
||||||
|
"mount_oauth_google": map[string]any{
|
||||||
|
"configured": mountOAuthProviderSecretConfigured(policy, "google"),
|
||||||
|
},
|
||||||
|
"mount_oauth_dropbox": map[string]any{
|
||||||
|
"configured": mountOAuthProviderSecretConfigured(policy, "dropbox"),
|
||||||
|
},
|
||||||
|
"mount_oauth_microsoft": map[string]any{
|
||||||
|
"configured": mountOAuthProviderSecretConfigured(policy, "microsoft"),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
if idpSecrets := buildIdentityProviderSecretsStatus(policy); len(idpSecrets) > 0 {
|
if idpSecrets := buildIdentityProviderSecretsStatus(policy); len(idpSecrets) > 0 {
|
||||||
secrets["identity_providers"] = idpSecrets
|
secrets["identity_providers"] = idpSecrets
|
||||||
|
|||||||
@ -16,6 +16,7 @@ import (
|
|||||||
"github.com/ultisuite/ulti-backend/internal/auth"
|
"github.com/ultisuite/ulti-backend/internal/auth"
|
||||||
meetpkg "github.com/ultisuite/ulti-backend/internal/meet"
|
meetpkg "github.com/ultisuite/ulti-backend/internal/meet"
|
||||||
"github.com/ultisuite/ulti-backend/internal/nextcloud"
|
"github.com/ultisuite/ulti-backend/internal/nextcloud"
|
||||||
|
"github.com/ultisuite/ulti-backend/internal/orgpolicy"
|
||||||
"github.com/ultisuite/ulti-backend/internal/permission"
|
"github.com/ultisuite/ulti-backend/internal/permission"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -24,9 +25,9 @@ type Handler struct {
|
|||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewHandler(nc *nextcloud.Client, meetCfg *meetpkg.Config) *Handler {
|
func NewHandler(nc *nextcloud.Client, meetCfg *meetpkg.Config, policy *orgpolicy.Loader) *Handler {
|
||||||
return &Handler{
|
return &Handler{
|
||||||
svc: NewService(nc, meetCfg),
|
svc: NewService(nc, meetCfg, policy),
|
||||||
logger: slog.Default().With("component", "calendar-api"),
|
logger: slog.Default().With("component", "calendar-api"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -78,6 +79,15 @@ func (h *Handler) retryOnDAVMissing(
|
|||||||
return op(refreshed)
|
return op(refreshed)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Handler) writeCalendarServiceError(w http.ResponseWriter, r *http.Request, op string, err error) {
|
||||||
|
if errors.Is(err, nextcloud.ErrDAVCredentialsMissing) {
|
||||||
|
apiresponse.WriteError(w, r, http.StatusServiceUnavailable, "calendar_unavailable", "calendar backend credentials need refresh; retry shortly", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.logger.Error(op, "error", err)
|
||||||
|
apivalidate.WriteInternal(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) ListCalendars(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) ListCalendars(w http.ResponseWriter, r *http.Request) {
|
||||||
claims := middleware.ClaimsFromContext(r.Context())
|
claims := middleware.ClaimsFromContext(r.Context())
|
||||||
ncUser, ok := h.nextcloudUser(w, r, claims)
|
ncUser, ok := h.nextcloudUser(w, r, claims)
|
||||||
@ -91,8 +101,7 @@ func (h *Handler) ListCalendars(w http.ResponseWriter, r *http.Request) {
|
|||||||
return listErr
|
return listErr
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.logger.Error("list calendars", "error", err)
|
h.writeCalendarServiceError(w, r, "list calendars", err)
|
||||||
apivalidate.WriteInternal(w, r)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
apiresponse.WriteJSON(w, http.StatusOK, map[string]any{"calendars": cals})
|
apiresponse.WriteJSON(w, http.StatusOK, map[string]any{"calendars": cals})
|
||||||
@ -117,8 +126,7 @@ func (h *Handler) CreateCalendar(w http.ResponseWriter, r *http.Request) {
|
|||||||
return h.svc.CreateCalendar(r.Context(), userID, normalized.ID, normalized.DisplayName, normalized.Color)
|
return h.svc.CreateCalendar(r.Context(), userID, normalized.ID, normalized.DisplayName, normalized.Color)
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.logger.Error("create calendar", "error", err)
|
h.writeCalendarServiceError(w, r, "create calendar", err)
|
||||||
apivalidate.WriteInternal(w, r)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
apiresponse.WriteJSON(w, http.StatusCreated, map[string]any{"id": normalized.ID})
|
apiresponse.WriteJSON(w, http.StatusCreated, map[string]any{"id": normalized.ID})
|
||||||
@ -143,8 +151,7 @@ func (h *Handler) UpdateCalendar(w http.ResponseWriter, r *http.Request) {
|
|||||||
return h.svc.UpdateCalendar(r.Context(), userID, calID, strings.TrimSpace(req.DisplayName), strings.TrimSpace(req.Color))
|
return h.svc.UpdateCalendar(r.Context(), userID, calID, strings.TrimSpace(req.DisplayName), strings.TrimSpace(req.Color))
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.logger.Error("update calendar", "error", err)
|
h.writeCalendarServiceError(w, r, "update calendar", err)
|
||||||
apivalidate.WriteInternal(w, r)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
@ -161,8 +168,7 @@ func (h *Handler) DeleteCalendar(w http.ResponseWriter, r *http.Request) {
|
|||||||
return h.svc.DeleteCalendar(r.Context(), userID, calID)
|
return h.svc.DeleteCalendar(r.Context(), userID, calID)
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.logger.Error("delete calendar", "error", err)
|
h.writeCalendarServiceError(w, r, "delete calendar", err)
|
||||||
apivalidate.WriteInternal(w, r)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
@ -188,8 +194,7 @@ func (h *Handler) ListEvents(w http.ResponseWriter, r *http.Request) {
|
|||||||
return listErr
|
return listErr
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.logger.Error("list events", "error", err)
|
h.writeCalendarServiceError(w, r, "list events", err)
|
||||||
apivalidate.WriteInternal(w, r)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
apiresponse.WriteJSON(w, http.StatusOK, result)
|
apiresponse.WriteJSON(w, http.StatusOK, result)
|
||||||
@ -219,8 +224,7 @@ func (h *Handler) FreeBusy(w http.ResponseWriter, r *http.Request) {
|
|||||||
return fbErr
|
return fbErr
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.logger.Error("free/busy", "error", err)
|
h.writeCalendarServiceError(w, r, "free/busy", err)
|
||||||
apivalidate.WriteInternal(w, r)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
apiresponse.WriteJSON(w, http.StatusOK, result)
|
apiresponse.WriteJSON(w, http.StatusOK, result)
|
||||||
@ -250,8 +254,7 @@ func (h *Handler) CreateEvent(w http.ResponseWriter, r *http.Request) {
|
|||||||
return h.svc.CreateEvent(r.Context(), userID, calID, &event)
|
return h.svc.CreateEvent(r.Context(), userID, calID, &event)
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.logger.Error("create event", "error", err)
|
h.writeCalendarServiceError(w, r, "create event", err)
|
||||||
apivalidate.WriteInternal(w, r)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.WriteHeader(http.StatusCreated)
|
w.WriteHeader(http.StatusCreated)
|
||||||
@ -299,8 +302,7 @@ func (h *Handler) UpdateEvent(w http.ResponseWriter, r *http.Request) {
|
|||||||
apiresponse.WriteError(w, r, http.StatusPreconditionFailed, "etag_mismatch", "etag does not match current resource version", nil)
|
apiresponse.WriteError(w, r, http.StatusPreconditionFailed, "etag_mismatch", "etag does not match current resource version", nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.logger.Error("update event", "error", err)
|
h.writeCalendarServiceError(w, r, "update event", err)
|
||||||
apivalidate.WriteInternal(w, r)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
apiresponse.WriteJSON(w, http.StatusOK, map[string]any{"etag": etag})
|
apiresponse.WriteJSON(w, http.StatusOK, map[string]any{"etag": etag})
|
||||||
@ -411,8 +413,7 @@ func (h *Handler) DeleteEvent(w http.ResponseWriter, r *http.Request) {
|
|||||||
return h.svc.DeleteEvent(r.Context(), userID, eventPath)
|
return h.svc.DeleteEvent(r.Context(), userID, eventPath)
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.logger.Error("delete event", "error", err)
|
h.writeCalendarServiceError(w, r, "delete event", err)
|
||||||
apivalidate.WriteInternal(w, r)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
|||||||
@ -12,17 +12,19 @@ import (
|
|||||||
"github.com/ultisuite/ulti-backend/internal/auth"
|
"github.com/ultisuite/ulti-backend/internal/auth"
|
||||||
meetpkg "github.com/ultisuite/ulti-backend/internal/meet"
|
meetpkg "github.com/ultisuite/ulti-backend/internal/meet"
|
||||||
"github.com/ultisuite/ulti-backend/internal/nextcloud"
|
"github.com/ultisuite/ulti-backend/internal/nextcloud"
|
||||||
|
"github.com/ultisuite/ulti-backend/internal/orgpolicy"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
nc *nextcloud.Client
|
nc *nextcloud.Client
|
||||||
meetCfg *meetpkg.Config
|
meetCfg *meetpkg.Config
|
||||||
|
policy *orgpolicy.Loader
|
||||||
}
|
}
|
||||||
|
|
||||||
var ErrMeetDisabled = errors.New("meet is disabled")
|
var ErrMeetDisabled = errors.New("meet is disabled")
|
||||||
|
|
||||||
func NewService(nc *nextcloud.Client, meetCfg *meetpkg.Config) *Service {
|
func NewService(nc *nextcloud.Client, meetCfg *meetpkg.Config, policy *orgpolicy.Loader) *Service {
|
||||||
return &Service{nc: nc, meetCfg: meetCfg}
|
return &Service{nc: nc, meetCfg: meetCfg, policy: policy}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) EnsureNextcloudUser(ctx context.Context, claims *auth.Claims) (string, error) {
|
func (s *Service) EnsureNextcloudUser(ctx context.Context, claims *auth.Claims) (string, error) {
|
||||||
@ -99,10 +101,23 @@ func (s *Service) CreateEvent(ctx context.Context, userID, calID string, event *
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) UpdateEvent(ctx context.Context, userID, eventPath, ifMatch string, event *nextcloud.Event) (string, error) {
|
func (s *Service) UpdateEvent(ctx context.Context, userID, eventPath, ifMatch string, event *nextcloud.Event) (string, error) {
|
||||||
if strings.TrimSpace(event.Organizer) == "" {
|
existing, err := s.nc.GetEvent(ctx, userID, eventPath)
|
||||||
event.Organizer = userID
|
if err != nil {
|
||||||
|
return "", err
|
||||||
}
|
}
|
||||||
return s.nc.UpdateEvent(ctx, userID, eventPath, ifMatch, event)
|
merged := nextcloud.MergeEvent(existing, event)
|
||||||
|
if strings.TrimSpace(merged.Organizer) == "" {
|
||||||
|
merged.Organizer = userID
|
||||||
|
}
|
||||||
|
merged.Sequence = existing.Sequence + 1
|
||||||
|
if merged.Sequence < 1 {
|
||||||
|
merged.Sequence = 1
|
||||||
|
}
|
||||||
|
match := strings.TrimSpace(ifMatch)
|
||||||
|
if match == "" || match == "*" {
|
||||||
|
match = strings.TrimSpace(existing.ETag)
|
||||||
|
}
|
||||||
|
return s.nc.UpdateEvent(ctx, userID, eventPath, match, merged)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) DeleteEvent(ctx context.Context, userID, eventPath string) error {
|
func (s *Service) DeleteEvent(ctx context.Context, userID, eventPath string) error {
|
||||||
@ -144,12 +159,18 @@ func (s *Service) CreateMeetLink(ctx context.Context, userID, userName, userEmai
|
|||||||
if roomID == "" {
|
if roomID == "" {
|
||||||
roomID = fmt.Sprintf("event-%d", time.Now().Unix())
|
roomID = fmt.Sprintf("event-%d", time.Now().Unix())
|
||||||
}
|
}
|
||||||
|
tokenOpts := meetpkg.TokenOptions{}
|
||||||
|
if s.policy != nil {
|
||||||
|
if p, err := s.policy.MeetPolicy(ctx); err == nil && p.LiveTranscriptionJWT() {
|
||||||
|
tokenOpts.Transcription = true
|
||||||
|
}
|
||||||
|
}
|
||||||
token, err := s.meetCfg.GenerateToken(roomID, &meetpkg.UserInfo{
|
token, err := s.meetCfg.GenerateToken(roomID, &meetpkg.UserInfo{
|
||||||
ID: userID,
|
ID: userID,
|
||||||
Name: userName,
|
Name: userName,
|
||||||
Email: userEmail,
|
Email: userEmail,
|
||||||
IsMod: true,
|
IsMod: true,
|
||||||
}, 24*time.Hour)
|
}, 24*time.Hour, tokenOpts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", err
|
return "", "", err
|
||||||
}
|
}
|
||||||
|
|||||||
@ -108,6 +108,8 @@ func (h *Handler) Routes() chi.Router {
|
|||||||
r.With(write).Put("/shares/{shareID}", h.UpdateShare)
|
r.With(write).Put("/shares/{shareID}", h.UpdateShare)
|
||||||
r.With(write).Delete("/shares/{shareID}", h.DeleteShare)
|
r.With(write).Delete("/shares/{shareID}", h.DeleteShare)
|
||||||
|
|
||||||
|
h.registerOrgAndMountRoutes(r, read, write)
|
||||||
|
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -348,7 +350,23 @@ func (h *Handler) Move(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := h.svc.Move(r.Context(), ncUser, req.Source, req.Destination); err != nil {
|
srcRef, err := pathRefFromMoveSource(&req)
|
||||||
|
if err != nil {
|
||||||
|
writeDriveError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
destRef, err := pathRefFromMoveDestination(&req)
|
||||||
|
if err != nil {
|
||||||
|
writeDriveError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if usesAlternateRoot(srcRef) || usesAlternateRoot(destRef) {
|
||||||
|
if err := h.svc.MoveAtRoot(r.Context(), ncUser, srcRef, destRef); err != nil {
|
||||||
|
h.logger.Error("move", "error", err)
|
||||||
|
writeDriveError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else if err := h.svc.Move(r.Context(), ncUser, req.Source, req.Destination); err != nil {
|
||||||
h.logger.Error("move", "error", err)
|
h.logger.Error("move", "error", err)
|
||||||
writeDriveError(w, r, err)
|
writeDriveError(w, r, err)
|
||||||
return
|
return
|
||||||
@ -376,7 +394,23 @@ func (h *Handler) Copy(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := h.svc.Copy(r.Context(), ncUser, req.Source, req.Destination); err != nil {
|
srcRef, err := pathRefFromCopySource(&req)
|
||||||
|
if err != nil {
|
||||||
|
writeDriveError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
destRef, err := pathRefFromCopyDestination(&req)
|
||||||
|
if err != nil {
|
||||||
|
writeDriveError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if usesAlternateRoot(srcRef) || usesAlternateRoot(destRef) {
|
||||||
|
if err := h.svc.CopyAtRoot(r.Context(), ncUser, srcRef, destRef); err != nil {
|
||||||
|
h.logger.Error("copy", "error", err)
|
||||||
|
writeDriveError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else if err := h.svc.Copy(r.Context(), ncUser, req.Source, req.Destination); err != nil {
|
||||||
h.logger.Error("copy", "error", err)
|
h.logger.Error("copy", "error", err)
|
||||||
writeDriveError(w, r, err)
|
writeDriveError(w, r, err)
|
||||||
return
|
return
|
||||||
@ -404,7 +438,18 @@ func (h *Handler) Rename(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := h.svc.Rename(r.Context(), ncUser, req.Path, req.NewName); err != nil {
|
ref, err := pathRefFromRename(&req)
|
||||||
|
if err != nil {
|
||||||
|
writeDriveError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if usesAlternateRoot(ref) {
|
||||||
|
if err := h.svc.RenameAtRoot(r.Context(), ncUser, ref, req.NewName); err != nil {
|
||||||
|
h.logger.Error("rename", "error", err)
|
||||||
|
writeDriveError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else if err := h.svc.Rename(r.Context(), ncUser, req.Path, req.NewName); err != nil {
|
||||||
h.logger.Error("rename", "error", err)
|
h.logger.Error("rename", "error", err)
|
||||||
writeDriveError(w, r, err)
|
writeDriveError(w, r, err)
|
||||||
return
|
return
|
||||||
@ -513,6 +558,11 @@ func (h *Handler) CreateShare(w http.ResponseWriter, r *http.Request) {
|
|||||||
apivalidate.WriteValidationError(w, r, verr)
|
apivalidate.WriteValidationError(w, r, verr)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
shareRef, err := pathRefFromShare(&req)
|
||||||
|
if err != nil {
|
||||||
|
writeDriveError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
permissions := req.Permissions
|
permissions := req.Permissions
|
||||||
if permissions == 0 && strings.TrimSpace(req.Role) != "" {
|
if permissions == 0 && strings.TrimSpace(req.Role) != "" {
|
||||||
@ -521,7 +571,7 @@ func (h *Handler) CreateShare(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
share, err := h.svc.CreateShare(r.Context(), ncUser, req.Path, req, permissions)
|
share, err := h.svc.CreateShareAtRoot(r.Context(), ncUser, shareRef, req, permissions)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.logger.Error("create share", "error", err)
|
h.logger.Error("create share", "error", err)
|
||||||
writeDriveError(w, r, err)
|
writeDriveError(w, r, err)
|
||||||
@ -583,7 +633,14 @@ func (h *Handler) ListShares(w http.ResponseWriter, r *http.Request) {
|
|||||||
))
|
))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
shares, err := h.svc.ListShares(r.Context(), ncUser, filePath)
|
rootKind := r.URL.Query().Get("root")
|
||||||
|
rootID := r.URL.Query().Get("root_id")
|
||||||
|
ref, err := pathRefFromParts(rootKind, rootID, filePath)
|
||||||
|
if err != nil {
|
||||||
|
writeDriveError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
shares, err := h.svc.ListSharesAtRoot(r.Context(), ncUser, ref)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeDriveError(w, r, err)
|
writeDriveError(w, r, err)
|
||||||
return
|
return
|
||||||
@ -749,7 +806,12 @@ func (h *Handler) SetFavorite(w http.ResponseWriter, r *http.Request) {
|
|||||||
apivalidate.WriteValidationError(w, r, verr)
|
apivalidate.WriteValidationError(w, r, verr)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := h.svc.SetFavorite(r.Context(), ncUser, req.Path, req.Favorite); err != nil {
|
favRef, err := pathRefFromFavorite(&req)
|
||||||
|
if err != nil {
|
||||||
|
writeDriveError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.svc.SetFavoriteAtRoot(r.Context(), ncUser, favRef, req.Favorite); err != nil {
|
||||||
writeDriveError(w, r, err)
|
writeDriveError(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -798,6 +860,8 @@ func writeDriveError(w http.ResponseWriter, r *http.Request, err error) {
|
|||||||
apiresponse.WriteError(w, r, http.StatusUnprocessableEntity, "drive.malware_detected", "malware detected in file", nil)
|
apiresponse.WriteError(w, r, http.StatusUnprocessableEntity, "drive.malware_detected", "malware detected in file", nil)
|
||||||
case errors.Is(err, ErrInvalid):
|
case errors.Is(err, ErrInvalid):
|
||||||
apiresponse.WriteError(w, r, http.StatusBadRequest, apiresponse.CodeInvalidRequest, "invalid request body", nil)
|
apiresponse.WriteError(w, r, http.StatusBadRequest, apiresponse.CodeInvalidRequest, "invalid request body", nil)
|
||||||
|
case errors.Is(err, ErrOAuthNotConfigured):
|
||||||
|
apiresponse.WriteError(w, r, http.StatusBadRequest, "drive.oauth_not_configured", "cloud storage OAuth is not configured for this organization", nil)
|
||||||
default:
|
default:
|
||||||
apivalidate.WriteInternal(w, r)
|
apivalidate.WriteInternal(w, r)
|
||||||
}
|
}
|
||||||
|
|||||||
298
internal/api/drive/mounts_service.go
Normal file
298
internal/api/drive/mounts_service.go
Normal 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)
|
||||||
|
}
|
||||||
256
internal/api/drive/org_folders_service.go
Normal file
256
internal/api/drive/org_folders_service.go
Normal 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
|
||||||
|
}
|
||||||
382
internal/api/drive/org_mount_handlers.go
Normal file
382
internal/api/drive/org_mount_handlers.go
Normal 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
|
||||||
|
}
|
||||||
45
internal/api/drive/path_ref.go
Normal file
45
internal/api/drive/path_ref.go
Normal 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 != ""
|
||||||
|
}
|
||||||
@ -18,6 +18,7 @@ import (
|
|||||||
"github.com/ultisuite/ulti-backend/internal/api/query"
|
"github.com/ultisuite/ulti-backend/internal/api/query"
|
||||||
"github.com/ultisuite/ulti-backend/internal/auth"
|
"github.com/ultisuite/ulti-backend/internal/auth"
|
||||||
"github.com/ultisuite/ulti-backend/internal/automation"
|
"github.com/ultisuite/ulti-backend/internal/automation"
|
||||||
|
"github.com/ultisuite/ulti-backend/internal/drivestore"
|
||||||
"github.com/ultisuite/ulti-backend/internal/filescan"
|
"github.com/ultisuite/ulti-backend/internal/filescan"
|
||||||
"github.com/ultisuite/ulti-backend/internal/mail/rules"
|
"github.com/ultisuite/ulti-backend/internal/mail/rules"
|
||||||
"github.com/ultisuite/ulti-backend/internal/nextcloud"
|
"github.com/ultisuite/ulti-backend/internal/nextcloud"
|
||||||
@ -38,6 +39,7 @@ type Service struct {
|
|||||||
nc *nextcloud.Client
|
nc *nextcloud.Client
|
||||||
hub *realtime.Hub
|
hub *realtime.Hub
|
||||||
db *pgxpool.Pool
|
db *pgxpool.Pool
|
||||||
|
store *drivestore.Store
|
||||||
automation driveAutomation
|
automation driveAutomation
|
||||||
scanner *filescan.Scanner
|
scanner *filescan.Scanner
|
||||||
maxUploadBytes int64
|
maxUploadBytes int64
|
||||||
@ -49,13 +51,17 @@ type driveAutomation interface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func NewService(nc *nextcloud.Client, hub *realtime.Hub, db *pgxpool.Pool) *Service {
|
func NewService(nc *nextcloud.Client, hub *realtime.Hub, db *pgxpool.Pool) *Service {
|
||||||
return &Service{
|
s := &Service{
|
||||||
nc: nc,
|
nc: nc,
|
||||||
hub: hub,
|
hub: hub,
|
||||||
db: db,
|
db: db,
|
||||||
maxUploadBytes: envInt64("ULTID_DRIVE_MAX_UPLOAD_BYTES", 0),
|
maxUploadBytes: envInt64("ULTID_DRIVE_MAX_UPLOAD_BYTES", 0),
|
||||||
quotaReserveByte: envInt64("ULTID_DRIVE_QUOTA_RESERVED_BYTES", 0),
|
quotaReserveByte: envInt64("ULTID_DRIVE_QUOTA_RESERVED_BYTES", 0),
|
||||||
}
|
}
|
||||||
|
if db != nil {
|
||||||
|
s.store = drivestore.NewStore(db)
|
||||||
|
}
|
||||||
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) EnsureNextcloudUser(ctx context.Context, claims *auth.Claims) (string, error) {
|
func (s *Service) EnsureNextcloudUser(ctx context.Context, claims *auth.Claims) (string, error) {
|
||||||
|
|||||||
299
internal/api/drive/service_roots.go
Normal file
299
internal/api/drive/service_roots.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
42
internal/api/drive/service_roots_share.go
Normal file
42
internal/api/drive/service_roots_share.go
Normal 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)
|
||||||
|
}
|
||||||
@ -1,9 +1,11 @@
|
|||||||
package drive
|
package drive
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/ultisuite/ulti-backend/internal/api/apivalidate"
|
"github.com/ultisuite/ulti-backend/internal/api/apivalidate"
|
||||||
|
"github.com/ultisuite/ulti-backend/internal/nextcloud"
|
||||||
)
|
)
|
||||||
|
|
||||||
const maxJSONRequestBody = 32 << 10
|
const maxJSONRequestBody = 32 << 10
|
||||||
@ -11,16 +13,43 @@ const maxJSONRequestBody = 32 << 10
|
|||||||
type moveRequest struct {
|
type moveRequest struct {
|
||||||
Source string `json:"source"`
|
Source string `json:"source"`
|
||||||
Destination string `json:"destination"`
|
Destination string `json:"destination"`
|
||||||
|
SourceRoot string `json:"source_root,omitempty"`
|
||||||
|
SourceRootID string `json:"source_root_id,omitempty"`
|
||||||
|
DestinationRoot string `json:"destination_root,omitempty"`
|
||||||
|
DestinationRootID string `json:"destination_root_id,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type copyRequest struct {
|
type copyRequest struct {
|
||||||
Source string `json:"source"`
|
Source string `json:"source"`
|
||||||
Destination string `json:"destination"`
|
Destination string `json:"destination"`
|
||||||
|
SourceRoot string `json:"source_root,omitempty"`
|
||||||
|
SourceRootID string `json:"source_root_id,omitempty"`
|
||||||
|
DestinationRoot string `json:"destination_root,omitempty"`
|
||||||
|
DestinationRootID string `json:"destination_root_id,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type renameRequest struct {
|
type renameRequest struct {
|
||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
NewName string `json:"new_name"`
|
NewName string `json:"new_name"`
|
||||||
|
Root string `json:"root,omitempty"`
|
||||||
|
RootID string `json:"root_id,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type favoriteRequest struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
Favorite bool `json:"favorite"`
|
||||||
|
Root string `json:"root,omitempty"`
|
||||||
|
RootID string `json:"root_id,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type createMountRequest struct {
|
||||||
|
Scope string `json:"scope"`
|
||||||
|
OrgSlug string `json:"org_slug,omitempty"`
|
||||||
|
DisplayName string `json:"display_name"`
|
||||||
|
BackendType string `json:"backend_type"`
|
||||||
|
WebDAV *nextcloud.WebDAVMountConfig `json:"webdav,omitempty"`
|
||||||
|
OAuthBackend string `json:"oauth_backend,omitempty"`
|
||||||
|
OAuthAuth string `json:"oauth_auth,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateMoveRequest(req *moveRequest) *apivalidate.ValidationError {
|
func validateMoveRequest(req *moveRequest) *apivalidate.ValidationError {
|
||||||
@ -77,6 +106,8 @@ type createShareRequest struct {
|
|||||||
ShareWith string `json:"share_with"`
|
ShareWith string `json:"share_with"`
|
||||||
Note string `json:"note"`
|
Note string `json:"note"`
|
||||||
SendMail *bool `json:"send_mail"`
|
SendMail *bool `json:"send_mail"`
|
||||||
|
Root string `json:"root,omitempty"`
|
||||||
|
RootID string `json:"root_id,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func sharePermissionsForRole(role string) (int, bool) {
|
func sharePermissionsForRole(role string) (int, bool) {
|
||||||
@ -144,11 +175,6 @@ func validateDeleteTrashRequest(req *deleteTrashRequest) *apivalidate.Validation
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type favoriteRequest struct {
|
|
||||||
Path string `json:"path"`
|
|
||||||
Favorite bool `json:"favorite"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateFavoriteRequest(req *favoriteRequest) *apivalidate.ValidationError {
|
func validateFavoriteRequest(req *favoriteRequest) *apivalidate.ValidationError {
|
||||||
if strings.TrimSpace(req.Path) == "" {
|
if strings.TrimSpace(req.Path) == "" {
|
||||||
return apivalidate.NewValidationError(apivalidate.FieldDetail{
|
return apivalidate.NewValidationError(apivalidate.FieldDetail{
|
||||||
@ -194,3 +220,24 @@ func validatePath(path string) *apivalidate.ValidationError {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const mountOAuthCallbackPath = "/drive/mounts/oauth/callback"
|
||||||
|
|
||||||
|
func validateMountOAuthRedirectURI(raw string) error {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if raw == "" {
|
||||||
|
return ErrInvalid
|
||||||
|
}
|
||||||
|
parsed, err := url.Parse(raw)
|
||||||
|
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
||||||
|
return ErrInvalid
|
||||||
|
}
|
||||||
|
if parsed.Scheme != "http" && parsed.Scheme != "https" {
|
||||||
|
return ErrInvalid
|
||||||
|
}
|
||||||
|
path := strings.TrimRight(parsed.Path, "/")
|
||||||
|
if path != mountOAuthCallbackPath {
|
||||||
|
return ErrInvalid
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@ -20,6 +20,7 @@ type createApiTokenRequest struct {
|
|||||||
Permissions []apitokens.PermissionGrant `json:"permissions"`
|
Permissions []apitokens.PermissionGrant `json:"permissions"`
|
||||||
MailScope apitokens.MailScope `json:"mail_scope"`
|
MailScope apitokens.MailScope `json:"mail_scope"`
|
||||||
DriveScope apitokens.DriveScope `json:"drive_scope"`
|
DriveScope apitokens.DriveScope `json:"drive_scope"`
|
||||||
|
AgendaScope apitokens.AgendaScope `json:"agenda_scope"`
|
||||||
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -75,6 +76,7 @@ func (h *Handler) CreateApiToken(w http.ResponseWriter, r *http.Request) {
|
|||||||
req.Permissions,
|
req.Permissions,
|
||||||
normalizeMailScope(req.MailScope),
|
normalizeMailScope(req.MailScope),
|
||||||
normalizeDriveScope(req.DriveScope),
|
normalizeDriveScope(req.DriveScope),
|
||||||
|
normalizeAgendaScope(req.AgendaScope),
|
||||||
req.ExpiresAt,
|
req.ExpiresAt,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -129,6 +131,13 @@ func normalizeDriveScope(scope apitokens.DriveScope) apitokens.DriveScope {
|
|||||||
return scope
|
return scope
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func normalizeAgendaScope(scope apitokens.AgendaScope) apitokens.AgendaScope {
|
||||||
|
if scope.AllCalendars || len(scope.CalendarIDs) == 0 {
|
||||||
|
return apitokens.AgendaScope{AllCalendars: true, CalendarIDs: nil}
|
||||||
|
}
|
||||||
|
return scope
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) db() *pgxpool.Pool {
|
func (h *Handler) db() *pgxpool.Pool {
|
||||||
if s, ok := h.svc.(*Service); ok {
|
if s, ok := h.svc.(*Service); ok {
|
||||||
return s.DB()
|
return s.DB()
|
||||||
|
|||||||
@ -724,7 +724,7 @@ func (s *Service) ListWebhooks(ctx context.Context, externalID string, params qu
|
|||||||
}
|
}
|
||||||
|
|
||||||
rows, err := s.db.Query(ctx, `
|
rows, err := s.db.Query(ctx, `
|
||||||
SELECT id, name, url, method, version, is_active, body_template, event_types, mail_scope, drive_scope, contacts_scope
|
SELECT id, name, url, method, version, is_active, body_template, event_types, mail_scope, drive_scope, contacts_scope, agenda_scope
|
||||||
FROM webhook_templates
|
FROM webhook_templates
|
||||||
WHERE user_id = (SELECT id FROM users WHERE external_id = $1)
|
WHERE user_id = (SELECT id FROM users WHERE external_id = $1)
|
||||||
ORDER BY created_at ASC
|
ORDER BY created_at ASC
|
||||||
@ -740,8 +740,8 @@ func (s *Service) ListWebhooks(ctx context.Context, externalID string, params qu
|
|||||||
var id, name, url, method, bodyTemplate string
|
var id, name, url, method, bodyTemplate string
|
||||||
var version int
|
var version int
|
||||||
var isActive bool
|
var isActive bool
|
||||||
var eventTypes, mailScope, driveScope, contactsScope []byte
|
var eventTypes, mailScope, driveScope, contactsScope, agendaScope []byte
|
||||||
if err := rows.Scan(&id, &name, &url, &method, &version, &isActive, &bodyTemplate, &eventTypes, &mailScope, &driveScope, &contactsScope); err != nil {
|
if err := rows.Scan(&id, &name, &url, &method, &version, &isActive, &bodyTemplate, &eventTypes, &mailScope, &driveScope, &contactsScope, &agendaScope); err != nil {
|
||||||
return WebhooksList{}, err
|
return WebhooksList{}, err
|
||||||
}
|
}
|
||||||
webhooks = append(webhooks, map[string]any{
|
webhooks = append(webhooks, map[string]any{
|
||||||
@ -751,6 +751,7 @@ func (s *Service) ListWebhooks(ctx context.Context, externalID string, params qu
|
|||||||
"mail_scope": jsonRawOrEmptyObject(mailScope),
|
"mail_scope": jsonRawOrEmptyObject(mailScope),
|
||||||
"drive_scope": jsonRawOrEmptyObject(driveScope),
|
"drive_scope": jsonRawOrEmptyObject(driveScope),
|
||||||
"contacts_scope": jsonRawOrEmptyObject(contactsScope),
|
"contacts_scope": jsonRawOrEmptyObject(contactsScope),
|
||||||
|
"agenda_scope": jsonRawOrEmptyObject(agendaScope),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if err := rows.Err(); err != nil {
|
if err := rows.Err(); err != nil {
|
||||||
@ -795,20 +796,24 @@ func (s *Service) CreateWebhook(ctx context.Context, externalID string, req *cre
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
agendaScopeJSON, err := marshalWebhookAgendaScope(req.AgendaScope)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
var id string
|
var id string
|
||||||
err = s.db.QueryRow(ctx, `
|
err = s.db.QueryRow(ctx, `
|
||||||
INSERT INTO webhook_templates (
|
INSERT INTO webhook_templates (
|
||||||
user_id, name, url, method, headers, body_template, version, signing_secret, max_retries,
|
user_id, name, url, method, headers, body_template, version, signing_secret, max_retries,
|
||||||
event_types, mail_scope, drive_scope, contacts_scope
|
event_types, mail_scope, drive_scope, contacts_scope, agenda_scope
|
||||||
)
|
)
|
||||||
VALUES (
|
VALUES (
|
||||||
(SELECT id FROM users WHERE external_id = $1), $2, $3, $4, $5, $6, 1, $7, $8,
|
(SELECT id FROM users WHERE external_id = $1), $2, $3, $4, $5, $6, 1, $7, $8,
|
||||||
$9, $10, $11, $12
|
$9, $10, $11, $12, $13
|
||||||
)
|
)
|
||||||
RETURNING id
|
RETURNING id
|
||||||
`, externalID, req.Name, req.URL, method, headersJSON, req.BodyTemplate, req.SigningSecret, maxRetries,
|
`, externalID, req.Name, req.URL, method, headersJSON, req.BodyTemplate, req.SigningSecret, maxRetries,
|
||||||
eventTypesJSON, mailScopeJSON, driveScopeJSON, contactsScopeJSON).Scan(&id)
|
eventTypesJSON, mailScopeJSON, driveScopeJSON, contactsScopeJSON, agendaScopeJSON).Scan(&id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|||||||
@ -35,6 +35,10 @@ func (s *Service) UpdateWebhook(ctx context.Context, externalID, webhookID strin
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
agendaScopeJSON, err := marshalWebhookAgendaScope(req.AgendaScope)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
err = tx.QueryRow(ctx, `
|
err = tx.QueryRow(ctx, `
|
||||||
UPDATE webhook_templates
|
UPDATE webhook_templates
|
||||||
@ -50,13 +54,14 @@ func (s *Service) UpdateWebhook(ctx context.Context, externalID, webhookID strin
|
|||||||
mail_scope = $9,
|
mail_scope = $9,
|
||||||
drive_scope = $10,
|
drive_scope = $10,
|
||||||
contacts_scope = $11,
|
contacts_scope = $11,
|
||||||
|
agenda_scope = $12,
|
||||||
version = version + 1,
|
version = version + 1,
|
||||||
updated_at = NOW()
|
updated_at = NOW()
|
||||||
WHERE id = $12
|
WHERE id = $13
|
||||||
AND user_id = (SELECT id FROM users WHERE external_id = $13)
|
AND user_id = (SELECT id FROM users WHERE external_id = $14)
|
||||||
RETURNING version
|
RETURNING version
|
||||||
`, req.Name, req.URL, method, headersJSON, req.BodyTemplate, req.SigningSecret, maxRetries,
|
`, req.Name, req.URL, method, headersJSON, req.BodyTemplate, req.SigningSecret, maxRetries,
|
||||||
eventTypesJSON, mailScopeJSON, driveScopeJSON, contactsScopeJSON, webhookID, externalID).Scan(&version)
|
eventTypesJSON, mailScopeJSON, driveScopeJSON, contactsScopeJSON, agendaScopeJSON, webhookID, externalID).Scan(&version)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return ErrNotFound
|
return ErrNotFound
|
||||||
|
|||||||
@ -609,6 +609,7 @@ type createWebhookRequest struct {
|
|||||||
MailScope *webhookMailScope `json:"mail_scope"`
|
MailScope *webhookMailScope `json:"mail_scope"`
|
||||||
DriveScope *webhookDriveScope `json:"drive_scope"`
|
DriveScope *webhookDriveScope `json:"drive_scope"`
|
||||||
ContactsScope *webhookContactsScope `json:"contacts_scope"`
|
ContactsScope *webhookContactsScope `json:"contacts_scope"`
|
||||||
|
AgendaScope *webhookAgendaScope `json:"agenda_scope"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type webhookMailScope struct {
|
type webhookMailScope struct {
|
||||||
@ -626,6 +627,11 @@ type webhookContactsScope struct {
|
|||||||
BookIDs []string `json:"book_ids"`
|
BookIDs []string `json:"book_ids"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type webhookAgendaScope struct {
|
||||||
|
AllCalendars bool `json:"all_calendars"`
|
||||||
|
CalendarIDs []string `json:"calendar_ids"`
|
||||||
|
}
|
||||||
|
|
||||||
type updateWebhookRequest struct {
|
type updateWebhookRequest struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
@ -638,6 +644,7 @@ type updateWebhookRequest struct {
|
|||||||
MailScope *webhookMailScope `json:"mail_scope"`
|
MailScope *webhookMailScope `json:"mail_scope"`
|
||||||
DriveScope *webhookDriveScope `json:"drive_scope"`
|
DriveScope *webhookDriveScope `json:"drive_scope"`
|
||||||
ContactsScope *webhookContactsScope `json:"contacts_scope"`
|
ContactsScope *webhookContactsScope `json:"contacts_scope"`
|
||||||
|
AgendaScope *webhookAgendaScope `json:"agenda_scope"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type previewWebhookMessageRequest struct {
|
type previewWebhookMessageRequest struct {
|
||||||
|
|||||||
@ -48,3 +48,15 @@ func marshalWebhookContactsScope(scope *webhookContactsScope) ([]byte, error) {
|
|||||||
}
|
}
|
||||||
return json.Marshal(out)
|
return json.Marshal(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func marshalWebhookAgendaScope(scope *webhookAgendaScope) ([]byte, error) {
|
||||||
|
out := automation.AgendaScope{AllCalendars: true}
|
||||||
|
if scope != nil {
|
||||||
|
out.AllCalendars = scope.AllCalendars
|
||||||
|
out.CalendarIDs = scope.CalendarIDs
|
||||||
|
if out.CalendarIDs == nil {
|
||||||
|
out.CalendarIDs = []string{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return json.Marshal(out)
|
||||||
|
}
|
||||||
|
|||||||
@ -3,35 +3,78 @@ package meet
|
|||||||
import (
|
import (
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
"github.com/ultisuite/ulti-backend/internal/api/apiresponse"
|
"github.com/ultisuite/ulti-backend/internal/api/apiresponse"
|
||||||
"github.com/ultisuite/ulti-backend/internal/api/apivalidate"
|
"github.com/ultisuite/ulti-backend/internal/api/apivalidate"
|
||||||
"github.com/ultisuite/ulti-backend/internal/api/middleware"
|
"github.com/ultisuite/ulti-backend/internal/api/middleware"
|
||||||
"github.com/ultisuite/ulti-backend/internal/auth"
|
"github.com/ultisuite/ulti-backend/internal/auth"
|
||||||
meetpkg "github.com/ultisuite/ulti-backend/internal/meet"
|
meetpkg "github.com/ultisuite/ulti-backend/internal/meet"
|
||||||
|
"github.com/ultisuite/ulti-backend/internal/nextcloud"
|
||||||
|
"github.com/ultisuite/ulti-backend/internal/orgpolicy"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
svc *Service
|
svc *Service
|
||||||
|
enabled bool
|
||||||
|
publicURL string
|
||||||
|
policy *orgpolicy.Loader
|
||||||
|
transcripts *TranscriptProcessor
|
||||||
|
transcriptSecret string
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewHandler(meetCfg *meetpkg.Config) *Handler {
|
func NewHandler(
|
||||||
|
meetCfg *meetpkg.Config,
|
||||||
|
enabled bool,
|
||||||
|
publicURL string,
|
||||||
|
policy *orgpolicy.Loader,
|
||||||
|
db *pgxpool.Pool,
|
||||||
|
nc *nextcloud.Client,
|
||||||
|
transcriptSecret string,
|
||||||
|
) *Handler {
|
||||||
return &Handler{
|
return &Handler{
|
||||||
svc: NewService(meetCfg),
|
svc: NewService(meetCfg, policy),
|
||||||
|
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"),
|
logger: slog.Default().With("component", "meet-api"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) Routes() chi.Router {
|
func (h *Handler) Routes() chi.Router {
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
|
r.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", h.CreateRoom)
|
||||||
r.Post("/rooms/{roomID}/token", h.GetToken)
|
r.Post("/rooms/{roomID}/token", h.GetToken)
|
||||||
|
})
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Handler) GetConfig(w http.ResponseWriter, r *http.Request) {
|
||||||
|
resp := map[string]any{
|
||||||
|
"enabled": h.enabled,
|
||||||
|
"public_url": h.publicURL,
|
||||||
|
"brand_name": "UltiMeet",
|
||||||
|
}
|
||||||
|
if h.policy != nil {
|
||||||
|
if pub, err := h.policy.PublicMeetPolicy(r.Context()); err == nil {
|
||||||
|
resp["transcription_enabled"] = pub.TranscriptionEnabled
|
||||||
|
resp["transcription_mode"] = pub.TranscriptionMode
|
||||||
|
resp["auto_start_transcription"] = pub.AutoStartTranscription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
apiresponse.WriteJSON(w, http.StatusOK, resp)
|
||||||
|
}
|
||||||
|
|
||||||
func meetUser(claims *auth.Claims) *meetpkg.UserInfo {
|
func meetUser(claims *auth.Claims) *meetpkg.UserInfo {
|
||||||
return &meetpkg.UserInfo{
|
return &meetpkg.UserInfo{
|
||||||
ID: claims.Sub,
|
ID: claims.Sub,
|
||||||
@ -41,6 +84,10 @@ func meetUser(claims *auth.Claims) *meetpkg.UserInfo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) CreateRoom(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) CreateRoom(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !h.enabled {
|
||||||
|
apiresponse.WriteError(w, r, http.StatusConflict, "meet_disabled", "meet is disabled", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
claims := middleware.ClaimsFromContext(r.Context())
|
claims := middleware.ClaimsFromContext(r.Context())
|
||||||
|
|
||||||
var req createRoomRequest
|
var req createRoomRequest
|
||||||
@ -56,7 +103,7 @@ func (h *Handler) CreateRoom(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
user := meetUser(claims)
|
user := meetUser(claims)
|
||||||
user.IsMod = true
|
user.IsMod = true
|
||||||
token, err := h.svc.CreateRoom(req.Name, user)
|
token, err := h.svc.CreateRoom(r.Context(), req.Name, user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.logger.Error("create room token", "error", err)
|
h.logger.Error("create room token", "error", err)
|
||||||
apivalidate.WriteInternal(w, r)
|
apivalidate.WriteInternal(w, r)
|
||||||
@ -66,6 +113,10 @@ func (h *Handler) CreateRoom(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) GetToken(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) GetToken(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !h.enabled {
|
||||||
|
apiresponse.WriteError(w, r, http.StatusConflict, "meet_disabled", "meet is disabled", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
claims := middleware.ClaimsFromContext(r.Context())
|
claims := middleware.ClaimsFromContext(r.Context())
|
||||||
roomID := chi.URLParam(r, "roomID")
|
roomID := chi.URLParam(r, "roomID")
|
||||||
if verr := validateRoomID(roomID); verr != nil {
|
if verr := validateRoomID(roomID); verr != nil {
|
||||||
@ -76,7 +127,7 @@ func (h *Handler) GetToken(w http.ResponseWriter, r *http.Request) {
|
|||||||
user := meetUser(claims)
|
user := meetUser(claims)
|
||||||
user.IsMod = false
|
user.IsMod = false
|
||||||
|
|
||||||
token, err := h.svc.GetToken(roomID, user)
|
token, err := h.svc.GetToken(r.Context(), roomID, user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.logger.Error("get room token", "error", err)
|
h.logger.Error("get room token", "error", err)
|
||||||
apivalidate.WriteInternal(w, r)
|
apivalidate.WriteInternal(w, r)
|
||||||
@ -84,3 +135,50 @@ func (h *Handler) GetToken(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
apiresponse.WriteJSON(w, http.StatusOK, token)
|
apiresponse.WriteJSON(w, http.StatusOK, token)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Handler) ReceiveTranscript(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if h.transcriptSecret != "" {
|
||||||
|
authHeader := strings.TrimSpace(r.Header.Get("Authorization"))
|
||||||
|
if authHeader != "Bearer "+h.transcriptSecret {
|
||||||
|
apiresponse.WriteError(w, r, http.StatusUnauthorized, "auth.unauthorized", "unauthorized", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var req transcriptRequest
|
||||||
|
if err := apivalidate.DecodeJSON(w, r, 2<<20, &req); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if verr := validateTranscriptRequest(&req); verr != nil {
|
||||||
|
apivalidate.WriteValidationError(w, r, verr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
claims := middleware.ClaimsFromContext(r.Context())
|
||||||
|
organizerUserID := strings.TrimSpace(req.OrganizerUserID)
|
||||||
|
organizerEmail := strings.TrimSpace(req.OrganizerEmail)
|
||||||
|
if claims != nil {
|
||||||
|
if organizerUserID == "" {
|
||||||
|
organizerUserID = claims.Sub
|
||||||
|
}
|
||||||
|
if organizerEmail == "" {
|
||||||
|
organizerEmail = claims.Email
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
err := h.transcripts.Handle(r.Context(), transcriptJobInput{
|
||||||
|
RoomID: strings.TrimSpace(req.RoomID),
|
||||||
|
OrganizerUserID: organizerUserID,
|
||||||
|
OrganizerEmail: organizerEmail,
|
||||||
|
ParticipantEmails: req.ParticipantEmails,
|
||||||
|
RawTranscript: req.Transcript,
|
||||||
|
Mode: strings.TrimSpace(req.Mode),
|
||||||
|
QueuedAudioURL: strings.TrimSpace(req.QueuedAudioURL),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("process transcript", "error", err)
|
||||||
|
apiresponse.WriteError(w, r, http.StatusBadRequest, "transcript_failed", err.Error(), nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
apiresponse.WriteJSON(w, http.StatusAccepted, map[string]any{"ok": true})
|
||||||
|
}
|
||||||
|
|||||||
@ -1,30 +1,46 @@
|
|||||||
package meet
|
package meet
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
|
||||||
meetpkg "github.com/ultisuite/ulti-backend/internal/meet"
|
meetpkg "github.com/ultisuite/ulti-backend/internal/meet"
|
||||||
|
"github.com/ultisuite/ulti-backend/internal/orgpolicy"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
cfg *meetpkg.Config
|
cfg *meetpkg.Config
|
||||||
|
policy *orgpolicy.Loader
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewService(cfg *meetpkg.Config) *Service {
|
func NewService(cfg *meetpkg.Config, policy *orgpolicy.Loader) *Service {
|
||||||
return &Service{cfg: cfg}
|
return &Service{cfg: cfg, policy: policy}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) CreateRoom(name string, user *meetpkg.UserInfo) (*meetpkg.RoomToken, error) {
|
func (s *Service) tokenOpts(ctx context.Context, user *meetpkg.UserInfo) meetpkg.TokenOptions {
|
||||||
|
opts := meetpkg.TokenOptions{}
|
||||||
|
if s.policy == nil {
|
||||||
|
return opts
|
||||||
|
}
|
||||||
|
p, err := s.policy.MeetPolicy(ctx)
|
||||||
|
if err != nil || !p.LiveTranscriptionJWT() {
|
||||||
|
return opts
|
||||||
|
}
|
||||||
|
opts.Transcription = user.IsMod || p.AutoStartTranscription
|
||||||
|
return opts
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) CreateRoom(ctx context.Context, name string, user *meetpkg.UserInfo) (*meetpkg.RoomToken, error) {
|
||||||
roomID := uuid.New().String()[:8]
|
roomID := uuid.New().String()[:8]
|
||||||
if strings.TrimSpace(name) != "" {
|
if strings.TrimSpace(name) != "" {
|
||||||
roomID = strings.TrimSpace(name)
|
roomID = strings.TrimSpace(name)
|
||||||
}
|
}
|
||||||
return s.cfg.GenerateToken(roomID, user, 24*time.Hour)
|
return s.cfg.GenerateToken(roomID, user, 24*time.Hour, s.tokenOpts(ctx, user))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) GetToken(roomID string, user *meetpkg.UserInfo) (*meetpkg.RoomToken, error) {
|
func (s *Service) GetToken(ctx context.Context, roomID string, user *meetpkg.UserInfo) (*meetpkg.RoomToken, error) {
|
||||||
return s.cfg.GenerateToken(roomID, user, 4*time.Hour)
|
return s.cfg.GenerateToken(roomID, user, 4*time.Hour, s.tokenOpts(ctx, user))
|
||||||
}
|
}
|
||||||
|
|||||||
335
internal/api/meet/transcript_processor.go
Normal file
335
internal/api/meet/transcript_processor.go
Normal 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
|
||||||
|
}
|
||||||
44
internal/api/meet/transcript_validate.go
Normal file
44
internal/api/meet/transcript_validate.go
Normal 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
|
||||||
|
}
|
||||||
@ -1,10 +1,13 @@
|
|||||||
package users
|
package users
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
"github.com/ultisuite/ulti-backend/internal/api/apiresponse"
|
"github.com/ultisuite/ulti-backend/internal/api/apiresponse"
|
||||||
@ -12,16 +15,19 @@ import (
|
|||||||
"github.com/ultisuite/ulti-backend/internal/api/middleware"
|
"github.com/ultisuite/ulti-backend/internal/api/middleware"
|
||||||
"github.com/ultisuite/ulti-backend/internal/permission"
|
"github.com/ultisuite/ulti-backend/internal/permission"
|
||||||
platformusers "github.com/ultisuite/ulti-backend/internal/users"
|
platformusers "github.com/ultisuite/ulti-backend/internal/users"
|
||||||
|
"github.com/ultisuite/ulti-backend/internal/orgpolicy"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
db *pgxpool.Pool
|
db *pgxpool.Pool
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
|
orgPolicy *orgpolicy.Loader
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewHandler(db *pgxpool.Pool) *Handler {
|
func NewHandler(db *pgxpool.Pool) *Handler {
|
||||||
return &Handler{
|
return &Handler{
|
||||||
db: db,
|
db: db,
|
||||||
|
orgPolicy: orgpolicy.NewLoader(db, nil),
|
||||||
logger: slog.Default().With("component", "users-api"),
|
logger: slog.Default().With("component", "users-api"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -29,6 +35,8 @@ func NewHandler(db *pgxpool.Pool) *Handler {
|
|||||||
func (h *Handler) Routes() chi.Router {
|
func (h *Handler) Routes() chi.Router {
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
r.Get("/me", h.Me)
|
r.Get("/me", h.Me)
|
||||||
|
r.Put("/me/avatar", h.PutAvatar)
|
||||||
|
r.Delete("/me/avatar", h.DeleteAvatar)
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -47,7 +55,28 @@ func (h *Handler) Me(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
role := permission.DeriveAccountRole(state.PlatformAdmin, state.Status)
|
role := permission.DeriveAccountRole(state.PlatformAdmin, state.Status)
|
||||||
|
|
||||||
apiresponse.WriteJSON(w, http.StatusOK, map[string]any{
|
orgAgenda, err := h.orgPolicy.PublicAgendaPolicy(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Warn("read org agenda policy", "error", err)
|
||||||
|
orgAgenda = orgpolicy.PublicAgendaPolicy{
|
||||||
|
DefaultThemeMode: "system",
|
||||||
|
DefaultVideoProvider: "ultimeet",
|
||||||
|
ConfiguredVideoProviders: []string{"ultimeet"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
orgDrive, err := h.orgPolicy.PublicDrivePolicy(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Warn("read org drive policy", "error", err)
|
||||||
|
orgDrive = orgpolicy.PublicDrivePolicy{}
|
||||||
|
}
|
||||||
|
|
||||||
|
avatarURL, err := platformusers.GetAvatarURL(r.Context(), h.db, claims.Sub)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Warn("read user avatar", "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := map[string]any{
|
||||||
"sub": claims.Sub,
|
"sub": claims.Sub,
|
||||||
"email": claims.Email,
|
"email": claims.Email,
|
||||||
"name": claims.Name,
|
"name": claims.Name,
|
||||||
@ -55,5 +84,67 @@ func (h *Handler) Me(w http.ResponseWriter, r *http.Request) {
|
|||||||
"platform_admin": state.PlatformAdmin,
|
"platform_admin": state.PlatformAdmin,
|
||||||
"role": role,
|
"role": role,
|
||||||
"groups": claims.Groups,
|
"groups": claims.Groups,
|
||||||
|
"org_agenda": orgAgenda,
|
||||||
|
"org_drive": orgDrive,
|
||||||
|
}
|
||||||
|
if avatarURL != "" {
|
||||||
|
payload["avatar_url"] = avatarURL
|
||||||
|
}
|
||||||
|
|
||||||
|
apiresponse.WriteJSON(w, http.StatusOK, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) PutAvatar(w http.ResponseWriter, r *http.Request) {
|
||||||
|
claims := middleware.ClaimsFromContext(r.Context())
|
||||||
|
if claims == nil {
|
||||||
|
apiresponse.WriteError(w, r, http.StatusUnauthorized, apiresponse.CodeAuthUnauthorized, "unauthorized", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var body struct {
|
||||||
|
AvatarURL string `json:"avatar_url"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
apiresponse.WriteError(w, r, http.StatusBadRequest, apiresponse.CodeInvalidRequest, "invalid json body", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := platformusers.SetAvatarURL(r.Context(), h.db, claims.Sub, body.AvatarURL); err != nil {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, platformusers.ErrAvatarTooLarge):
|
||||||
|
apiresponse.WriteError(w, r, http.StatusBadRequest, apiresponse.CodeInvalidRequest, "avatar too large (max 512 KiB)", nil)
|
||||||
|
case errors.Is(err, platformusers.ErrAvatarInvalid):
|
||||||
|
apiresponse.WriteError(w, r, http.StatusBadRequest, apiresponse.CodeInvalidRequest, "invalid avatar image", nil)
|
||||||
|
case errors.Is(err, pgx.ErrNoRows):
|
||||||
|
apivalidate.WriteNotFound(w, r, "user not found")
|
||||||
|
default:
|
||||||
|
h.logger.Error("set user avatar", "error", err)
|
||||||
|
apivalidate.WriteInternal(w, r)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
apiresponse.WriteJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"avatar_url": body.AvatarURL,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Handler) DeleteAvatar(w http.ResponseWriter, r *http.Request) {
|
||||||
|
claims := middleware.ClaimsFromContext(r.Context())
|
||||||
|
if claims == nil {
|
||||||
|
apiresponse.WriteError(w, r, http.StatusUnauthorized, apiresponse.CodeAuthUnauthorized, "unauthorized", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := platformusers.ClearAvatarURL(r.Context(), h.db, claims.Sub); err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
apivalidate.WriteNotFound(w, r, "user not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.logger.Error("clear user avatar", "error", err)
|
||||||
|
apivalidate.WriteInternal(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
apiresponse.WriteJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||||
|
}
|
||||||
|
|||||||
@ -33,7 +33,7 @@ func CreateChatSession(ctx context.Context, db *pgxpool.Pool, externalID, email
|
|||||||
expiresAt := time.Now().UTC().Add(in.TTL)
|
expiresAt := time.Now().UTC().Add(in.TTL)
|
||||||
perms, mailScope, driveScope := chatSessionGrants(in)
|
perms, mailScope, driveScope := chatSessionGrants(in)
|
||||||
name := fmt.Sprintf("UltiAI session %s", time.Now().UTC().Format("2006-01-02 15:04"))
|
name := fmt.Sprintf("UltiAI session %s", time.Now().UTC().Format("2006-01-02 15:04"))
|
||||||
return Create(ctx, db, externalID, name, perms, mailScope, driveScope, &expiresAt)
|
return Create(ctx, db, externalID, name, perms, mailScope, driveScope, AgendaScope{AllCalendars: true}, &expiresAt)
|
||||||
}
|
}
|
||||||
|
|
||||||
func chatSessionGrants(in ChatSessionInput) ([]PermissionGrant, MailScope, DriveScope) {
|
func chatSessionGrants(in ChatSessionInput) ([]PermissionGrant, MailScope, DriveScope) {
|
||||||
|
|||||||
@ -82,6 +82,9 @@ func RequirementForRequest(method, fullPath, typesQuery string) (Requirement, bo
|
|||||||
case strings.HasPrefix(path, "/api/v1/drive/"):
|
case strings.HasPrefix(path, "/api/v1/drive/"):
|
||||||
return driveRequirement(method, path)
|
return driveRequirement(method, path)
|
||||||
|
|
||||||
|
case strings.HasPrefix(path, "/api/v1/calendar/"):
|
||||||
|
return calendarRequirement(method, path)
|
||||||
|
|
||||||
case strings.HasPrefix(path, "/api/v1/richtext/"):
|
case strings.HasPrefix(path, "/api/v1/richtext/"):
|
||||||
return richtextRequirement(method, path)
|
return richtextRequirement(method, path)
|
||||||
|
|
||||||
@ -202,6 +205,30 @@ func driveRequirement(method, path string) (Requirement, bool) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func calendarRequirement(method, path string) (Requirement, bool) {
|
||||||
|
write := method != http.MethodGet && method != http.MethodHead
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case strings.HasSuffix(path, "/freebusy"):
|
||||||
|
return Requirement{Resource: "agenda.freebusy", Write: false}, true
|
||||||
|
case strings.Contains(path, "/events/response/"):
|
||||||
|
return Requirement{Resource: "agenda.response", Write: true}, true
|
||||||
|
case strings.Contains(path, "/events/meet-link/"):
|
||||||
|
return Requirement{Resource: "agenda.events.write", Write: true}, true
|
||||||
|
case strings.Contains(path, "/events/"):
|
||||||
|
if write {
|
||||||
|
return Requirement{Resource: "agenda.events.write", Write: true}, true
|
||||||
|
}
|
||||||
|
return Requirement{Resource: "agenda.events", Write: false}, true
|
||||||
|
case method == http.MethodDelete:
|
||||||
|
return Requirement{Resource: "agenda.events.delete", Write: true}, true
|
||||||
|
case write:
|
||||||
|
return Requirement{Resource: "agenda.calendars", Write: true}, true
|
||||||
|
default:
|
||||||
|
return Requirement{Resource: "agenda.calendars", Write: false}, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func searchRequirement(typesQuery string) (Requirement, bool) {
|
func searchRequirement(typesQuery string) (Requirement, bool) {
|
||||||
types := parseSearchTypes(typesQuery)
|
types := parseSearchTypes(typesQuery)
|
||||||
if len(types) == 0 {
|
if len(types) == 0 {
|
||||||
|
|||||||
@ -33,6 +33,15 @@ func AllowsDrivePath(auth *AuthContext, rawPath string) bool {
|
|||||||
if target == "" {
|
if target == "" {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
// Scoped paths: org:{id}:/path or mount:{id}:/path
|
||||||
|
if strings.HasPrefix(target, "org:") || strings.HasPrefix(target, "mount:") {
|
||||||
|
for _, allowed := range auth.DriveScope.FolderPaths {
|
||||||
|
if driveScopePrefixMatch(target, allowed) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
for _, allowed := range auth.DriveScope.FolderPaths {
|
for _, allowed := range auth.DriveScope.FolderPaths {
|
||||||
if drivePathWithinScope(target, allowed) {
|
if drivePathWithinScope(target, allowed) {
|
||||||
return true
|
return true
|
||||||
@ -41,6 +50,14 @@ func AllowsDrivePath(auth *AuthContext, rawPath string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func driveScopePrefixMatch(target, allowed string) bool {
|
||||||
|
allowed = strings.TrimSpace(allowed)
|
||||||
|
if allowed == "" || allowed == "/" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return target == allowed || strings.HasPrefix(target, allowed+":") || strings.HasPrefix(target, allowed+"/")
|
||||||
|
}
|
||||||
|
|
||||||
func NormalizeDriveScopePath(rawPath string) string {
|
func NormalizeDriveScopePath(rawPath string) string {
|
||||||
rawPath = strings.TrimSpace(rawPath)
|
rawPath = strings.TrimSpace(rawPath)
|
||||||
if rawPath == "" {
|
if rawPath == "" {
|
||||||
@ -67,3 +84,18 @@ func drivePathWithinScope(target, allowed string) bool {
|
|||||||
}
|
}
|
||||||
return strings.HasPrefix(target, allowed+"/")
|
return strings.HasPrefix(target, allowed+"/")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func AllowsAgendaCalendar(auth *AuthContext, calendarID string) bool {
|
||||||
|
if auth == nil || calendarID == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if auth.AgendaScope.AllCalendars {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, id := range auth.AgendaScope.CalendarIDs {
|
||||||
|
if id == calendarID {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|||||||
@ -44,6 +44,11 @@ type DriveScope struct {
|
|||||||
FolderPaths []string `json:"folder_paths"`
|
FolderPaths []string `json:"folder_paths"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type AgendaScope struct {
|
||||||
|
AllCalendars bool `json:"all_calendars"`
|
||||||
|
CalendarIDs []string `json:"calendar_ids"`
|
||||||
|
}
|
||||||
|
|
||||||
type Token struct {
|
type Token struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
@ -51,6 +56,7 @@ type Token struct {
|
|||||||
Permissions []PermissionGrant `json:"permissions"`
|
Permissions []PermissionGrant `json:"permissions"`
|
||||||
MailScope MailScope `json:"mail_scope"`
|
MailScope MailScope `json:"mail_scope"`
|
||||||
DriveScope DriveScope `json:"drive_scope"`
|
DriveScope DriveScope `json:"drive_scope"`
|
||||||
|
AgendaScope AgendaScope `json:"agenda_scope"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
|
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
|
||||||
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||||
@ -70,6 +76,7 @@ type AuthContext struct {
|
|||||||
Permissions []PermissionGrant
|
Permissions []PermissionGrant
|
||||||
MailScope MailScope
|
MailScope MailScope
|
||||||
DriveScope DriveScope
|
DriveScope DriveScope
|
||||||
|
AgendaScope AgendaScope
|
||||||
}
|
}
|
||||||
|
|
||||||
func HashSecret(secret string) []byte {
|
func HashSecret(secret string) []byte {
|
||||||
@ -90,7 +97,7 @@ func generateSecret() (string, string, error) {
|
|||||||
|
|
||||||
func List(ctx context.Context, db *pgxpool.Pool, externalID string) ([]Token, error) {
|
func List(ctx context.Context, db *pgxpool.Pool, externalID string) ([]Token, error) {
|
||||||
rows, err := db.Query(ctx, `
|
rows, err := db.Query(ctx, `
|
||||||
SELECT t.id, t.name, t.token_prefix, t.permissions, t.mail_scope, t.drive_scope,
|
SELECT t.id, t.name, t.token_prefix, t.permissions, t.mail_scope, t.drive_scope, t.agenda_scope,
|
||||||
t.created_at, t.last_used_at, t.expires_at
|
t.created_at, t.last_used_at, t.expires_at
|
||||||
FROM api_tokens t
|
FROM api_tokens t
|
||||||
JOIN users u ON u.id = t.user_id
|
JOIN users u ON u.id = t.user_id
|
||||||
@ -113,7 +120,7 @@ func List(ctx context.Context, db *pgxpool.Pool, externalID string) ([]Token, er
|
|||||||
return out, rows.Err()
|
return out, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
func Create(ctx context.Context, db *pgxpool.Pool, externalID string, name string, permissions []PermissionGrant, mailScope MailScope, driveScope DriveScope, expiresAt *time.Time) (CreatedToken, error) {
|
func Create(ctx context.Context, db *pgxpool.Pool, externalID string, name string, permissions []PermissionGrant, mailScope MailScope, driveScope DriveScope, agendaScope AgendaScope, expiresAt *time.Time) (CreatedToken, error) {
|
||||||
secret, prefix, err := generateSecret()
|
secret, prefix, err := generateSecret()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return CreatedToken{}, err
|
return CreatedToken{}, err
|
||||||
@ -131,24 +138,29 @@ func Create(ctx context.Context, db *pgxpool.Pool, externalID string, name strin
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return CreatedToken{}, err
|
return CreatedToken{}, err
|
||||||
}
|
}
|
||||||
|
agendaJSON, err := json.Marshal(agendaScope)
|
||||||
|
if err != nil {
|
||||||
|
return CreatedToken{}, err
|
||||||
|
}
|
||||||
|
|
||||||
var item Token
|
var item Token
|
||||||
err = db.QueryRow(ctx, `
|
err = db.QueryRow(ctx, `
|
||||||
INSERT INTO api_tokens (
|
INSERT INTO api_tokens (
|
||||||
user_id, name, token_prefix, secret_hash, permissions, mail_scope, drive_scope, expires_at
|
user_id, name, token_prefix, secret_hash, permissions, mail_scope, drive_scope, agenda_scope, expires_at
|
||||||
)
|
)
|
||||||
VALUES (
|
VALUES (
|
||||||
(SELECT id FROM users WHERE external_id = $1),
|
(SELECT id FROM users WHERE external_id = $1),
|
||||||
$2, $3, $4, $5, $6, $7, $8
|
$2, $3, $4, $5, $6, $7, $8, $9
|
||||||
)
|
)
|
||||||
RETURNING id, name, token_prefix, permissions, mail_scope, drive_scope, created_at, last_used_at, expires_at
|
RETURNING id, name, token_prefix, permissions, mail_scope, drive_scope, agenda_scope, created_at, last_used_at, expires_at
|
||||||
`, externalID, name, prefix, HashSecret(secret), permJSON, mailJSON, driveJSON, expiresAt).Scan(
|
`, externalID, name, prefix, HashSecret(secret), permJSON, mailJSON, driveJSON, agendaJSON, expiresAt).Scan(
|
||||||
&item.ID,
|
&item.ID,
|
||||||
&item.Name,
|
&item.Name,
|
||||||
&item.TokenPrefix,
|
&item.TokenPrefix,
|
||||||
&permJSON,
|
&permJSON,
|
||||||
&mailJSON,
|
&mailJSON,
|
||||||
&driveJSON,
|
&driveJSON,
|
||||||
|
&agendaJSON,
|
||||||
&item.CreatedAt,
|
&item.CreatedAt,
|
||||||
&item.LastUsedAt,
|
&item.LastUsedAt,
|
||||||
&item.ExpiresAt,
|
&item.ExpiresAt,
|
||||||
@ -156,7 +168,7 @@ func Create(ctx context.Context, db *pgxpool.Pool, externalID string, name strin
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return CreatedToken{}, err
|
return CreatedToken{}, err
|
||||||
}
|
}
|
||||||
if err := decodeTokenJSON(permJSON, mailJSON, driveJSON, &item); err != nil {
|
if err := decodeTokenJSON(permJSON, mailJSON, driveJSON, agendaJSON, &item); err != nil {
|
||||||
return CreatedToken{}, err
|
return CreatedToken{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -189,7 +201,7 @@ func Authenticate(ctx context.Context, db *pgxpool.Pool, secret string) (*AuthCo
|
|||||||
hash := HashSecret(secret)
|
hash := HashSecret(secret)
|
||||||
row := db.QueryRow(ctx, `
|
row := db.QueryRow(ctx, `
|
||||||
SELECT t.id, u.id::text, u.external_id, u.email, COALESCE(u.name, ''),
|
SELECT t.id, u.id::text, u.external_id, u.email, COALESCE(u.name, ''),
|
||||||
t.permissions, t.mail_scope, t.drive_scope, t.expires_at, t.revoked_at
|
t.permissions, t.mail_scope, t.drive_scope, t.agenda_scope, t.expires_at, t.revoked_at
|
||||||
FROM api_tokens t
|
FROM api_tokens t
|
||||||
JOIN users u ON u.id = t.user_id
|
JOIN users u ON u.id = t.user_id
|
||||||
WHERE t.secret_hash = $1
|
WHERE t.secret_hash = $1
|
||||||
@ -197,7 +209,7 @@ func Authenticate(ctx context.Context, db *pgxpool.Pool, secret string) (*AuthCo
|
|||||||
`, hash)
|
`, hash)
|
||||||
|
|
||||||
var auth AuthContext
|
var auth AuthContext
|
||||||
var permJSON, mailJSON, driveJSON []byte
|
var permJSON, mailJSON, driveJSON, agendaJSON []byte
|
||||||
var expiresAt *time.Time
|
var expiresAt *time.Time
|
||||||
var revokedAt *time.Time
|
var revokedAt *time.Time
|
||||||
if err := row.Scan(
|
if err := row.Scan(
|
||||||
@ -209,6 +221,7 @@ func Authenticate(ctx context.Context, db *pgxpool.Pool, secret string) (*AuthCo
|
|||||||
&permJSON,
|
&permJSON,
|
||||||
&mailJSON,
|
&mailJSON,
|
||||||
&driveJSON,
|
&driveJSON,
|
||||||
|
&agendaJSON,
|
||||||
&expiresAt,
|
&expiresAt,
|
||||||
&revokedAt,
|
&revokedAt,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
@ -232,6 +245,9 @@ func Authenticate(ctx context.Context, db *pgxpool.Pool, secret string) (*AuthCo
|
|||||||
if err := json.Unmarshal(driveJSON, &auth.DriveScope); err != nil {
|
if err := json.Unmarshal(driveJSON, &auth.DriveScope); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if err := json.Unmarshal(agendaJSON, &auth.AgendaScope); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
_, _ = db.Exec(ctx, `
|
_, _ = db.Exec(ctx, `
|
||||||
UPDATE api_tokens SET last_used_at = now(), updated_at = now() WHERE id = $1
|
UPDATE api_tokens SET last_used_at = now(), updated_at = now() WHERE id = $1
|
||||||
@ -266,7 +282,7 @@ type rowScanner interface {
|
|||||||
|
|
||||||
func scanToken(rows rowScanner) (Token, error) {
|
func scanToken(rows rowScanner) (Token, error) {
|
||||||
var item Token
|
var item Token
|
||||||
var permJSON, mailJSON, driveJSON []byte
|
var permJSON, mailJSON, driveJSON, agendaJSON []byte
|
||||||
if err := rows.Scan(
|
if err := rows.Scan(
|
||||||
&item.ID,
|
&item.ID,
|
||||||
&item.Name,
|
&item.Name,
|
||||||
@ -274,19 +290,20 @@ func scanToken(rows rowScanner) (Token, error) {
|
|||||||
&permJSON,
|
&permJSON,
|
||||||
&mailJSON,
|
&mailJSON,
|
||||||
&driveJSON,
|
&driveJSON,
|
||||||
|
&agendaJSON,
|
||||||
&item.CreatedAt,
|
&item.CreatedAt,
|
||||||
&item.LastUsedAt,
|
&item.LastUsedAt,
|
||||||
&item.ExpiresAt,
|
&item.ExpiresAt,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return Token{}, err
|
return Token{}, err
|
||||||
}
|
}
|
||||||
if err := decodeTokenJSON(permJSON, mailJSON, driveJSON, &item); err != nil {
|
if err := decodeTokenJSON(permJSON, mailJSON, driveJSON, agendaJSON, &item); err != nil {
|
||||||
return Token{}, err
|
return Token{}, err
|
||||||
}
|
}
|
||||||
return item, nil
|
return item, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func decodeTokenJSON(permJSON, mailJSON, driveJSON []byte, item *Token) error {
|
func decodeTokenJSON(permJSON, mailJSON, driveJSON, agendaJSON []byte, item *Token) error {
|
||||||
if err := json.Unmarshal(permJSON, &item.Permissions); err != nil {
|
if err := json.Unmarshal(permJSON, &item.Permissions); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@ -296,5 +313,12 @@ func decodeTokenJSON(permJSON, mailJSON, driveJSON []byte, item *Token) error {
|
|||||||
if err := json.Unmarshal(driveJSON, &item.DriveScope); err != nil {
|
if err := json.Unmarshal(driveJSON, &item.DriveScope); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if len(agendaJSON) > 0 {
|
||||||
|
if err := json.Unmarshal(agendaJSON, &item.AgendaScope); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
item.AgendaScope = AgendaScope{AllCalendars: true, CalendarIDs: []string{}}
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@ -73,7 +73,7 @@ func (d *Dispatcher) OnMailCreated(ctx context.Context, userID, accountID, messa
|
|||||||
msg.ID = messageID
|
msg.ID = messageID
|
||||||
}
|
}
|
||||||
d.runRules(ctx, userID, msg, evt)
|
d.runRules(ctx, userID, msg, evt)
|
||||||
d.dispatchWebhooks(ctx, userID, string(rules.TriggerMessageReceived), evt, msg, accountID, "", "")
|
d.dispatchWebhooks(ctx, userID, string(rules.TriggerMessageReceived), evt, msg, accountID, "", "", "")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *Dispatcher) OnDriveEvent(ctx context.Context, externalUserID string, trigger rules.TriggerType, payload DrivePayload) {
|
func (d *Dispatcher) OnDriveEvent(ctx context.Context, externalUserID string, trigger rules.TriggerType, payload DrivePayload) {
|
||||||
@ -88,7 +88,7 @@ func (d *Dispatcher) OnDriveEvent(ctx context.Context, externalUserID string, tr
|
|||||||
evt := driveEventContext(trigger, payload)
|
evt := driveEventContext(trigger, payload)
|
||||||
msg := &rules.Message{}
|
msg := &rules.Message{}
|
||||||
d.runRules(ctx, userID, msg, evt)
|
d.runRules(ctx, userID, msg, evt)
|
||||||
d.dispatchWebhooks(ctx, userID, string(trigger), evt, msg, "", payload.FilePath, "")
|
d.dispatchWebhooks(ctx, userID, string(trigger), evt, msg, "", payload.FilePath, "", "")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *Dispatcher) OnContactEvent(ctx context.Context, externalUserID string, trigger rules.TriggerType, payload ContactPayload) {
|
func (d *Dispatcher) OnContactEvent(ctx context.Context, externalUserID string, trigger rules.TriggerType, payload ContactPayload) {
|
||||||
@ -103,7 +103,7 @@ func (d *Dispatcher) OnContactEvent(ctx context.Context, externalUserID string,
|
|||||||
evt := contactEventContext(trigger, payload)
|
evt := contactEventContext(trigger, payload)
|
||||||
msg := &rules.Message{}
|
msg := &rules.Message{}
|
||||||
d.runRules(ctx, userID, msg, evt)
|
d.runRules(ctx, userID, msg, evt)
|
||||||
d.dispatchWebhooks(ctx, userID, string(trigger), evt, msg, "", "", payload.BookID)
|
d.dispatchWebhooks(ctx, userID, string(trigger), evt, msg, "", "", payload.BookID, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *Dispatcher) runRules(ctx context.Context, userID string, msg *rules.Message, evt *rules.EventContext) {
|
func (d *Dispatcher) runRules(ctx context.Context, userID string, msg *rules.Message, evt *rules.EventContext) {
|
||||||
@ -121,6 +121,7 @@ type webhookTemplateRow struct {
|
|||||||
mailScope []byte
|
mailScope []byte
|
||||||
driveScope []byte
|
driveScope []byte
|
||||||
contactsScope []byte
|
contactsScope []byte
|
||||||
|
agendaScope []byte
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *Dispatcher) dispatchWebhooks(
|
func (d *Dispatcher) dispatchWebhooks(
|
||||||
@ -132,12 +133,13 @@ func (d *Dispatcher) dispatchWebhooks(
|
|||||||
accountID string,
|
accountID string,
|
||||||
drivePath string,
|
drivePath string,
|
||||||
bookID string,
|
bookID string,
|
||||||
|
calendarID string,
|
||||||
) {
|
) {
|
||||||
if d.hooks == nil || d.db == nil {
|
if d.hooks == nil || d.db == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
rows, err := d.db.Query(ctx, `
|
rows, err := d.db.Query(ctx, `
|
||||||
SELECT id, event_types, mail_scope, drive_scope, contacts_scope
|
SELECT id, event_types, mail_scope, drive_scope, contacts_scope, agenda_scope
|
||||||
FROM webhook_templates
|
FROM webhook_templates
|
||||||
WHERE user_id = $1 AND is_active = true
|
WHERE user_id = $1 AND is_active = true
|
||||||
`, userID)
|
`, userID)
|
||||||
@ -150,14 +152,14 @@ func (d *Dispatcher) dispatchWebhooks(
|
|||||||
msgCtx := rules.WebhookContextFromEvent(evt, msg)
|
msgCtx := rules.WebhookContextFromEvent(evt, msg)
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var row webhookTemplateRow
|
var row webhookTemplateRow
|
||||||
if err := rows.Scan(&row.id, &row.eventTypes, &row.mailScope, &row.driveScope, &row.contactsScope); err != nil {
|
if err := rows.Scan(&row.id, &row.eventTypes, &row.mailScope, &row.driveScope, &row.contactsScope, &row.agendaScope); err != nil {
|
||||||
d.logger.Error("scan webhook template", "error", err)
|
d.logger.Error("scan webhook template", "error", err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if !webhookMatchesEvent(row, eventType) {
|
if !webhookMatchesEvent(row, eventType) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if !webhookMatchesScope(row, accountID, drivePath, bookID) {
|
if !webhookMatchesScope(row, accountID, drivePath, bookID, calendarID) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if err := d.hooks.Execute(ctx, row.id, msgCtx); err != nil {
|
if err := d.hooks.Execute(ctx, row.id, msgCtx); err != nil {
|
||||||
@ -182,13 +184,15 @@ func webhookMatchesEvent(row webhookTemplateRow, eventType string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func webhookMatchesScope(row webhookTemplateRow, accountID, drivePath, bookID string) bool {
|
func webhookMatchesScope(row webhookTemplateRow, accountID, drivePath, bookID, calendarID string) bool {
|
||||||
var mailScope MailScope
|
var mailScope MailScope
|
||||||
var driveScope DriveScope
|
var driveScope DriveScope
|
||||||
var contactsScope ContactsScope
|
var contactsScope ContactsScope
|
||||||
|
var agendaScope AgendaScope
|
||||||
_ = json.Unmarshal(row.mailScope, &mailScope)
|
_ = json.Unmarshal(row.mailScope, &mailScope)
|
||||||
_ = json.Unmarshal(row.driveScope, &driveScope)
|
_ = json.Unmarshal(row.driveScope, &driveScope)
|
||||||
_ = json.Unmarshal(row.contactsScope, &contactsScope)
|
_ = json.Unmarshal(row.contactsScope, &contactsScope)
|
||||||
|
_ = json.Unmarshal(row.agendaScope, &agendaScope)
|
||||||
|
|
||||||
if accountID != "" {
|
if accountID != "" {
|
||||||
return AllowsMailScope(mailScope, accountID)
|
return AllowsMailScope(mailScope, accountID)
|
||||||
@ -199,6 +203,9 @@ func webhookMatchesScope(row webhookTemplateRow, accountID, drivePath, bookID st
|
|||||||
if bookID != "" {
|
if bookID != "" {
|
||||||
return AllowsContactsScope(contactsScope, bookID)
|
return AllowsContactsScope(contactsScope, bookID)
|
||||||
}
|
}
|
||||||
|
if calendarID != "" {
|
||||||
|
return AllowsAgendaScope(agendaScope, calendarID)
|
||||||
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -19,6 +19,11 @@ type ContactsScope struct {
|
|||||||
BookIDs []string `json:"book_ids"`
|
BookIDs []string `json:"book_ids"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type AgendaScope struct {
|
||||||
|
AllCalendars bool `json:"all_calendars"`
|
||||||
|
CalendarIDs []string `json:"calendar_ids"`
|
||||||
|
}
|
||||||
|
|
||||||
func AllowsMailScope(scope MailScope, accountID string) bool {
|
func AllowsMailScope(scope MailScope, accountID string) bool {
|
||||||
if accountID == "" {
|
if accountID == "" {
|
||||||
return true
|
return true
|
||||||
@ -79,3 +84,18 @@ func AllowsContactsScope(scope ContactsScope, bookID string) bool {
|
|||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func AllowsAgendaScope(scope AgendaScope, calendarID string) bool {
|
||||||
|
if calendarID == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if scope.AllCalendars {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, id := range scope.CalendarIDs {
|
||||||
|
if id == calendarID {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|||||||
@ -88,6 +88,7 @@ type Config struct {
|
|||||||
JitsiAppID string
|
JitsiAppID string
|
||||||
JitsiAppSecret string
|
JitsiAppSecret string
|
||||||
JitsiPublicURL string
|
JitsiPublicURL string
|
||||||
|
MeetTranscriptWebhookSecret string
|
||||||
|
|
||||||
// Immich
|
// Immich
|
||||||
ImmichEnabled bool
|
ImmichEnabled bool
|
||||||
@ -210,6 +211,7 @@ func Load() (*Config, error) {
|
|||||||
JitsiAppID: envOrDefault("JITSI_APP_ID", "ulti"),
|
JitsiAppID: envOrDefault("JITSI_APP_ID", "ulti"),
|
||||||
JitsiAppSecret: envOrDefaultSecret("JITSI_APP_SECRET", "changeme-jwt-secret"),
|
JitsiAppSecret: envOrDefaultSecret("JITSI_APP_SECRET", "changeme-jwt-secret"),
|
||||||
JitsiPublicURL: envOrDefault("JITSI_PUBLIC_URL", "https://localhost/meet"),
|
JitsiPublicURL: envOrDefault("JITSI_PUBLIC_URL", "https://localhost/meet"),
|
||||||
|
MeetTranscriptWebhookSecret: envOrDefaultSecret("MEET_TRANSCRIPT_WEBHOOK_SECRET", ""),
|
||||||
|
|
||||||
ImmichEnabled: envBool("IMMICH_ENABLED", true),
|
ImmichEnabled: envBool("IMMICH_ENABLED", true),
|
||||||
ImmichAPIURL: envOrDefault("IMMICH_API_URL", "http://immich-server:2283/api"),
|
ImmichAPIURL: envOrDefault("IMMICH_API_URL", "http://immich-server:2283/api"),
|
||||||
|
|||||||
85
internal/driveroot/root.go
Normal file
85
internal/driveroot/root.go
Normal 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
|
||||||
|
}
|
||||||
323
internal/drivestore/store.go
Normal file
323
internal/drivestore/store.go
Normal 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()
|
||||||
|
}
|
||||||
@ -295,6 +295,38 @@ func matchCondition(cond Condition, msg *Message, evt *EventContext) bool {
|
|||||||
if evt != nil {
|
if evt != nil {
|
||||||
fieldValue = evt.ContactOrg
|
fieldValue = evt.ContactOrg
|
||||||
}
|
}
|
||||||
|
case "calendar_event_title":
|
||||||
|
if evt != nil {
|
||||||
|
fieldValue = evt.CalendarEventTitle
|
||||||
|
}
|
||||||
|
case "calendar_event_location":
|
||||||
|
if evt != nil {
|
||||||
|
fieldValue = evt.CalendarEventLocation
|
||||||
|
}
|
||||||
|
case "calendar_event_organizer":
|
||||||
|
if evt != nil {
|
||||||
|
fieldValue = evt.CalendarEventOrganizer
|
||||||
|
}
|
||||||
|
case "calendar_event_attendee":
|
||||||
|
if evt != nil {
|
||||||
|
fieldValue = evt.CalendarEventAttendee
|
||||||
|
}
|
||||||
|
case "calendar_event_all_day":
|
||||||
|
if evt != nil {
|
||||||
|
if evt.CalendarEventAllDay {
|
||||||
|
fieldValue = "true"
|
||||||
|
} else {
|
||||||
|
fieldValue = "false"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "calendar_event_has_video":
|
||||||
|
if evt != nil {
|
||||||
|
if evt.CalendarEventHasVideo {
|
||||||
|
fieldValue = "true"
|
||||||
|
} else {
|
||||||
|
fieldValue = "false"
|
||||||
|
}
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@ -433,6 +465,9 @@ func (e *Engine) executeAction(ctx context.Context, action Action, msg *Message,
|
|||||||
case "contact_add_label", "contact_remove_label", "contact_delete":
|
case "contact_add_label", "contact_remove_label", "contact_delete":
|
||||||
e.logger.Info("deferred contact action", "type", action.Type, "value", action.Value)
|
e.logger.Info("deferred contact action", "type", action.Type, "value", action.Value)
|
||||||
return nil
|
return nil
|
||||||
|
case "calendar_add_attendee", "calendar_update_title", "calendar_cancel_event", "calendar_notify_attendees":
|
||||||
|
e.logger.Info("deferred calendar action", "type", action.Type, "value", action.Value)
|
||||||
|
return nil
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("unknown action type: %s", action.Type)
|
return fmt.Errorf("unknown action type: %s", action.Type)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -29,6 +29,10 @@ const (
|
|||||||
TriggerContactCreated TriggerType = "contact_created"
|
TriggerContactCreated TriggerType = "contact_created"
|
||||||
TriggerContactUpdated TriggerType = "contact_updated"
|
TriggerContactUpdated TriggerType = "contact_updated"
|
||||||
TriggerContactDeleted TriggerType = "contact_deleted"
|
TriggerContactDeleted TriggerType = "contact_deleted"
|
||||||
|
TriggerCalendarEventCreated TriggerType = "calendar_event_created"
|
||||||
|
TriggerCalendarEventUpdated TriggerType = "calendar_event_updated"
|
||||||
|
TriggerCalendarEventDeleted TriggerType = "calendar_event_deleted"
|
||||||
|
TriggerCalendarEventResponse TriggerType = "calendar_event_response"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Trigger struct {
|
type Trigger struct {
|
||||||
@ -38,6 +42,7 @@ type Trigger struct {
|
|||||||
AccountID string `json:"account_id,omitempty"`
|
AccountID string `json:"account_id,omitempty"`
|
||||||
FolderPath string `json:"folder_path,omitempty"`
|
FolderPath string `json:"folder_path,omitempty"`
|
||||||
ContactLabel string `json:"contact_label,omitempty"`
|
ContactLabel string `json:"contact_label,omitempty"`
|
||||||
|
CalendarID string `json:"calendar_id,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type TriggerGroup struct {
|
type TriggerGroup struct {
|
||||||
@ -130,6 +135,7 @@ type EventContext struct {
|
|||||||
Label string
|
Label string
|
||||||
FolderPath string
|
FolderPath string
|
||||||
ContactLabel string
|
ContactLabel string
|
||||||
|
CalendarID string
|
||||||
// Drive payload (when domain is drive)
|
// Drive payload (when domain is drive)
|
||||||
DriveFileName string
|
DriveFileName string
|
||||||
DriveFilePath string
|
DriveFilePath string
|
||||||
@ -143,6 +149,16 @@ type EventContext struct {
|
|||||||
ContactEmail string
|
ContactEmail string
|
||||||
ContactPhone string
|
ContactPhone string
|
||||||
ContactOrg string
|
ContactOrg string
|
||||||
|
// Calendar payload (when domain is agenda)
|
||||||
|
CalendarEventTitle string
|
||||||
|
CalendarEventLocation string
|
||||||
|
CalendarEventOrganizer string
|
||||||
|
CalendarEventAttendee string
|
||||||
|
CalendarEventStart string
|
||||||
|
CalendarEventEnd string
|
||||||
|
CalendarEventAllDay bool
|
||||||
|
CalendarEventHasVideo bool
|
||||||
|
CalendarEventUID string
|
||||||
}
|
}
|
||||||
|
|
||||||
func ParseWorkflow(raw []byte) (*Workflow, error) {
|
func ParseWorkflow(raw []byte) (*Workflow, error) {
|
||||||
@ -276,6 +292,14 @@ func matchTrigger(t Trigger, msg *Message, evt *EventContext) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
|
case TriggerCalendarEventCreated, TriggerCalendarEventUpdated, TriggerCalendarEventDeleted, TriggerCalendarEventResponse:
|
||||||
|
if evt == nil || evt.Type != t.Type {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if t.CalendarID != "" && evt.CalendarID != "" && t.CalendarID != evt.CalendarID {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
@ -272,6 +272,24 @@ func workflowFieldValue(field string, msg *Message, evt *EventContext, execCtx *
|
|||||||
return evt.ContactOrg
|
return evt.ContactOrg
|
||||||
case "contact_label":
|
case "contact_label":
|
||||||
return evt.ContactLabel
|
return evt.ContactLabel
|
||||||
|
case "calendar_event_title":
|
||||||
|
return evt.CalendarEventTitle
|
||||||
|
case "calendar_event_location":
|
||||||
|
return evt.CalendarEventLocation
|
||||||
|
case "calendar_event_organizer":
|
||||||
|
return evt.CalendarEventOrganizer
|
||||||
|
case "calendar_event_attendee":
|
||||||
|
return evt.CalendarEventAttendee
|
||||||
|
case "calendar_event_all_day":
|
||||||
|
if evt.CalendarEventAllDay {
|
||||||
|
return "true"
|
||||||
|
}
|
||||||
|
return "false"
|
||||||
|
case "calendar_event_has_video":
|
||||||
|
if evt.CalendarEventHasVideo {
|
||||||
|
return "true"
|
||||||
|
}
|
||||||
|
return "false"
|
||||||
default:
|
default:
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|||||||
@ -30,6 +30,11 @@ type UserInfo struct {
|
|||||||
IsMod bool `json:"is_moderator"`
|
IsMod bool `json:"is_moderator"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TokenOptions toggles JWT feature flags for Jitsi.
|
||||||
|
type TokenOptions struct {
|
||||||
|
Transcription bool
|
||||||
|
}
|
||||||
|
|
||||||
func NewConfig(appID, appSecret, domain string) *Config {
|
func NewConfig(appID, appSecret, domain string) *Config {
|
||||||
return &Config{
|
return &Config{
|
||||||
AppID: appID,
|
AppID: appID,
|
||||||
@ -38,7 +43,7 @@ func NewConfig(appID, appSecret, domain string) *Config {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Config) GenerateToken(room string, user *UserInfo, ttl time.Duration) (*RoomToken, error) {
|
func (c *Config) GenerateToken(room string, user *UserInfo, ttl time.Duration, opts TokenOptions) (*RoomToken, error) {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
exp := now.Add(ttl)
|
exp := now.Add(ttl)
|
||||||
|
|
||||||
@ -47,6 +52,24 @@ func (c *Config) GenerateToken(room string, user *UserInfo, ttl time.Duration) (
|
|||||||
"typ": "JWT",
|
"typ": "JWT",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
userCtx := map[string]any{
|
||||||
|
"id": user.ID,
|
||||||
|
"name": user.Name,
|
||||||
|
"email": user.Email,
|
||||||
|
"avatar": user.Avatar,
|
||||||
|
"moderator": user.IsMod,
|
||||||
|
}
|
||||||
|
contextPayload := map[string]any{
|
||||||
|
"user": userCtx,
|
||||||
|
}
|
||||||
|
if opts.Transcription {
|
||||||
|
contextPayload["features"] = map[string]any{
|
||||||
|
"transcription": "true",
|
||||||
|
"recording": "false",
|
||||||
|
"livestreaming": "false",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
payload := map[string]any{
|
payload := map[string]any{
|
||||||
"iss": c.AppID,
|
"iss": c.AppID,
|
||||||
"sub": "meet.jitsi",
|
"sub": "meet.jitsi",
|
||||||
@ -54,15 +77,7 @@ func (c *Config) GenerateToken(room string, user *UserInfo, ttl time.Duration) (
|
|||||||
"iat": now.Unix(),
|
"iat": now.Unix(),
|
||||||
"exp": exp.Unix(),
|
"exp": exp.Unix(),
|
||||||
"room": room,
|
"room": room,
|
||||||
"context": map[string]any{
|
"context": contextPayload,
|
||||||
"user": map[string]any{
|
|
||||||
"id": user.ID,
|
|
||||||
"name": user.Name,
|
|
||||||
"email": user.Email,
|
|
||||||
"avatar": user.Avatar,
|
|
||||||
"moderator": user.IsMod,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
token, err := signJWT(header, payload, c.AppSecret)
|
token, err := signJWT(header, payload, c.AppSecret)
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@ -32,6 +33,7 @@ type Event struct {
|
|||||||
Attendees []EventAttendee `json:"attendees,omitempty"`
|
Attendees []EventAttendee `json:"attendees,omitempty"`
|
||||||
MeetURL string `json:"meet_url,omitempty"`
|
MeetURL string `json:"meet_url,omitempty"`
|
||||||
Color string `json:"color,omitempty"`
|
Color string `json:"color,omitempty"`
|
||||||
|
Sequence int `json:"sequence,omitempty"`
|
||||||
RRule string `json:"rrule,omitempty"`
|
RRule string `json:"rrule,omitempty"`
|
||||||
ExDates []string `json:"exdates,omitempty"`
|
ExDates []string `json:"exdates,omitempty"`
|
||||||
RawICS string `json:"raw_ics,omitempty"`
|
RawICS string `json:"raw_ics,omitempty"`
|
||||||
@ -215,6 +217,7 @@ func (c *Client) CreateEvent(ctx context.Context, userID, calendarPath string, e
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) GetEvent(ctx context.Context, userID, eventPath string) (*Event, error) {
|
func (c *Client) GetEvent(ctx context.Context, userID, eventPath string) (*Event, error) {
|
||||||
|
eventPath = normalizeDAVHref(eventPath)
|
||||||
resp, err := c.DoAsUser(ctx, "GET", eventPath, nil, userID, nil)
|
resp, err := c.DoAsUser(ctx, "GET", eventPath, nil, userID, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@ -234,7 +237,68 @@ func (c *Client) GetEvent(ctx context.Context, userID, eventPath string) (*Event
|
|||||||
return &event, nil
|
return &event, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func uidFromEventPath(eventPath string) string {
|
||||||
|
eventPath = strings.TrimSuffix(strings.TrimSpace(eventPath), "/")
|
||||||
|
if idx := strings.LastIndex(eventPath, "/"); idx >= 0 {
|
||||||
|
eventPath = eventPath[idx+1:]
|
||||||
|
}
|
||||||
|
return strings.TrimSuffix(eventPath, ".ics")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MergeEvent overlays patch onto existing. Patch wins for editable fields; UID and
|
||||||
|
// exdates fall back to existing when absent so CalDAV PUT keeps a valid master.
|
||||||
|
func MergeEvent(existing, patch *Event) *Event {
|
||||||
|
if existing == nil {
|
||||||
|
return patch
|
||||||
|
}
|
||||||
|
if patch == nil {
|
||||||
|
out := *existing
|
||||||
|
return &out
|
||||||
|
}
|
||||||
|
|
||||||
|
merged := *existing
|
||||||
|
merged.Summary = patch.Summary
|
||||||
|
merged.Description = patch.Description
|
||||||
|
merged.Location = patch.Location
|
||||||
|
if strings.TrimSpace(patch.Start) != "" {
|
||||||
|
merged.Start = patch.Start
|
||||||
|
merged.AllDay = patch.AllDay
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(patch.End) != "" {
|
||||||
|
merged.End = patch.End
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(patch.UID) != "" {
|
||||||
|
merged.UID = patch.UID
|
||||||
|
}
|
||||||
|
if len(patch.Attendees) > 0 {
|
||||||
|
merged.Attendees = patch.Attendees
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(patch.Organizer) != "" {
|
||||||
|
merged.Organizer = patch.Organizer
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(patch.MeetURL) != "" {
|
||||||
|
merged.MeetURL = patch.MeetURL
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(patch.Color) != "" {
|
||||||
|
merged.Color = patch.Color
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(patch.RRule) != "" {
|
||||||
|
merged.RRule = patch.RRule
|
||||||
|
}
|
||||||
|
if patch.ExDates != nil {
|
||||||
|
merged.ExDates = patch.ExDates
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(merged.UID) == "" {
|
||||||
|
merged.UID = uidFromEventPath(existing.Path)
|
||||||
|
}
|
||||||
|
return &merged
|
||||||
|
}
|
||||||
|
|
||||||
func (c *Client) UpdateEvent(ctx context.Context, userID, eventPath, ifMatch string, event *Event) (string, error) {
|
func (c *Client) UpdateEvent(ctx context.Context, userID, eventPath, ifMatch string, event *Event) (string, error) {
|
||||||
|
eventPath = normalizeDAVHref(eventPath)
|
||||||
|
if strings.TrimSpace(event.UID) == "" {
|
||||||
|
event.UID = uidFromEventPath(eventPath)
|
||||||
|
}
|
||||||
ics := buildICS(event)
|
ics := buildICS(event)
|
||||||
headers := map[string]string{
|
headers := map[string]string{
|
||||||
"Content-Type": "text/calendar; charset=utf-8",
|
"Content-Type": "text/calendar; charset=utf-8",
|
||||||
@ -258,6 +322,7 @@ func (c *Client) UpdateEvent(ctx context.Context, userID, eventPath, ifMatch str
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) DeleteEvent(ctx context.Context, userID, eventPath string) error {
|
func (c *Client) DeleteEvent(ctx context.Context, userID, eventPath string) error {
|
||||||
|
eventPath = normalizeDAVHref(eventPath)
|
||||||
resp, err := c.DoAsUser(ctx, "DELETE", eventPath, nil, userID, nil)
|
resp, err := c.DoAsUser(ctx, "DELETE", eventPath, nil, userID, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@ -484,9 +549,6 @@ func buildICS(event *Event) string {
|
|||||||
b.WriteString(fmt.Sprintf("URL:%s\r\n", strings.TrimSpace(event.MeetURL)))
|
b.WriteString(fmt.Sprintf("URL:%s\r\n", strings.TrimSpace(event.MeetURL)))
|
||||||
b.WriteString(fmt.Sprintf("X-ULTI-MEET-URL:%s\r\n", strings.TrimSpace(event.MeetURL)))
|
b.WriteString(fmt.Sprintf("X-ULTI-MEET-URL:%s\r\n", strings.TrimSpace(event.MeetURL)))
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(event.Color) != "" {
|
|
||||||
b.WriteString(fmt.Sprintf("COLOR:%s\r\n", strings.TrimSpace(event.Color)))
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(event.RRule) != "" {
|
if strings.TrimSpace(event.RRule) != "" {
|
||||||
b.WriteString(fmt.Sprintf("RRULE:%s\r\n", strings.TrimSpace(event.RRule)))
|
b.WriteString(fmt.Sprintf("RRULE:%s\r\n", strings.TrimSpace(event.RRule)))
|
||||||
}
|
}
|
||||||
@ -503,6 +565,9 @@ func buildICS(event *Event) string {
|
|||||||
}
|
}
|
||||||
writeDateProp(&b, "DTSTART", event.Start, event.AllDay)
|
writeDateProp(&b, "DTSTART", event.Start, event.AllDay)
|
||||||
writeDateProp(&b, "DTEND", event.End, event.AllDay)
|
writeDateProp(&b, "DTEND", event.End, event.AllDay)
|
||||||
|
if event.Sequence > 0 {
|
||||||
|
b.WriteString(fmt.Sprintf("SEQUENCE:%d\r\n", event.Sequence))
|
||||||
|
}
|
||||||
b.WriteString(fmt.Sprintf("DTSTAMP:%s\r\n", time.Now().UTC().Format("20060102T150405Z")))
|
b.WriteString(fmt.Sprintf("DTSTAMP:%s\r\n", time.Now().UTC().Format("20060102T150405Z")))
|
||||||
b.WriteString("END:VEVENT\r\n")
|
b.WriteString("END:VEVENT\r\n")
|
||||||
b.WriteString("END:VCALENDAR\r\n")
|
b.WriteString("END:VCALENDAR\r\n")
|
||||||
@ -515,9 +580,11 @@ func parseCalendarList(body io.Reader, basePath string) ([]Calendar, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
basePath = normalizeDAVHref(basePath)
|
||||||
calendars := make([]Calendar, 0)
|
calendars := make([]Calendar, 0)
|
||||||
for _, r := range ms.Responses {
|
for _, r := range ms.Responses {
|
||||||
if r.Href == basePath {
|
href := normalizeDAVHref(r.Href)
|
||||||
|
if href == basePath {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
name := r.Propstat.Prop.DisplayName
|
name := r.Propstat.Prop.DisplayName
|
||||||
@ -525,10 +592,10 @@ func parseCalendarList(body io.Reader, basePath string) ([]Calendar, error) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
calendars = append(calendars, Calendar{
|
calendars = append(calendars, Calendar{
|
||||||
ID: strings.TrimSuffix(strings.TrimPrefix(r.Href, basePath), "/"),
|
ID: strings.TrimSuffix(strings.TrimPrefix(href, basePath), "/"),
|
||||||
DisplayName: name,
|
DisplayName: name,
|
||||||
Color: r.Propstat.Prop.CalendarColor,
|
Color: r.Propstat.Prop.CalendarColor,
|
||||||
Path: r.Href,
|
Path: href,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return calendars, nil
|
return calendars, nil
|
||||||
@ -545,7 +612,7 @@ func parseEventList(body io.Reader) ([]Event, error) {
|
|||||||
ics := r.Propstat.Prop.CalendarData
|
ics := r.Propstat.Prop.CalendarData
|
||||||
event := parseICS(ics)
|
event := parseICS(ics)
|
||||||
event.RawICS = ics
|
event.RawICS = ics
|
||||||
event.Path = r.Href
|
event.Path = normalizeDAVHref(r.Href)
|
||||||
event.ETag = strings.TrimSpace(r.Propstat.Prop.ETag)
|
event.ETag = strings.TrimSpace(r.Propstat.Prop.ETag)
|
||||||
events = append(events, event)
|
events = append(events, event)
|
||||||
}
|
}
|
||||||
@ -679,6 +746,10 @@ func parseICS(ics string) Event {
|
|||||||
e.Color = value
|
e.Color = value
|
||||||
case "RRULE":
|
case "RRULE":
|
||||||
e.RRule = value
|
e.RRule = value
|
||||||
|
case "SEQUENCE":
|
||||||
|
if seq, err := strconv.Atoi(strings.TrimSpace(value)); err == nil {
|
||||||
|
e.Sequence = seq
|
||||||
|
}
|
||||||
case "EXDATE":
|
case "EXDATE":
|
||||||
for _, ex := range strings.Split(value, ",") {
|
for _, ex := range strings.Split(value, ",") {
|
||||||
normalized, _ := normalizeICSDate(ex, params)
|
normalized, _ := normalizeICSDate(ex, params)
|
||||||
|
|||||||
@ -131,8 +131,8 @@ func TestBuildICSRoundTrip(t *testing.T) {
|
|||||||
if parsed.RRule != event.RRule {
|
if parsed.RRule != event.RRule {
|
||||||
t.Fatalf("RRule = %q", parsed.RRule)
|
t.Fatalf("RRule = %q", parsed.RRule)
|
||||||
}
|
}
|
||||||
if parsed.Color != event.Color {
|
if parsed.Color != "" {
|
||||||
t.Fatalf("Color = %q", parsed.Color)
|
t.Fatalf("Color should not be serialized in ICS, got %q", parsed.Color)
|
||||||
}
|
}
|
||||||
if len(parsed.ExDates) != 1 || parsed.ExDates[0] != "20260618T100000Z" {
|
if len(parsed.ExDates) != 1 || parsed.ExDates[0] != "20260618T100000Z" {
|
||||||
t.Fatalf("ExDates = %v", parsed.ExDates)
|
t.Fatalf("ExDates = %v", parsed.ExDates)
|
||||||
@ -145,6 +145,48 @@ func TestBuildICSRoundTrip(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMergeEventPreservesUID(t *testing.T) {
|
||||||
|
existing := &Event{
|
||||||
|
UID: "abc@ulti",
|
||||||
|
Summary: "Original",
|
||||||
|
Start: "20260611T100000Z",
|
||||||
|
End: "20260611T110000Z",
|
||||||
|
Path: "/remote.php/dav/calendars/user/personal/abc@ulti.ics",
|
||||||
|
MeetURL: "https://meet.example/room",
|
||||||
|
ExDates: []string{"20260618T100000Z"},
|
||||||
|
}
|
||||||
|
patch := &Event{
|
||||||
|
Summary: "Updated title",
|
||||||
|
Start: "20260612T100000Z",
|
||||||
|
End: "20260612T110000Z",
|
||||||
|
}
|
||||||
|
merged := MergeEvent(existing, patch)
|
||||||
|
if merged.UID != "abc@ulti" {
|
||||||
|
t.Fatalf("UID = %q", merged.UID)
|
||||||
|
}
|
||||||
|
if merged.Summary != "Updated title" {
|
||||||
|
t.Fatalf("Summary = %q", merged.Summary)
|
||||||
|
}
|
||||||
|
if merged.MeetURL != "https://meet.example/room" {
|
||||||
|
t.Fatalf("MeetURL should be preserved, got %q", merged.MeetURL)
|
||||||
|
}
|
||||||
|
if len(merged.ExDates) != 1 {
|
||||||
|
t.Fatalf("ExDates = %v", merged.ExDates)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMergeEventUIDFromPath(t *testing.T) {
|
||||||
|
existing := &Event{
|
||||||
|
Summary: "Keep",
|
||||||
|
Path: "/remote.php/dav/calendars/user/personal/fallback@ulti.ics",
|
||||||
|
}
|
||||||
|
patch := &Event{Summary: "New"}
|
||||||
|
merged := MergeEvent(existing, patch)
|
||||||
|
if merged.UID != "fallback@ulti" {
|
||||||
|
t.Fatalf("UID = %q", merged.UID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestBuildICSAllDay(t *testing.T) {
|
func TestBuildICSAllDay(t *testing.T) {
|
||||||
ics := buildICS(&Event{UID: "ad", Summary: "Férié", Start: "20260714", End: "20260715", AllDay: true})
|
ics := buildICS(&Event{UID: "ad", Summary: "Férié", Start: "20260714", End: "20260715", AllDay: true})
|
||||||
if !strings.Contains(ics, "DTSTART;VALUE=DATE:20260714") {
|
if !strings.Contains(ics, "DTSTART;VALUE=DATE:20260714") {
|
||||||
@ -155,3 +197,35 @@ func TestBuildICSAllDay(t *testing.T) {
|
|||||||
t.Fatal("AllDay should round-trip")
|
t.Fatal("AllDay should round-trip")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestParseCalendarListNormalizesCloudPrefix(t *testing.T) {
|
||||||
|
basePath := "/remote.php/dav/calendars/user@example.com/"
|
||||||
|
raw := `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<d:multistatus xmlns:d="DAV:" xmlns:apple="http://apple.com/ns/ical/">
|
||||||
|
<d:response>
|
||||||
|
<d:href>/cloud/remote.php/dav/calendars/user@example.com/</d:href>
|
||||||
|
<d:propstat><d:prop><d:displayname>Root</d:displayname></d:prop></d:propstat>
|
||||||
|
</d:response>
|
||||||
|
<d:response>
|
||||||
|
<d:href>/cloud/remote.php/dav/calendars/user@example.com/personal/</d:href>
|
||||||
|
<d:propstat><d:prop>
|
||||||
|
<d:displayname>Personal</d:displayname>
|
||||||
|
<apple:calendar-color>#1a73e8</apple:calendar-color>
|
||||||
|
</d:prop></d:propstat>
|
||||||
|
</d:response>
|
||||||
|
</d:multistatus>`
|
||||||
|
|
||||||
|
cals, err := parseCalendarList(strings.NewReader(raw), basePath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(cals) != 1 {
|
||||||
|
t.Fatalf("len = %d, want 1", len(cals))
|
||||||
|
}
|
||||||
|
if cals[0].ID != "personal" {
|
||||||
|
t.Fatalf("ID = %q, want personal", cals[0].ID)
|
||||||
|
}
|
||||||
|
if cals[0].Path != "/remote.php/dav/calendars/user@example.com/personal/" {
|
||||||
|
t.Fatalf("Path = %q", cals[0].Path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -58,8 +58,15 @@ func (c *Client) webDAVDestination(davPath string) string {
|
|||||||
return SameServerDestinationHeader(davPath)
|
return SameServerDestinationHeader(davPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func joinBaseURL(baseURL, path string) string {
|
||||||
|
if !strings.HasPrefix(path, "/") {
|
||||||
|
path = "/" + path
|
||||||
|
}
|
||||||
|
return baseURL + path
|
||||||
|
}
|
||||||
|
|
||||||
func (c *Client) doRequest(ctx context.Context, method, path string, body io.Reader, headers map[string]string) (*http.Response, error) {
|
func (c *Client) doRequest(ctx context.Context, method, path string, body io.Reader, headers map[string]string) (*http.Response, error) {
|
||||||
url := c.baseURL + path
|
url := joinBaseURL(c.baseURL, path)
|
||||||
req, err := http.NewRequestWithContext(ctx, method, url, body)
|
req, err := http.NewRequestWithContext(ctx, method, url, body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@ -84,7 +91,7 @@ func (c *Client) doAsUser(ctx context.Context, method, path string, body io.Read
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
url := c.baseURL + path
|
url := joinBaseURL(c.baseURL, path)
|
||||||
req, err := http.NewRequestWithContext(ctx, method, url, body)
|
req, err := http.NewRequestWithContext(ctx, method, url, body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
16
internal/nextcloud/client_test.go
Normal file
16
internal/nextcloud/client_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -15,6 +15,12 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type FileCapabilities struct {
|
||||||
|
Share bool `json:"share"`
|
||||||
|
Trash bool `json:"trash"`
|
||||||
|
Preview bool `json:"preview"`
|
||||||
|
}
|
||||||
|
|
||||||
type FileInfo struct {
|
type FileInfo struct {
|
||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
@ -27,6 +33,9 @@ type FileInfo struct {
|
|||||||
IsFavorite bool `json:"is_favorite"`
|
IsFavorite bool `json:"is_favorite"`
|
||||||
IsShared bool `json:"is_shared"`
|
IsShared bool `json:"is_shared"`
|
||||||
Source string `json:"source,omitempty"`
|
Source string `json:"source,omitempty"`
|
||||||
|
RootKind string `json:"root_kind,omitempty"`
|
||||||
|
RootID string `json:"root_id,omitempty"`
|
||||||
|
Capabilities *FileCapabilities `json:"capabilities,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ShareInfo struct {
|
type ShareInfo struct {
|
||||||
|
|||||||
278
internal/nextcloud/external_storage.go
Normal file
278
internal/nextcloud/external_storage.go
Normal 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))
|
||||||
|
}
|
||||||
150
internal/nextcloud/groupfolder_dav.go
Normal file
150
internal/nextcloud/groupfolder_dav.go
Normal 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>`
|
||||||
252
internal/nextcloud/groupfolders.go
Normal file
252
internal/nextcloud/groupfolders.go
Normal 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
|
||||||
|
}
|
||||||
79
internal/orgpolicy/agenda.go
Normal file
79
internal/orgpolicy/agenda.go
Normal 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
171
internal/orgpolicy/drive.go
Normal 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
169
internal/orgpolicy/meet.go
Normal 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"
|
||||||
|
}
|
||||||
@ -344,12 +344,18 @@ func New(ctx context.Context, cfg *config.Config, opts Options) (*App, error) {
|
|||||||
TypesenseCollection: cfg.TypesenseCollection,
|
TypesenseCollection: cfg.TypesenseCollection,
|
||||||
}).Search)
|
}).Search)
|
||||||
if driveHandler != nil {
|
if driveHandler != nil {
|
||||||
r.Mount("/api/v1/calendar", calendar.NewHandler(ncClient, meetCfg).Routes())
|
r.Mount("/api/v1/calendar", calendar.NewHandler(ncClient, meetCfg, orgPolicyLoader).Routes())
|
||||||
r.Mount("/api/v1/contacts", contactsHandler.Routes())
|
r.Mount("/api/v1/contacts", contactsHandler.Routes())
|
||||||
}
|
}
|
||||||
if meetCfg != nil {
|
r.Mount("/api/v1/meet", meetapi.NewHandler(
|
||||||
r.Mount("/api/v1/meet", meetapi.NewHandler(meetCfg).Routes())
|
meetCfg,
|
||||||
}
|
cfg.JitsiEnabled,
|
||||||
|
cfg.JitsiPublicURL,
|
||||||
|
orgPolicyLoader,
|
||||||
|
pool,
|
||||||
|
ncClient,
|
||||||
|
cfg.MeetTranscriptWebhookSecret,
|
||||||
|
).Routes())
|
||||||
if photosClient != nil {
|
if photosClient != nil {
|
||||||
r.Mount("/api/v1/photos", photosapi.NewHandler(photosClient, ncClient).Routes())
|
r.Mount("/api/v1/photos", photosapi.NewHandler(photosClient, ncClient).Routes())
|
||||||
}
|
}
|
||||||
|
|||||||
134
internal/users/avatar.go
Normal file
134
internal/users/avatar.go
Normal 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
|
||||||
|
}
|
||||||
23
internal/users/avatar_test.go
Normal file
23
internal/users/avatar_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
5
migrations/000036_automation_agenda_scope.down.sql
Normal file
5
migrations/000036_automation_agenda_scope.down.sql
Normal 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;
|
||||||
5
migrations/000036_automation_agenda_scope.up.sql
Normal file
5
migrations/000036_automation_agenda_scope.up.sql
Normal 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;
|
||||||
2
migrations/000037_drive_org_folders_mounts.down.sql
Normal file
2
migrations/000037_drive_org_folders_mounts.down.sql
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
DROP TABLE IF EXISTS drive_mounts;
|
||||||
|
DROP TABLE IF EXISTS drive_org_folders;
|
||||||
38
migrations/000037_drive_org_folders_mounts.up.sql
Normal file
38
migrations/000037_drive_org_folders_mounts.up.sql
Normal 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';
|
||||||
1
migrations/000038_user_avatar.down.sql
Normal file
1
migrations/000038_user_avatar.down.sql
Normal file
@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE users DROP COLUMN IF EXISTS avatar_url;
|
||||||
2
migrations/000038_user_avatar.up.sql
Normal file
2
migrations/000038_user_avatar.up.sql
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE users
|
||||||
|
ADD COLUMN IF NOT EXISTS avatar_url TEXT;
|
||||||
1
migrations/000039_meet_transcripts.down.sql
Normal file
1
migrations/000039_meet_transcripts.down.sql
Normal file
@ -0,0 +1 @@
|
|||||||
|
DROP TABLE IF EXISTS meet_transcript_jobs;
|
||||||
17
migrations/000039_meet_transcripts.up.sql
Normal file
17
migrations/000039_meet_transcripts.up.sql
Normal 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);
|
||||||
Loading…
Reference in New Issue
Block a user