diff --git a/.env.example b/.env.example index 04a9c3a..8135b94 100644 --- a/.env.example +++ b/.env.example @@ -160,6 +160,15 @@ ULTID_PUBLIC_URL=http://{{DOMAIN}} # Base URL for public share links (default: {ULTID_PUBLIC_URL}/drive → /drive/s/{token}) # DRIVE_PUBLIC_URL=http://{{DOMAIN}}/drive +# ----------------------------------------------------------------------------- +# Rich text editor (TipTap + Hocuspocus) +# ----------------------------------------------------------------------------- +RICHTEXT_ENABLED=true +HOCUSPOCUS_PUBLIC_URL=ws://{{DOMAIN}}/collab +HOCUSPOCUS_SECRET=changeme-hocuspocus-secret +RICHTEXT_STORAGE_MODE=sidecar +# RICHTEXT_EXPORT_MIRROR=docx + # ----------------------------------------------------------------------------- # Jitsi Meet (Visioconference) # Mode local : Jitsi deploye dans la stack diff --git a/deploy/nginx/default.conf.template b/deploy/nginx/default.conf.template index fcfd61e..bd4be35 100644 --- a/deploy/nginx/default.conf.template +++ b/deploy/nginx/default.conf.template @@ -65,6 +65,24 @@ server { proxy_set_header X-Forwarded-Proto $scheme; } + # TipTap / Hocuspocus — proxy WS without redirect (301 breaks upgrade) + location /collab { + resolver 127.0.0.11 valid=10s ipv6=off; + set $hocuspocus_upstream host.docker.internal:1234; + + rewrite ^/collab/?(.*)$ /$1 break; + proxy_pass http://$hocuspocus_upstream; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + 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_read_timeout 86400s; + proxy_send_timeout 86400s; + } + # Ultimail OIDC post-login — before Authentik /auth/ (path collision) location ^~ /auth/complete { resolver 127.0.0.11 valid=10s ipv6=off; diff --git a/internal/api/admin/org_settings.go b/internal/api/admin/org_settings.go index f251635..42cc372 100644 --- a/internal/api/admin/org_settings.go +++ b/internal/api/admin/org_settings.go @@ -105,6 +105,12 @@ func defaultOrgPolicy() map[string]any { "jwt_secret": "", "jwt_header": "Authorization", }, + "richtext": map[string]any{ + "enabled": true, + "storage_mode": "sidecar", + "export_mirror_format": "", + "hocuspocus_url": "", + }, "plugins": []any{ map[string]any{"id": "mail-automation", "name": "Automatisations mail", "description": "Règles, webhooks et tri IA sur la réception.", "enabled": true, "version": "1.0.0"}, map[string]any{"id": "contact-discovery", "name": "Découverte contacts", "description": "Enrichissement IA et signatures détectées.", "enabled": true, "version": "1.0.0"}, diff --git a/internal/api/drive/handlers.go b/internal/api/drive/handlers.go index 73559b8..5fd5a43 100644 --- a/internal/api/drive/handlers.go +++ b/internal/api/drive/handlers.go @@ -23,9 +23,14 @@ import ( ) type Handler struct { - svc *Service - publicOffice PublicOfficeAPI - logger *slog.Logger + svc *Service + publicOffice PublicOfficeAPI + publicRichText PublicRichTextAPI + logger *slog.Logger +} + +type PublicRichTextAPI interface { + RegisterPublicShareRoutes(r chi.Router) } func NewHandler(nc *nextcloud.Client, hub *realtime.Hub, db *pgxpool.Pool) *Handler { @@ -43,6 +48,10 @@ func (h *Handler) SetPublicOffice(api PublicOfficeAPI) { h.publicOffice = api } +func (h *Handler) SetPublicRichText(api PublicRichTextAPI) { + h.publicRichText = api +} + func (h *Handler) nextcloudUser(w http.ResponseWriter, r *http.Request, claims *auth.Claims) (string, bool) { userID, err := h.svc.EnsureNextcloudUser(r.Context(), claims) if err != nil { diff --git a/internal/api/drive/public_handlers.go b/internal/api/drive/public_handlers.go index 14ecea8..27b1034 100644 --- a/internal/api/drive/public_handlers.go +++ b/internal/api/drive/public_handlers.go @@ -24,6 +24,9 @@ func (h *Handler) PublicRoutes() chi.Router { if h.publicOffice != nil { h.publicOffice.RegisterPublicShareRoutes(r) } + if h.publicRichText != nil { + h.publicRichText.RegisterPublicShareRoutes(r) + } return r } diff --git a/internal/api/drive/service.go b/internal/api/drive/service.go index 2631cac..9b7fe8a 100644 --- a/internal/api/drive/service.go +++ b/internal/api/drive/service.go @@ -97,6 +97,7 @@ func (s *Service) ListFilterCorpus(ctx context.Context, userID, path string) (Fi if err != nil { return FilesList{}, mapDriveError(err) } + files = nextcloud.FilterHiddenUltidocSidecars(files) total := int64(len(files)) return FilesList{ Files: files, @@ -112,7 +113,7 @@ func (s *Service) ListFiles(ctx context.Context, userID, path string, params que if err != nil { return FilesList{}, mapDriveError(err) } - filtered := filterFiles(files, params.Q) + filtered := visibleDriveFiles(files, params.Q) page, total := paginate.Slice(filtered, params.Offset(), params.Limit()) return FilesList{ Files: page, @@ -125,7 +126,7 @@ func (s *Service) ListTrash(ctx context.Context, userID string, params query.Lis if err != nil { return FilesList{}, mapDriveError(err) } - filtered := filterFiles(files, params.Q) + filtered := visibleDriveFiles(files, params.Q) page, total := paginate.Slice(filtered, params.Offset(), params.Limit()) return FilesList{ Files: page, @@ -138,7 +139,7 @@ func (s *Service) ListRecent(ctx context.Context, userID string, params query.Li if err != nil { return FilesList{}, mapDriveError(err) } - filtered := filterFiles(files, params.Q) + filtered := visibleDriveFiles(files, params.Q) page, total := paginate.Slice(filtered, params.Offset(), params.Limit()) return FilesList{ Files: page, @@ -151,7 +152,7 @@ func (s *Service) ListSharedWithMe(ctx context.Context, userID string, params qu if err != nil { return FilesList{}, mapDriveError(err) } - filtered := filterFiles(files, params.Q) + filtered := visibleDriveFiles(files, params.Q) page, total := paginate.Slice(filtered, params.Offset(), params.Limit()) return FilesList{ Files: page, @@ -175,7 +176,7 @@ func (s *Service) ListStarred(ctx context.Context, userID, basePath string, para if err != nil { return FilesList{}, mapDriveError(err) } - filtered := filterFiles(starred, params.Q) + filtered := visibleDriveFiles(starred, params.Q) page, total := paginate.Slice(filtered, params.Offset(), limit) return FilesList{ Files: page, @@ -402,6 +403,9 @@ func (s *Service) GetPublicShare(ctx context.Context, token, path, password stri if err != nil { return nil, mapPublicShareError(err) } + if view != nil && len(view.Files) > 0 { + view.Files = nextcloud.FilterHiddenUltidocSidecars(view.Files) + } s.recordPublicShareAccess(ctx, token) return view, nil } @@ -521,7 +525,7 @@ func (s *Service) Search(ctx context.Context, userID string, opts SearchOptions, return FilesList{}, mapDriveError(err) } - page, total := paginate.Slice(files, params.Offset(), limit) + page, total := paginate.Slice(nextcloud.FilterHiddenUltidocSidecars(files), params.Offset(), limit) return FilesList{ Files: page, Pagination: params.Meta(&total), @@ -576,6 +580,10 @@ func filterFiles(files []nextcloud.FileInfo, q string) []nextcloud.FileInfo { return out } +func visibleDriveFiles(files []nextcloud.FileInfo, q string) []nextcloud.FileInfo { + return filterFiles(nextcloud.FilterHiddenUltidocSidecars(files), q) +} + type ChunkUpload struct { Index int Total int diff --git a/internal/api/office/public_handlers.go b/internal/api/office/public_handlers.go index e1cdee0..081468b 100644 --- a/internal/api/office/public_handlers.go +++ b/internal/api/office/public_handlers.go @@ -29,7 +29,8 @@ type publicOfficeSessionRequest struct { Path string `json:"path"` Mode string `json:"mode"` Password string `json:"password"` - GuestID string `json:"guest_id"` + GuestID string `json:"guest_id"` + GuestName string `json:"guest_name"` } func publicSharePassword(r *http.Request) string { @@ -72,7 +73,7 @@ func (h *Handler) PublicShareSession(w http.ResponseWriter, r *http.Request) { if mode == "edit" && !nextcloud.PublicShareCanUpdate(perms) { mode = "view" } - cfg, err := h.svc.PublicEditorConfig(r.Context(), token, req.Path, mode, password, req.GuestID) + cfg, err := h.svc.PublicEditorConfig(r.Context(), token, req.Path, mode, password, req.GuestID, req.GuestName) if err != nil { h.logger.Error("public editor config", "error", err) apivalidate.WriteInternal(w, r) diff --git a/internal/api/office/public_share.go b/internal/api/office/public_share.go index b624bab..1a7be35 100644 --- a/internal/api/office/public_share.go +++ b/internal/api/office/public_share.go @@ -15,7 +15,7 @@ type PublicShareAccess struct { Password string } -func (s *Service) PublicEditorConfig(ctx context.Context, token, filePath, mode, password, guestID string) (map[string]any, error) { +func (s *Service) PublicEditorConfig(ctx context.Context, token, filePath, mode, password, guestID, guestName string) (map[string]any, error) { token = strings.TrimSpace(token) filePath = normalizePath(filePath) if token == "" || filePath == "" { @@ -45,11 +45,15 @@ func (s *Service) PublicEditorConfig(ctx context.Context, token, filePath, mode, editorUserID = "public:" + editorUserID } + if guestName == "" { + guestName = "Invité" + } + config, err := buildEditorConfig(buildEditorConfigInput{ filePath: filePath, mode: mode, editorUserID: editorUserID, - userName: "Invité", + userName: guestName, documentKey: s.keys.current(rev.FileID), downloadURL: downloadURL, callbackURL: callbackURL, diff --git a/internal/api/richtext/collab_room.go b/internal/api/richtext/collab_room.go new file mode 100644 index 0000000..3b4985a --- /dev/null +++ b/internal/api/richtext/collab_room.go @@ -0,0 +1,29 @@ +package richtext + +import ( + "context" + "fmt" + "strings" +) + +// resolveCollabRoomID returns a stable Hocuspocus room for a document on the owner's drive. +// Auth sessions, public share links, and guests all join the same room when editing the same sidecar. +func (s *Service) resolveCollabRoomID(ctx context.Context, ownerID, ownerSidecarPath string) (string, error) { + ownerID = strings.TrimSpace(ownerID) + ownerSidecarPath = normalizePath(ownerSidecarPath) + if ownerID == "" || ownerSidecarPath == "" { + return "", fmt.Errorf("collab room: missing owner or path") + } + if rev, err := s.nc.FileRevision(ctx, ownerID, ownerSidecarPath); err == nil && rev.FileID > 0 { + return fmt.Sprintf("rt:%s:%d", ownerID, rev.FileID), nil + } + return fmt.Sprintf("rt:%s:%s", ownerID, hashPath(ownerSidecarPath)), nil +} + +func (s *Service) ownerSidecarPathForPublic(ctx context.Context, token, password, clientCanonical, displayName string) (ownerID, ownerPath string, err error) { + binding, err := s.nc.ResolvePublicShareBinding(ctx, token, password) + if err != nil { + return "", "", err + } + return binding.OwnerID, binding.OwnerPathForClient(clientCanonical, displayName), nil +} diff --git a/internal/api/richtext/document.go b/internal/api/richtext/document.go new file mode 100644 index 0000000..e51c9b4 --- /dev/null +++ b/internal/api/richtext/document.go @@ -0,0 +1,130 @@ +package richtext + +import ( + "encoding/json" + "fmt" + "strings" + "time" +) + +const schemaVersion = 1 + +// UltiDoc is the canonical on-disk format for TipTap documents. +type UltiDoc struct { + SchemaVersion int `json:"schemaVersion"` + Editor string `json:"editor"` + Source *UltiDocSource `json:"source,omitempty"` + Content json.RawMessage `json:"content"` + YjsState string `json:"yjsState,omitempty"` + UpdatedAt string `json:"updatedAt,omitempty"` +} + +type UltiDocSource struct { + Path string `json:"path"` + Mime string `json:"mime,omitempty"` + ImportedAt string `json:"importedAt,omitempty"` +} + +func emptyDocContent() json.RawMessage { + return json.RawMessage(`{"type":"doc","content":[{"type":"paragraph"}]}`) +} + +func NewUltiDoc(content json.RawMessage, source *UltiDocSource) UltiDoc { + if len(content) == 0 { + content = emptyDocContent() + } + return UltiDoc{ + SchemaVersion: schemaVersion, + Editor: "tiptap", + Source: source, + Content: content, + UpdatedAt: time.Now().UTC().Format(time.RFC3339), + } +} + +func ParseUltiDoc(raw []byte) (UltiDoc, error) { + var doc UltiDoc + if err := json.Unmarshal(raw, &doc); err != nil { + return UltiDoc{}, err + } + if doc.SchemaVersion == 0 { + doc.SchemaVersion = schemaVersion + } + if doc.Editor == "" { + doc.Editor = "tiptap" + } + if len(doc.Content) == 0 { + doc.Content = emptyDocContent() + } + return doc, nil +} + +func (d UltiDoc) Marshal() ([]byte, error) { + d.UpdatedAt = time.Now().UTC().Format(time.RFC3339) + if d.SchemaVersion == 0 { + d.SchemaVersion = schemaVersion + } + if d.Editor == "" { + d.Editor = "tiptap" + } + return json.MarshalIndent(d, "", " ") +} + +func textToDocContent(text string) json.RawMessage { + paragraphs := strings.Split(strings.ReplaceAll(text, "\r\n", "\n"), "\n") + nodes := make([]map[string]any, 0, len(paragraphs)) + for _, para := range paragraphs { + node := map[string]any{"type": "paragraph"} + if strings.TrimSpace(para) != "" { + node["content"] = []map[string]any{ + {"type": "text", "text": para}, + } + } + nodes = append(nodes, node) + } + if len(nodes) == 0 { + nodes = append(nodes, map[string]any{"type": "paragraph"}) + } + raw, _ := json.Marshal(map[string]any{"type": "doc", "content": nodes}) + return raw +} + +func htmlToDocContent(html string) json.RawMessage { + // Minimal bridge: strip tags naively for bootstrap; rich import uses client/docx pipeline. + text := strings.TrimSpace(html) + if text == "" { + return emptyDocContent() + } + return textToDocContent(text) +} + +func ImportBytes(name, mime string, body []byte) (json.RawMessage, error) { + ext := strings.ToLower(name) + if dot := strings.LastIndex(ext, "."); dot >= 0 { + ext = ext[dot+1:] + } + switch ext { + case "ultidoc", "json": + if strings.HasSuffix(strings.ToLower(name), UltidocExtension) || strings.Contains(name, "ultidoc") { + doc, err := ParseUltiDoc(body) + if err != nil { + return nil, err + } + return doc.Content, nil + } + var generic map[string]any + if err := json.Unmarshal(body, &generic); err == nil { + if t, _ := generic["type"].(string); t == "doc" { + return json.RawMessage(body), nil + } + } + case "txt", "log", "ini", "conf", "cfg", "env": + return textToDocContent(string(body)), nil + case "md", "markdown": + return textToDocContent(string(body)), nil + case "html", "htm": + return htmlToDocContent(string(body)), nil + } + _ = mime + return nil, fmt.Errorf("import requires client conversion for %q", name) +} diff --git a/internal/api/richtext/handlers.go b/internal/api/richtext/handlers.go new file mode 100644 index 0000000..6487975 --- /dev/null +++ b/internal/api/richtext/handlers.go @@ -0,0 +1,274 @@ +package richtext + +import ( + "encoding/json" + "io" + "log/slog" + "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" + "github.com/ultisuite/ulti-backend/internal/permission" +) + +type Handler struct { + svc *Service + drive *drive.Service + logger *slog.Logger +} + +func NewHandler(svc *Service, driveSvc *drive.Service) *Handler { + return &Handler{ + svc: svc, + drive: driveSvc, + logger: slog.Default().With("component", "richtext-api"), + } +} + +func (h *Handler) Routes(authMiddleware func(http.Handler) http.Handler) chi.Router { + r := chi.NewRouter() + r.Get("/document", h.ServeDocument) + r.Put("/document", h.PutDocument) + r.Post("/hooks/store", h.HookStore) + r.Get("/internal/document", h.InternalLoadDocument) + + r.Group(func(pr chi.Router) { + pr.Use(authMiddleware) + read := middleware.RequirePermission(permission.ResourceDrive, permission.LevelRead) + write := middleware.RequirePermission(permission.ResourceDrive, permission.LevelWrite) + pr.With(read).Post("/session", h.CreateSession) + pr.With(read).Post("/import", h.Import) + pr.With(read).Post("/export", h.Export) + pr.With(write).Put("/save", h.Save) + }) + return r +} + +func (h *Handler) RegisterPublicShareRoutes(r chi.Router) { + r.Post("/shares/{token}/richtext/session", h.PublicShareSession) + r.Post("/shares/{token}/richtext/import", h.PublicShareImport) + r.Get("/shares/{token}/richtext/document", h.PublicShareDocument) + r.Put("/shares/{token}/richtext/document", h.PublicSharePutDocument) +} + +type sessionRequest struct { + Path string `json:"path"` + Mode string `json:"mode"` +} + +func (h *Handler) CreateSession(w http.ResponseWriter, r *http.Request) { + claims := middleware.ClaimsFromContext(r.Context()) + ncUser, err := h.svc.EnsureNextcloudUser(r.Context(), claims) + if err != nil { + apivalidate.WriteInternal(w, r) + return + } + var req sessionRequest + if err := apivalidate.DecodeJSON(w, r, 32<<10, &req); err != nil { + return + } + if strings.TrimSpace(req.Path) == "" { + apivalidate.WriteValidationError(w, r, apivalidate.NewValidationError( + apivalidate.FieldDetail{Field: "path", Message: "required"}, + )) + return + } + mode := strings.TrimSpace(req.Mode) + if mode == "" { + mode = "edit" + } + result, err := h.svc.CreateSession(r.Context(), ncUser, req.Path, mode, claims.Sub, claims.Name) + if err != nil { + h.logger.Error("richtext session", "error", err) + apiresponse.WriteError(w, r, http.StatusBadRequest, apiresponse.CodeInvalidRequest, err.Error(), nil) + return + } + apiresponse.WriteJSON(w, http.StatusOK, result) +} + +func (h *Handler) Import(w http.ResponseWriter, r *http.Request) { + claims := middleware.ClaimsFromContext(r.Context()) + ncUser, err := h.svc.EnsureNextcloudUser(r.Context(), claims) + if err != nil { + apivalidate.WriteInternal(w, r) + return + } + var req ImportRequest + if err := apivalidate.DecodeJSON(w, r, 32<<20, &req); err != nil { + return + } + canonical, err := h.svc.ImportDocument(r.Context(), ncUser, claims.Sub, req) + if err != nil { + apiresponse.WriteError(w, r, http.StatusBadRequest, apiresponse.CodeInvalidRequest, err.Error(), nil) + return + } + apiresponse.WriteJSON(w, http.StatusOK, map[string]string{"canonical_path": canonical}) +} + +func (h *Handler) Export(w http.ResponseWriter, r *http.Request) { + claims := middleware.ClaimsFromContext(r.Context()) + ncUser, err := h.svc.EnsureNextcloudUser(r.Context(), claims) + if err != nil { + apivalidate.WriteInternal(w, r) + return + } + var req ExportRequest + if err := apivalidate.DecodeJSON(w, r, 32<<10, &req); err != nil { + return + } + body, ct, err := h.svc.ExportDocument(r.Context(), ncUser, req.Path, req.Format) + if err != nil { + apiresponse.WriteError(w, r, http.StatusBadRequest, apiresponse.CodeInvalidRequest, err.Error(), nil) + return + } + w.Header().Set("Content-Type", ct) + w.WriteHeader(http.StatusOK) + _, _ = w.Write(body) +} + +type saveRequest struct { + Path string `json:"path"` + Document json.RawMessage `json:"document"` + YjsState string `json:"yjsState,omitempty"` +} + +func (h *Handler) Save(w http.ResponseWriter, r *http.Request) { + claims := middleware.ClaimsFromContext(r.Context()) + ncUser, err := h.svc.EnsureNextcloudUser(r.Context(), claims) + if err != nil { + apivalidate.WriteInternal(w, r) + return + } + var req saveRequest + if err := apivalidate.DecodeJSON(w, r, 32<<20, &req); err != nil { + return + } + doc := NewUltiDoc(req.Document, nil) + doc.YjsState = req.YjsState + payload, err := doc.Marshal() + if err != nil { + apivalidate.WriteInternal(w, r) + return + } + if err := h.svc.SaveDocument(r.Context(), ncUser, req.Path, payload, claims.Sub); err != nil { + apivalidate.WriteInternal(w, r) + return + } + apiresponse.WriteJSON(w, http.StatusOK, map[string]string{"path": normalizePath(req.Path)}) +} + +func (h *Handler) ServeDocument(w http.ResponseWriter, r *http.Request) { + sig := strings.TrimSpace(r.URL.Query().Get("sig")) + path := strings.TrimSpace(r.URL.Query().Get("path")) + user := strings.TrimSpace(r.URL.Query().Get("user")) + if h.svc.Cfg.HocuspocusSecret != "" { + if _, err := verifyDocAccessSig(sig, user, path, h.svc.Cfg.HocuspocusSecret); err != nil { + http.Error(w, "forbidden", http.StatusForbidden) + return + } + } + body, err := h.svc.LoadDocumentForUser(r.Context(), user, path) + if err != nil { + http.Error(w, "not found", http.StatusNotFound) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(body) +} + +func (h *Handler) PutDocument(w http.ResponseWriter, r *http.Request) { + sig := strings.TrimSpace(r.URL.Query().Get("sig")) + path := strings.TrimSpace(r.URL.Query().Get("path")) + user := strings.TrimSpace(r.URL.Query().Get("user")) + platformUser := strings.TrimSpace(r.URL.Query().Get("sub")) + if h.svc.Cfg.HocuspocusSecret != "" { + if _, err := verifyDocAccessSig(sig, user, path, h.svc.Cfg.HocuspocusSecret); err != nil { + http.Error(w, "forbidden", http.StatusForbidden) + return + } + } + raw, err := io.ReadAll(r.Body) + if err != nil { + apivalidate.WriteInternal(w, r) + return + } + if err := h.svc.SaveDocument(r.Context(), user, path, raw, platformUser); err != nil { + http.Error(w, "save failed", http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (h *Handler) InternalLoadDocument(w http.ResponseWriter, r *http.Request) { + secret := r.Header.Get("X-Hocuspocus-Secret") + if h.svc.Cfg.HocuspocusSecret != "" && secret != h.svc.Cfg.HocuspocusSecret { + http.Error(w, "forbidden", http.StatusForbidden) + return + } + user := strings.TrimSpace(r.URL.Query().Get("user")) + path := strings.TrimSpace(r.URL.Query().Get("path")) + body, err := h.svc.LoadDocumentForUser(r.Context(), user, path) + if err != nil { + http.Error(w, "not found", http.StatusNotFound) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(body) +} + +type hookStorePayload struct { + Room string `json:"room"` + Path string `json:"path"` + User string `json:"user"` + Sub string `json:"sub"` + YjsState string `json:"yjsState"` + Document json.RawMessage `json:"document,omitempty"` +} + +func (h *Handler) HookStore(w http.ResponseWriter, r *http.Request) { + secret := r.Header.Get("X-Hocuspocus-Secret") + if h.svc.Cfg.HocuspocusSecret != "" && secret != h.svc.Cfg.HocuspocusSecret { + http.Error(w, "forbidden", http.StatusForbidden) + return + } + var payload hookStorePayload + if err := apivalidate.DecodeJSON(w, r, 32<<20, &payload); err != nil { + return + } + path := normalizePath(payload.Path) + var raw []byte + if len(payload.Document) > 0 { + doc := NewUltiDoc(payload.Document, nil) + doc.YjsState = payload.YjsState + var err error + raw, err = doc.Marshal() + if err != nil { + apivalidate.WriteInternal(w, r) + return + } + } else if payload.YjsState != "" { + doc := UltiDoc{SchemaVersion: schemaVersion, Editor: "tiptap", YjsState: payload.YjsState, Content: emptyDocContent()} + var err error + raw, err = doc.Marshal() + if err != nil { + apivalidate.WriteInternal(w, r) + return + } + } else { + apivalidate.WriteValidationError(w, r, apivalidate.NewValidationError( + apivalidate.FieldDetail{Field: "document", Message: "required"}, + )) + return + } + if err := h.svc.SaveDocument(r.Context(), payload.User, path, raw, payload.Sub); err != nil { + h.logger.Error("hook store", "error", err, "path", path) + apivalidate.WriteInternal(w, r) + return + } + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/api/richtext/jwt.go b/internal/api/richtext/jwt.go new file mode 100644 index 0000000..2be7f90 --- /dev/null +++ b/internal/api/richtext/jwt.go @@ -0,0 +1,113 @@ +package richtext + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "time" +) + +type roomTokenPayload struct { + Room string `json:"room"` + Path string `json:"path"` + User string `json:"user"` + Sub string `json:"sub"` + Name string `json:"name"` + Mode string `json:"mode"` + Expires int64 `json:"exp"` +} + +func signRoomToken(payload roomTokenPayload, secret string) (string, error) { + if secret == "" { + return "", nil + } + return signJWT(payload, secret) +} + +func VerifyRoomToken(token, secret string) (roomTokenPayload, error) { + var out roomTokenPayload + if secret == "" { + return out, fmt.Errorf("missing secret") + } + raw, err := verifyJWT(token, secret) + if err != nil { + return out, err + } + if exp, ok := raw["exp"].(float64); ok && int64(exp) < time.Now().Unix() { + return out, fmt.Errorf("token expired") + } + b, _ := json.Marshal(raw) + _ = json.Unmarshal(b, &out) + return out, nil +} + +func signJWT(payload any, secret string) (string, error) { + if secret == "" { + return "", nil + } + header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"HS256","typ":"JWT"}`)) + bodyBytes, err := json.Marshal(payload) + if err != nil { + return "", err + } + body := base64.RawURLEncoding.EncodeToString(bodyBytes) + mac := hmac.New(sha256.New, []byte(secret)) + _, _ = mac.Write([]byte(header + "." + body)) + sig := base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) + return header + "." + body + "." + sig, nil +} + +func verifyJWT(token, secret string) (map[string]any, error) { + if secret == "" || token == "" { + return nil, fmt.Errorf("missing token or secret") + } + parts := splitJWT(token) + if len(parts) != 3 { + return nil, fmt.Errorf("invalid token") + } + mac := hmac.New(sha256.New, []byte(secret)) + _, _ = mac.Write([]byte(parts[0] + "." + parts[1])) + expected := base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) + if !hmac.Equal([]byte(expected), []byte(parts[2])) { + return nil, fmt.Errorf("invalid signature") + } + raw, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return nil, err + } + var payload map[string]any + if err := json.Unmarshal(raw, &payload); err != nil { + return nil, err + } + return payload, nil +} + +func splitJWT(token string) []string { + var parts []string + start := 0 + for i := 0; i < len(token); i++ { + if token[i] == '.' { + parts = append(parts, token[start:i]) + start = i + 1 + } + } + parts = append(parts, token[start:]) + return parts +} + +func sha256Hex(b []byte) string { + sum := sha256.Sum256(b) + return hexEncode(sum[:]) +} + +func hexEncode(b []byte) string { + const hexdigits = "0123456789abcdef" + out := make([]byte, len(b)*2) + for i, v := range b { + out[i*2] = hexdigits[v>>4] + out[i*2+1] = hexdigits[v&0x0f] + } + return string(out) +} diff --git a/internal/api/richtext/paths.go b/internal/api/richtext/paths.go new file mode 100644 index 0000000..51427a7 --- /dev/null +++ b/internal/api/richtext/paths.go @@ -0,0 +1,74 @@ +package richtext + +import ( + "strings" +) + +const UltidocExtension = "ultidoc.json" + +// Config holds rich-text editor integration settings. +type Config struct { + Enabled bool + HocuspocusPublicURL string + HocuspocusSecret string + APIInternalURL string + StorageMode string // sidecar | overwrite + ExportMirrorFormat string // "" | docx +} + +func normalizePath(p string) string { + p = strings.TrimSpace(p) + if p == "" { + return "/" + } + if !strings.HasPrefix(p, "/") { + p = "/" + p + } + return strings.ReplaceAll(p, "//", "/") +} + +func fileNameFromPath(p string) string { + p = normalizePath(p) + if p == "/" { + return "" + } + if i := strings.LastIndex(p, "/"); i >= 0 { + return p[i+1:] + } + return p +} + +func isUltidocPath(path string) bool { + return strings.HasSuffix(strings.ToLower(path), "."+UltidocExtension) +} + +func sidecarPathForSource(sourcePath string) string { + sourcePath = normalizePath(sourcePath) + dir := "/" + name := strings.TrimPrefix(sourcePath, "/") + if i := strings.LastIndex(name, "/"); i >= 0 { + dir = "/" + name[:i] + name = name[i+1:] + } + base := name + if dot := strings.LastIndex(name, "."); dot > 0 { + base = name[:dot] + } + sidecar := base + "." + UltidocExtension + if dir == "/" { + return "/" + sidecar + } + return dir + "/" + sidecar +} + +func parentDir(path string) string { + path = normalizePath(path) + if path == "/" { + return "/" + } + idx := strings.LastIndex(path, "/") + if idx <= 0 { + return "/" + } + return path[:idx] +} diff --git a/internal/api/richtext/public_handlers.go b/internal/api/richtext/public_handlers.go new file mode 100644 index 0000000..8e6d8e1 --- /dev/null +++ b/internal/api/richtext/public_handlers.go @@ -0,0 +1,205 @@ +package richtext + +import ( + "encoding/json" + "io" + "net/http" + "strings" + "time" + + "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/nextcloud" +) + +type publicSessionRequest struct { + Path string `json:"path"` + Mode string `json:"mode"` + Password string `json:"password"` + GuestID string `json:"guest_id"` + GuestName string `json:"guest_name"` + DisplayName string `json:"display_name"` +} + +func (h *Handler) PublicShareSession(w http.ResponseWriter, r *http.Request) { + token := strings.TrimSpace(chi.URLParam(r, "token")) + if token == "" { + apivalidate.WriteNotFound(w, r, "not found") + return + } + var req publicSessionRequest + if err := apivalidate.DecodeJSON(w, r, 32<<10, &req); err != nil { + return + } + if strings.TrimSpace(req.Path) == "" { + apivalidate.WriteValidationError(w, r, apivalidate.NewValidationError( + apivalidate.FieldDetail{Field: "path", Message: "required"}, + )) + return + } + password := strings.TrimSpace(req.Password) + perms, err := h.svc.nc.EffectivePublicSharePermissions(r.Context(), token, req.Path, password) + if err != nil { + apivalidate.WriteInternal(w, r) + return + } + if !nextcloud.PublicShareCanRead(perms) { + http.Error(w, "forbidden", http.StatusForbidden) + return + } + mode := strings.TrimSpace(req.Mode) + if mode == "" { + mode = "edit" + } + if mode == "edit" && !nextcloud.PublicShareCanUpdate(perms) { + mode = "view" + } + + guestID := strings.TrimSpace(req.GuestID) + if guestID == "" { + guestID = "public-guest" + } else { + guestID = "public:" + guestID + } + guestName := strings.TrimSpace(req.GuestName) + if guestName == "" { + guestName = "Invité" + } + + result, err := h.svc.CreatePublicSession(r.Context(), token, req.Path, mode, password, guestID, guestName, strings.TrimSpace(req.DisplayName)) + if err != nil { + h.logger.Error("public richtext session", "error", err) + apivalidate.WriteInternal(w, r) + return + } + result.Mode = mode + apiresponse.WriteJSON(w, http.StatusOK, result) +} + +type publicImportRequest struct { + Path string `json:"path"` + SourcePath string `json:"source_path"` + Password string `json:"password"` + DisplayName string `json:"display_name"` + Content json.RawMessage `json:"content"` +} + +func (h *Handler) PublicShareImport(w http.ResponseWriter, r *http.Request) { + token := strings.TrimSpace(chi.URLParam(r, "token")) + if token == "" { + apivalidate.WriteNotFound(w, r, "not found") + return + } + var req publicImportRequest + if err := apivalidate.DecodeJSON(w, r, 32<<20, &req); err != nil { + return + } + password := strings.TrimSpace(req.Password) + source := strings.TrimSpace(req.SourcePath) + if source == "" { + source = strings.TrimSpace(req.Path) + } + if source == "" { + apivalidate.WriteValidationError(w, r, apivalidate.NewValidationError( + apivalidate.FieldDetail{Field: "source_path", Message: "required"}, + )) + return + } + perms, err := h.svc.nc.EffectivePublicSharePermissions(r.Context(), token, source, password) + if err != nil || !nextcloud.PublicShareCanUpdate(perms) { + http.Error(w, "forbidden", http.StatusForbidden) + return + } + canonical, err := h.svc.ImportPublicDocument(r.Context(), token, password, strings.TrimSpace(req.DisplayName), ImportRequest{ + SourcePath: source, + Content: req.Content, + }) + if err != nil { + apiresponse.WriteError(w, r, http.StatusBadRequest, apiresponse.CodeInvalidRequest, err.Error(), nil) + return + } + apiresponse.WriteJSON(w, http.StatusOK, map[string]string{"canonical_path": canonical}) +} + +func (h *Handler) PublicShareDocument(w http.ResponseWriter, r *http.Request) { + token := strings.TrimSpace(chi.URLParam(r, "token")) + path := strings.TrimSpace(r.URL.Query().Get("path")) + password := strings.TrimSpace(r.URL.Query().Get("password")) + sig := strings.TrimSpace(r.URL.Query().Get("sig")) + if h.svc.Cfg.HocuspocusSecret != "" && !verifyPublicDocAccess(token, path, password, sig, h.svc.Cfg.HocuspocusSecret) { + http.Error(w, "forbidden", http.StatusForbidden) + return + } + body, err := h.svc.LoadPublicDocumentLegacy(r.Context(), token, path, password) + if err != nil { + http.Error(w, "not found", http.StatusNotFound) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(body) +} + +func (h *Handler) PublicSharePutDocument(w http.ResponseWriter, r *http.Request) { + token := strings.TrimSpace(chi.URLParam(r, "token")) + path := strings.TrimSpace(r.URL.Query().Get("path")) + password := strings.TrimSpace(r.URL.Query().Get("password")) + sig := strings.TrimSpace(r.URL.Query().Get("sig")) + if h.svc.Cfg.HocuspocusSecret != "" && !verifyPublicDocAccess(token, path, password, sig, h.svc.Cfg.HocuspocusSecret) { + http.Error(w, "forbidden", http.StatusForbidden) + return + } + perms, err := h.svc.nc.EffectivePublicSharePermissions(r.Context(), token, path, password) + if err != nil || !nextcloud.PublicShareCanUpdate(perms) { + http.Error(w, "forbidden", http.StatusForbidden) + return + } + raw, err := io.ReadAll(r.Body) + if err != nil { + apivalidate.WriteInternal(w, r) + return + } + if err := h.svc.SavePublicDocumentLegacy(r.Context(), token, path, password, raw); err != nil { + http.Error(w, "save failed", http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func verifyDocAccessSig(sig, user, path, secret string) (map[string]any, error) { + if secret == "" { + return map[string]any{}, nil + } + return verifyJWT(sig, secret) +} + +func verifyPublicDocAccess(token, filePath, password, sig, secret string) bool { + if secret == "" { + return true + } + payload, err := verifyJWT(sig, secret) + if err != nil { + return false + } + if payload["token"] != strings.TrimSpace(token) || payload["path"] != normalizePath(filePath) { + return false + } + if pw, _ := payload["password"].(string); pw != password { + return false + } + if exp, ok := payload["exp"].(float64); ok && int64(exp) < time.Now().Unix() { + return false + } + return true +} + +func signPublicDocAccess(token, filePath, password, secret string) (string, error) { + payload := map[string]any{ + "token": strings.TrimSpace(token), + "path": normalizePath(filePath), + "password": password, + "exp": time.Now().Add(2 * time.Hour).Unix(), + } + return signJWT(payload, secret) +} diff --git a/internal/api/richtext/public_share.go b/internal/api/richtext/public_share.go new file mode 100644 index 0000000..c73ebc4 --- /dev/null +++ b/internal/api/richtext/public_share.go @@ -0,0 +1,209 @@ +package richtext + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/url" + "strings" + "time" +) + +type PublicSessionResult struct { + SessionResult + DocumentURL string `json:"documentUrl,omitempty"` + SaveURL string `json:"saveUrl,omitempty"` +} + +func (s *Service) CreatePublicSession(ctx context.Context, token, filePath, mode, password, guestID, guestName, displayName string) (*PublicSessionResult, error) { + if !s.Cfg.Enabled { + return nil, fmt.Errorf("rich text editor disabled") + } + filePath = normalizePath(filePath) + canonical, source, importRequired, err := s.resolvePublicCanonicalPath(ctx, token, filePath, password, displayName) + if err != nil { + return nil, err + } + + ownerID, ownerPath, err := s.ownerSidecarPathForPublic(ctx, token, password, canonical, displayName) + if err != nil { + return nil, err + } + roomID, err := s.resolveCollabRoomID(ctx, ownerID, ownerPath) + if err != nil { + return nil, err + } + tokenJWT, err := signRoomToken(roomTokenPayload{ + Room: roomID, + Path: canonical, + User: "public:" + token, + Sub: guestID, + Name: guestName, + Mode: mode, + Expires: time.Now().Add(8 * time.Hour).Unix(), + }, s.Cfg.HocuspocusSecret) + if err != nil { + return nil, err + } + + apiBase := strings.TrimRight(s.Cfg.APIInternalURL, "/") + sig, _ := signPublicDocAccess(token, canonical, password, s.Cfg.HocuspocusSecret) + docURL := fmt.Sprintf("%s/api/v1/drive/public/shares/%s/richtext/document?path=%s&password=%s&sig=%s", + apiBase, url.PathEscape(token), url.QueryEscape(canonical), url.QueryEscape(password), url.QueryEscape(sig)) + saveURL := docURL + + wsURL := strings.TrimSpace(s.Cfg.HocuspocusPublicURL) + collab := wsURL != "" && s.Cfg.HocuspocusSecret != "" + + return &PublicSessionResult{ + SessionResult: SessionResult{ + RoomID: roomID, + CanonicalPath: canonical, + SourcePath: source, + WsURL: wsURL, + Token: tokenJWT, + Mode: mode, + ImportRequired: importRequired, + Collaboration: collab, + }, + DocumentURL: docURL, + SaveURL: saveURL, + }, nil +} + +func (s *Service) resolvePublicCanonicalPath(ctx context.Context, token, filePath, password, displayName string) (canonical, source string, importRequired bool, err error) { + filePath = normalizePath(filePath) + source = filePath + if source == "/" { + source = s.publicClientSourcePath(ctx, token, password, filePath, displayName) + } + + if isUltidocPath(filePath) { + return filePath, "", false, nil + } + if isUltidocPath(source) { + return source, "", false, nil + } + + sidecar := sidecarPathForSource(source) + + if s.publicSidecarExists(ctx, token, password, sidecar, displayName) { + return sidecar, source, false, nil + } + + if _, err := s.publicFileExists(ctx, token, source, password); err != nil { + return "", "", false, fmt.Errorf("file not found") + } + + return sidecar, source, true, nil +} + +func (s *Service) publicClientSourcePath(ctx context.Context, token, password, clientPath, displayName string) string { + binding, err := s.nc.ResolvePublicShareBinding(ctx, token, password) + if err == nil { + return binding.ClientSourcePath(clientPath, displayName) + } + if name := strings.TrimSpace(displayName); name != "" { + return normalizePath("/" + name) + } + return clientPath +} + +func (s *Service) publicSidecarExists(ctx context.Context, token, password, clientSidecar, displayName string) bool { + if _, err := s.publicFileExists(ctx, token, clientSidecar, password); err == nil { + return true + } + binding, err := s.nc.ResolvePublicShareBinding(ctx, token, password) + if err != nil { + return false + } + ownerPath := binding.OwnerPathForClient(clientSidecar, displayName) + if _, err := s.nc.FileRevision(ctx, binding.OwnerID, ownerPath); err == nil { + return true + } + return false +} + +func (s *Service) ImportPublicDocument(ctx context.Context, token, password, displayName string, req ImportRequest) (string, error) { + source := normalizePath(req.SourcePath) + canonical, _, importRequired, err := s.resolvePublicCanonicalPath(ctx, token, source, password, displayName) + if err != nil { + return "", err + } + if !importRequired { + return canonical, nil + } + + var content json.RawMessage + if len(req.Content) > 0 { + content = req.Content + } else { + body, err := s.LoadPublicDocument(ctx, token, source, password, displayName) + if err != nil { + return "", err + } + name := fileNameFromPath(source) + content, err = ImportBytes(name, "", body) + if err != nil { + return "", err + } + } + doc := NewUltiDoc(content, &UltiDocSource{ + Path: source, + ImportedAt: time.Now().UTC().Format(time.RFC3339), + }) + payload, err := doc.Marshal() + if err != nil { + return "", err + } + if err := s.SavePublicDocument(ctx, token, canonical, password, displayName, payload); err != nil { + return "", err + } + return canonical, nil +} + +func (s *Service) publicFileExists(ctx context.Context, token, path, password string) (bool, error) { + _, err := s.nc.PublicShareFileRevision(ctx, token, path, password) + if err != nil { + return false, err + } + return true, nil +} + +func (s *Service) LoadPublicDocument(ctx context.Context, token, clientPath, password, displayName string) ([]byte, error) { + if binding, err := s.nc.ResolvePublicShareBinding(ctx, token, password); err == nil { + ownerPath := binding.OwnerPathForClient(clientPath, displayName) + body, _, err := s.nc.Download(ctx, binding.OwnerID, ownerPath) + if err == nil { + defer body.Close() + return io.ReadAll(body) + } + } + body, _, err := s.nc.DownloadPublicShare(ctx, token, clientPath, password) + if err != nil { + return nil, err + } + defer body.Close() + return io.ReadAll(body) +} + +func (s *Service) SavePublicDocument(ctx context.Context, token, clientPath, password, displayName string, raw []byte) error { + if binding, err := s.nc.ResolvePublicShareBinding(ctx, token, password); err == nil { + ownerPath := binding.OwnerPathForClient(clientPath, displayName) + reader := strings.NewReader(string(raw)) + if err := s.nc.Upload(ctx, binding.OwnerID, ownerPath, reader, "application/json"); err == nil { + return nil + } + } + reader := strings.NewReader(string(raw)) + return s.nc.UploadPublicShare(ctx, token, clientPath, password, reader, "application/json") +} + +func (s *Service) LoadPublicDocumentLegacy(ctx context.Context, token, path, password string) ([]byte, error) { + return s.LoadPublicDocument(ctx, token, path, password, "") +} + +func (s *Service) SavePublicDocumentLegacy(ctx context.Context, token, path, password string, raw []byte) error { + return s.SavePublicDocument(ctx, token, path, password, "", raw) +} diff --git a/internal/api/richtext/service.go b/internal/api/richtext/service.go new file mode 100644 index 0000000..5f1f389 --- /dev/null +++ b/internal/api/richtext/service.go @@ -0,0 +1,326 @@ +package richtext + +import ( + "context" + "encoding/json" + "fmt" + "io" + "strings" + "time" + + "github.com/ultisuite/ulti-backend/internal/auth" + "github.com/ultisuite/ulti-backend/internal/nextcloud" +) + +type Service struct { + nc *nextcloud.Client + Cfg Config + hub fileChangePublisher +} + +type fileChangePublisher interface { + PublishFileChanged(platformUserID, path string) +} + +func NewService(nc *nextcloud.Client, cfg Config, hub fileChangePublisher) *Service { + if cfg.StorageMode == "" { + cfg.StorageMode = "sidecar" + } + return &Service{nc: nc, Cfg: cfg, hub: hub} +} + +func (s *Service) EnsureNextcloudUser(ctx context.Context, claims *auth.Claims) (string, error) { + return s.nc.EnsurePrincipal(ctx, claims.Email, claims.Sub, claims.Name) +} + +type SessionResult struct { + RoomID string `json:"roomId"` + CanonicalPath string `json:"canonicalPath"` + SourcePath string `json:"sourcePath,omitempty"` + WsURL string `json:"wsUrl"` + Token string `json:"token"` + Mode string `json:"mode"` + ImportRequired bool `json:"importRequired"` + Collaboration bool `json:"collaboration"` +} + +func (s *Service) CreateSession(ctx context.Context, ncUser, filePath, mode, editorUserID, editorName string) (*SessionResult, error) { + if !s.Cfg.Enabled { + return nil, fmt.Errorf("rich text editor disabled") + } + filePath = normalizePath(filePath) + if mode == "" { + mode = "edit" + } + + canonical, source, importRequired, err := s.resolveCanonicalPath(ctx, ncUser, filePath) + if err != nil { + return nil, err + } + + roomID, err := s.resolveCollabRoomID(ctx, ncUser, canonical) + if err != nil { + return nil, err + } + + token, err := signRoomToken(roomTokenPayload{ + Room: roomID, + Path: canonical, + User: ncUser, + Sub: editorUserID, + Name: editorName, + Mode: mode, + Expires: time.Now().Add(8 * time.Hour).Unix(), + }, s.Cfg.HocuspocusSecret) + if err != nil { + return nil, err + } + + wsURL := strings.TrimSpace(s.Cfg.HocuspocusPublicURL) + collab := wsURL != "" && s.Cfg.HocuspocusSecret != "" + + return &SessionResult{ + RoomID: roomID, + CanonicalPath: canonical, + SourcePath: source, + WsURL: wsURL, + Token: token, + Mode: mode, + ImportRequired: importRequired, + Collaboration: collab, + }, nil +} + +func (s *Service) resolveCanonicalPath(ctx context.Context, ncUser, filePath string) (canonical, source string, importRequired bool, err error) { + if isUltidocPath(filePath) { + return filePath, "", false, nil + } + + sidecar := sidecarPathForSource(filePath) + if s.Cfg.StorageMode == "overwrite" { + target := sidecarPathForSource(filePath) + if _, err := s.nc.FileRevision(ctx, ncUser, target); err == nil { + return target, filePath, false, nil + } + // Will import and optionally move — treat as import required if source exists. + if _, err := s.nc.FileRevision(ctx, ncUser, filePath); err == nil { + return target, filePath, true, nil + } + return "", "", false, fmt.Errorf("file not found: %s", filePath) + } + + if _, err := s.nc.FileRevision(ctx, ncUser, sidecar); err == nil { + return sidecar, filePath, false, nil + } + if _, err := s.nc.FileRevision(ctx, ncUser, filePath); err != nil { + return "", "", false, fmt.Errorf("file not found: %s", filePath) + } + return sidecar, filePath, true, nil +} + +func (s *Service) LoadDocument(ctx context.Context, ncUser, path string) ([]byte, error) { + path = normalizePath(path) + body, _, err := s.nc.Download(ctx, ncUser, path) + if err != nil { + return nil, err + } + defer body.Close() + return io.ReadAll(body) +} + +func (s *Service) SaveDocument(ctx context.Context, ncUser, path string, raw []byte, platformUserID string) error { + path = normalizePath(path) + reader := strings.NewReader(string(raw)) + if strings.HasPrefix(ncUser, "public:") { + token := strings.TrimPrefix(ncUser, "public:") + return s.SavePublicDocumentLegacy(ctx, token, path, "", raw) + } + if err := s.nc.Upload(ctx, ncUser, path, reader, "application/json"); err != nil { + return err + } + if s.hub != nil && platformUserID != "" { + s.hub.PublishFileChanged(platformUserID, path) + } + return nil +} + +func (s *Service) LoadDocumentForUser(ctx context.Context, ncUser, path string) ([]byte, error) { + path = normalizePath(path) + if strings.HasPrefix(ncUser, "public:") { + token := strings.TrimPrefix(ncUser, "public:") + return s.LoadPublicDocumentLegacy(ctx, token, path, "") + } + return s.LoadDocument(ctx, ncUser, path) +} + +type ImportRequest struct { + SourcePath string `json:"source_path"` + Content json.RawMessage `json:"content,omitempty"` + YjsState string `json:"yjsState,omitempty"` +} + +func (s *Service) ImportDocument(ctx context.Context, ncUser, platformUserID string, req ImportRequest) (string, error) { + source := normalizePath(req.SourcePath) + canonical, _, importRequired, err := s.resolveCanonicalPath(ctx, ncUser, source) + if err != nil { + return "", err + } + if !importRequired && len(req.Content) == 0 { + return canonical, nil + } + + var content json.RawMessage + if len(req.Content) > 0 { + content = req.Content + } else { + body, _, err := s.nc.Download(ctx, ncUser, source) + if err != nil { + return "", err + } + raw, err := io.ReadAll(body) + body.Close() + if err != nil { + return "", err + } + name := fileNameFromPath(source) + mime := "" + content, err = ImportBytes(name, mime, raw) + if err != nil { + return "", err + } + } + + doc := NewUltiDoc(content, &UltiDocSource{ + Path: source, + ImportedAt: time.Now().UTC().Format(time.RFC3339), + }) + if req.YjsState != "" { + doc.YjsState = req.YjsState + } + payload, err := doc.Marshal() + if err != nil { + return "", err + } + + if err := s.SaveDocument(ctx, ncUser, canonical, payload, platformUserID); err != nil { + return "", err + } + + if s.Cfg.StorageMode == "overwrite" && source != canonical { + if err := s.nc.Move(ctx, ncUser, source, canonical); err != nil { + return canonical, nil + } + } + + if source != canonical { + if err := replicateFileShares(ctx, s.nc, ncUser, source, canonical); err != nil { + return canonical, fmt.Errorf("save ok but share replication failed: %w", err) + } + } + + return canonical, nil +} + +func replicateFileShares(ctx context.Context, nc *nextcloud.Client, userID, fromPath, toPath string) error { + shares, err := nc.ListShares(ctx, userID, fromPath) + if err != nil { + return err + } + for _, sh := range shares { + opts := nextcloud.CreateShareOptions{ + ShareType: sh.ShareType, + Permissions: sh.Permissions, + ShareWith: sh.ShareWith, + ExpireDate: sh.ExpiresAt, + Note: sh.Note, + Label: sh.Label, + } + if _, err := nc.CreateShare(ctx, userID, toPath, opts); err != nil { + return err + } + } + return nil +} + +type ExportRequest struct { + Path string `json:"path"` + Format string `json:"format"` +} + +func (s *Service) ExportDocument(ctx context.Context, ncUser, path, format string) ([]byte, string, error) { + path = normalizePath(path) + raw, err := s.LoadDocument(ctx, ncUser, path) + if err != nil { + return nil, "", err + } + doc, err := ParseUltiDoc(raw) + if err != nil { + return nil, "", err + } + format = strings.ToLower(strings.TrimSpace(format)) + switch format { + case "json", "ultidoc": + out, err := json.MarshalIndent(doc, "", " ") + return out, "application/json", err + case "txt", "text": + return []byte(contentToPlainText(doc.Content)), "text/plain; charset=utf-8", nil + case "html", "htm": + return []byte(contentToHTML(doc.Content)), "text/html; charset=utf-8", nil + default: + return nil, "", fmt.Errorf("unsupported export format %q", format) + } +} + +func contentToPlainText(content json.RawMessage) string { + var node map[string]any + if err := json.Unmarshal(content, &node); err != nil { + return "" + } + var b strings.Builder + extractText(node, &b) + return strings.TrimSpace(b.String()) +} + +func extractText(node map[string]any, b *strings.Builder) { + if t, ok := node["text"].(string); ok { + b.WriteString(t) + } + children, _ := node["content"].([]any) + for _, ch := range children { + if m, ok := ch.(map[string]any); ok { + extractText(m, b) + } + } + if typ, _ := node["type"].(string); typ == "paragraph" || typ == "heading" { + b.WriteString("\n") + } +} + +func contentToHTML(content json.RawMessage) string { + text := contentToPlainText(content) + if text == "" { + return "

