hocuspocus lol 2

This commit is contained in:
R3D347HR4Y 2026-06-09 14:30:34 +02:00
parent cf087e637e
commit f1dbea8db3
23 changed files with 1732 additions and 15 deletions

View File

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

View File

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

View File

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

View File

@ -25,9 +25,14 @@ import (
type Handler struct {
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 {
return NewHandlerWithService(NewService(nc, hub, db))
}
@ -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 {

View File

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

View File

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

View File

@ -30,6 +30,7 @@ type publicOfficeSessionRequest struct {
Mode string `json:"mode"`
Password string `json:"password"`
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)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 "<p></p>"
}
var b strings.Builder
b.WriteString("<html><body>")
for _, line := range strings.Split(text, "\n") {
b.WriteString("<p>")
b.WriteString(htmlEscape(line))
b.WriteString("</p>")
}
b.WriteString("</body></html>")
return b.String()
}
func htmlEscape(s string) string {
r := strings.NewReplacer("&", "&amp;", "<", "&lt;", ">", "&gt;", `"`, "&quot;")
return r.Replace(s)
}
func hashPath(p string) string {
h := sha256Hex([]byte(normalizePath(p)))
if len(h) > 16 {
return h[:16]
}
return h
}

View File

@ -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
@ -181,6 +188,11 @@ func Load() (*Config, error) {
OnlyOfficeJWTSecret: secrets.Env("ONLYOFFICE_JWT_SECRET"),
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"),

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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