" + } + var b strings.Builder + b.WriteString("") + for _, line := range strings.Split(text, "\n") { + b.WriteString("

") + b.WriteString(htmlEscape(line)) + b.WriteString("

") + } + b.WriteString("") + return b.String() +} + +func htmlEscape(s string) string { + r := strings.NewReplacer("&", "&", "<", "<", ">", ">", `"`, """) + return r.Replace(s) +} + +func hashPath(p string) string { + h := sha256Hex([]byte(normalizePath(p))) + if len(h) > 16 { + return h[:16] + } + return h +} diff --git a/internal/config/config.go b/internal/config/config.go index 71ac13c..ac302f0 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -69,6 +69,13 @@ type Config struct { UltidPublicURL string DrivePublicURL string + // Rich text editor (TipTap + Hocuspocus) + RichTextEnabled bool + HocuspocusPublicURL string + HocuspocusSecret string + RichTextStorageMode string + RichTextExportMirror string + // Jitsi JitsiEnabled bool JitsiDomain string @@ -179,8 +186,13 @@ func Load() (*Config, error) { OnlyOfficePublicURL: envOrDefault("ONLYOFFICE_PUBLIC_URL", "http://localhost/office"), OnlyOfficeAPIInternalURL: envOrDefault("ONLYOFFICE_API_INTERNAL_URL", "http://ultid:8080"), OnlyOfficeJWTSecret: secrets.Env("ONLYOFFICE_JWT_SECRET"), - UltidPublicURL: envOrDefault("ULTID_PUBLIC_URL", "http://localhost"), - DrivePublicURL: drivePublicURL(), + UltidPublicURL: envOrDefault("ULTID_PUBLIC_URL", "http://localhost"), + DrivePublicURL: drivePublicURL(), + RichTextEnabled: envBool("RICHTEXT_ENABLED", true), + HocuspocusPublicURL: envOrDefault("HOCUSPOCUS_PUBLIC_URL", "ws://localhost:1234"), + HocuspocusSecret: secrets.Env("HOCUSPOCUS_SECRET"), + RichTextStorageMode: envOrDefault("RICHTEXT_STORAGE_MODE", "sidecar"), + RichTextExportMirror: envOrDefault("RICHTEXT_EXPORT_MIRROR", ""), JitsiEnabled: envBool("JITSI_ENABLED", true), JitsiDomain: envOrDefault("JITSI_DOMAIN", "meet.jitsi"), diff --git a/internal/nextcloud/drive_hidden.go b/internal/nextcloud/drive_hidden.go new file mode 100644 index 0000000..05d4267 --- /dev/null +++ b/internal/nextcloud/drive_hidden.go @@ -0,0 +1,79 @@ +package nextcloud + +import "strings" + +const ultidocSidecarSuffix = ".ultidoc.json" + +// IsUltidocSidecarName reports whether name is a TipTap sidecar file. +func IsUltidocSidecarName(name string) bool { + return strings.HasSuffix(strings.ToLower(strings.TrimSpace(name)), ultidocSidecarSuffix) +} + +func documentBaseName(name string) string { + name = strings.TrimSpace(name) + if name == "" { + return "" + } + lower := strings.ToLower(name) + if strings.HasSuffix(lower, ultidocSidecarSuffix) { + return name[:len(name)-len(ultidocSidecarSuffix)] + } + if i := strings.LastIndex(name, "."); i > 0 { + return name[:i] + } + return name +} + +func sidecarSourceKey(filePath string) string { + dir := parentDirPath(filePath) + base := strings.ToLower(documentBaseName(fileNameFromPath(filePath))) + return dir + "\x00" + base +} + +func parentDirPath(filePath string) string { + filePath = NormalizeClientPath(filePath) + if filePath == "/" { + return "/" + } + i := strings.LastIndex(filePath, "/") + if i <= 0 { + return "/" + } + return filePath[:i] +} + +func fileNameFromPath(p string) string { + p = NormalizeClientPath(p) + if i := strings.LastIndex(p, "/"); i >= 0 { + return p[i+1:] + } + return p +} + +// FilterHiddenUltidocSidecars removes .ultidoc.json sidecars when the source document +// is present in the same listing (sidecar storage mode). Orphan sidecars (overwrite mode) +// remain visible. +func FilterHiddenUltidocSidecars(files []FileInfo) []FileInfo { + if len(files) == 0 { + return files + } + + sources := make(map[string]struct{}, len(files)) + for _, f := range files { + if f.Type == "directory" || IsUltidocSidecarName(f.Name) { + continue + } + sources[sidecarSourceKey(f.Path)] = struct{}{} + } + + out := make([]FileInfo, 0, len(files)) + for _, f := range files { + if f.Type != "directory" && IsUltidocSidecarName(f.Name) { + if _, ok := sources[sidecarSourceKey(f.Path)]; ok { + continue + } + } + out = append(out, f) + } + return out +} diff --git a/internal/nextcloud/drive_hidden_test.go b/internal/nextcloud/drive_hidden_test.go new file mode 100644 index 0000000..ab58548 --- /dev/null +++ b/internal/nextcloud/drive_hidden_test.go @@ -0,0 +1,21 @@ +package nextcloud + +import "testing" + +func TestFilterHiddenUltidocSidecars(t *testing.T) { + files := []FileInfo{ + {Path: "/docs/report.docx", Name: "report.docx", Type: "file"}, + {Path: "/docs/report.ultidoc.json", Name: "report.ultidoc.json", Type: "file"}, + {Path: "/solo.ultidoc.json", Name: "solo.ultidoc.json", Type: "file"}, + {Path: "/docs", Name: "docs", Type: "directory"}, + } + out := FilterHiddenUltidocSidecars(files) + if len(out) != 3 { + t.Fatalf("len(out) = %d, want 3", len(out)) + } + for _, f := range out { + if f.Name == "report.ultidoc.json" { + t.Fatal("sidecar should be hidden when source exists in listing") + } + } +} diff --git a/internal/nextcloud/public_share.go b/internal/nextcloud/public_share.go index 49cea9d..05cae5d 100644 --- a/internal/nextcloud/public_share.go +++ b/internal/nextcloud/public_share.go @@ -347,6 +347,18 @@ func (c *Client) PublicShareFileRevision(ctx context.Context, token, filePath, p } filePath = NormalizeClientPath(filePath) + rev, err := c.publicShareFileRevisionAt(ctx, token, filePath, password) + if err == nil { + return rev, nil + } + var statusErr *HTTPStatusError + if errors.As(err, &statusErr) && statusErr.StatusCode == http.StatusNotFound && filePath != "/" { + return c.publicShareFileRevisionAt(ctx, token, "/", password) + } + return FileRevision{}, err +} + +func (c *Client) publicShareFileRevisionAt(ctx context.Context, token, filePath, password string) (FileRevision, error) { resp, err := c.publicShareRequest(ctx, "PROPFIND", token, filePath, strings.NewReader(propfindPublicRevisionBody), password, map[string]string{ "Depth": "0", "Content-Type": "application/xml", diff --git a/internal/nextcloud/public_share_binding.go b/internal/nextcloud/public_share_binding.go new file mode 100644 index 0000000..2c2d556 --- /dev/null +++ b/internal/nextcloud/public_share_binding.go @@ -0,0 +1,122 @@ +package nextcloud + +import ( + "context" + "fmt" + "path" + "strings" +) + +// PublicShareBinding maps a public link token to owner storage paths. +type PublicShareBinding struct { + Token string + OwnerID string + SharePath string // OCS path, e.g. /Documents/hello.docx or /Documents + ItemType string // file | folder +} + +func (b *PublicShareBinding) IsSingleFile() bool { + return strings.EqualFold(strings.TrimSpace(b.ItemType), "file") +} + +// ClientSourcePath resolves the logical client path for a shared item. +// Single-file shares expose WebDAV at "/" but editors use /filename. +func (b *PublicShareBinding) ClientSourcePath(clientPath, displayName string) string { + clientPath = NormalizeClientPath(clientPath) + if clientPath != "/" { + return clientPath + } + if b != nil && b.IsSingleFile() && b.SharePath != "" { + return NormalizeClientPath("/" + path.Base(strings.TrimPrefix(b.SharePath, "/"))) + } + if name := strings.TrimSpace(displayName); name != "" { + return NormalizeClientPath("/" + name) + } + return "/" +} + +// OwnerPathForClient maps a client-facing path to the owner's Nextcloud path. +func (b *PublicShareBinding) OwnerPathForClient(clientPath, displayName string) string { + if b == nil { + return NormalizeClientPath(clientPath) + } + src := b.ClientSourcePath(clientPath, displayName) + rel := strings.TrimPrefix(src, "/") + sharePath := NormalizeClientPath(b.SharePath) + if b.IsSingleFile() { + dir := path.Dir(strings.TrimPrefix(sharePath, "/")) + if dir == "." || dir == "" { + return NormalizeClientPath("/" + rel) + } + return NormalizeClientPath(path.Join("/", dir, rel)) + } + if sharePath == "/" || sharePath == "" { + return NormalizeClientPath("/" + rel) + } + return NormalizeClientPath(path.Join(sharePath, rel)) +} + +func normalizeOCSSharePath(p string) string { + p = strings.TrimSpace(p) + if p == "" { + return "/" + } + if !strings.HasPrefix(p, "/") { + p = "/" + p + } + return NormalizeClientPath(p) +} + +// ResolvePublicShareBinding resolves owner + OCS share metadata for a public token. +func (c *Client) ResolvePublicShareBinding(ctx context.Context, token, password string) (*PublicShareBinding, error) { + token = strings.TrimSpace(token) + if token == "" { + return nil, ErrInvalidPublicShare + } + ownerID, err := c.getPublicShareOwnerID(ctx, token, password) + if err != nil { + return nil, err + } + ownerID = strings.TrimSpace(ownerID) + if ownerID == "" { + return nil, fmt.Errorf("public share owner not found") + } + + shares, err := c.ListShares(ctx, ownerID, "") + if err == nil { + for _, sh := range shares { + if strings.TrimSpace(sh.Token) != token { + continue + } + itemType := strings.TrimSpace(sh.ItemType) + if itemType == "" { + itemType = "folder" + } + return &PublicShareBinding{ + Token: token, + OwnerID: ownerID, + SharePath: normalizeOCSSharePath(sh.Path), + ItemType: itemType, + }, nil + } + } + + view, err := c.GetPublicShare(ctx, token, "/", password) + if err != nil { + return nil, fmt.Errorf("public share token not found: %w", err) + } + itemType := strings.TrimSpace(view.ItemType) + if itemType == "" { + itemType = "folder" + } + sharePath := "/" + if itemType == "file" && strings.TrimSpace(view.Name) != "" { + sharePath = normalizeOCSSharePath("/" + view.Name) + } + return &PublicShareBinding{ + Token: token, + OwnerID: ownerID, + SharePath: sharePath, + ItemType: itemType, + }, nil +} diff --git a/internal/nextcloud/public_share_binding_test.go b/internal/nextcloud/public_share_binding_test.go new file mode 100644 index 0000000..4e7e009 --- /dev/null +++ b/internal/nextcloud/public_share_binding_test.go @@ -0,0 +1,39 @@ +package nextcloud + +import "testing" + +func TestPublicShareBindingClientSourcePath(t *testing.T) { + b := &PublicShareBinding{ + SharePath: "/Documents/hello.docx", + ItemType: "file", + } + if got := b.ClientSourcePath("/", ""); got != "/hello.docx" { + t.Fatalf("single file empty display: got %q", got) + } + if got := b.ClientSourcePath("/hello.docx", ""); got != "/hello.docx" { + t.Fatalf("explicit path: got %q", got) + } + + folder := &PublicShareBinding{SharePath: "/Documents", ItemType: "folder"} + if got := folder.ClientSourcePath("/", "notes.txt"); got != "/notes.txt" { + t.Fatalf("folder root display: got %q", got) + } +} + +func TestPublicShareBindingOwnerPathForClient(t *testing.T) { + b := &PublicShareBinding{ + SharePath: "/Documents/hello.docx", + ItemType: "file", + } + if got := b.OwnerPathForClient("/", ""); got != "/Documents/hello.docx" { + t.Fatalf("source owner path: got %q", got) + } + if got := b.OwnerPathForClient("/hello.ultidoc.json", ""); got != "/Documents/hello.ultidoc.json" { + t.Fatalf("sidecar owner path: got %q", got) + } + + folder := &PublicShareBinding{SharePath: "/Documents", ItemType: "folder"} + if got := folder.OwnerPathForClient("/hello.docx", ""); got != "/Documents/hello.docx" { + t.Fatalf("folder file owner path: got %q", got) + } +} diff --git a/internal/server/bootstrap.go b/internal/server/bootstrap.go index e47e818..250ceb1 100644 --- a/internal/server/bootstrap.go +++ b/internal/server/bootstrap.go @@ -26,6 +26,7 @@ import ( meetapi "github.com/ultisuite/ulti-backend/internal/api/meet" "github.com/ultisuite/ulti-backend/internal/api/middleware" "github.com/ultisuite/ulti-backend/internal/api/office" + "github.com/ultisuite/ulti-backend/internal/api/richtext" photosapi "github.com/ultisuite/ulti-backend/internal/api/photos" usersapi "github.com/ultisuite/ulti-backend/internal/api/users" "github.com/ultisuite/ulti-backend/internal/automation" @@ -287,6 +288,19 @@ func New(ctx context.Context, cfg *config.Config, opts Options) (*App, error) { r.Mount("/api/v1/office", officeHandler.Routes(middleware.Auth(verifierHolder, pool, auditLogger, orgPolicyLoader))) driveHandler.SetPublicOffice(officeHandler) } + if ncClient != nil && cfg.RichTextEnabled && driveSvc != nil { + rtSvc := richtext.NewService(ncClient, richtext.Config{ + Enabled: true, + HocuspocusPublicURL: cfg.HocuspocusPublicURL, + HocuspocusSecret: cfg.HocuspocusSecret, + APIInternalURL: cfg.OnlyOfficeAPIInternalURL, + StorageMode: cfg.RichTextStorageMode, + ExportMirrorFormat: cfg.RichTextExportMirror, + }, driveSvc) + rtHandler := richtext.NewHandler(rtSvc, driveSvc) + r.Mount("/api/v1/richtext", rtHandler.Routes(middleware.Auth(verifierHolder, pool, auditLogger, orgPolicyLoader))) + driveHandler.SetPublicRichText(rtHandler) + } if driveHandler != nil { r.Mount("/api/v1/drive/public", driveHandler.PublicRoutes()) }