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"` PageSetup json.RawMessage `json:"pageSetup,omitempty"` } 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 != "" pageSetup, _ := s.loadPageSetupFromSidecar(ctx, ncUser, canonical) return &SessionResult{ RoomID: roomID, CanonicalPath: canonical, SourcePath: source, WsURL: wsURL, Token: token, Mode: mode, ImportRequired: importRequired, Collaboration: collab, PageSetup: pageSetup, }, 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 { needsImport, err := s.sidecarImportRequired(ctx, ncUser, target, filePath) if err != nil { return "", "", false, err } return target, filePath, needsImport, 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 { needsImport, err := s.sidecarImportRequired(ctx, ncUser, sidecar, filePath) if err != nil { return "", "", false, err } return sidecar, filePath, needsImport, 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) sidecarImportRequired(ctx context.Context, ncUser, sidecarPath, sourcePath string) (bool, error) { body, err := s.LoadDocument(ctx, ncUser, sidecarPath) if err != nil { return true, nil } doc, err := ParseUltiDoc(body) if err != nil { return true, nil } if !isEmptyDocContent(doc.Content) { return false, nil } candidates := []string{sourcePath} if doc.Source != nil && doc.Source.Path != "" && doc.Source.Path != sourcePath { candidates = append(candidates, doc.Source.Path) } for _, src := range candidates { if src == "" { continue } if _, err := s.nc.FileRevision(ctx, ncUser, src); err == nil { return true, nil } } return false, nil } func (s *Service) loadPageSetupFromSidecar(ctx context.Context, ncUser, canonicalPath string) (json.RawMessage, error) { body, err := s.LoadDocument(ctx, ncUser, canonicalPath) if err != nil { return nil, err } doc, err := ParseUltiDoc(body) if err != nil || doc.PageSetup == nil { return nil, err } return json.Marshal(doc.PageSetup) } 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"` PageSetup json.RawMessage `json:"pageSetup,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 len(req.PageSetup) > 0 { var pageSetup UltiDocPageSetup if err := json.Unmarshal(req.PageSetup, &pageSetup); err == nil { doc.PageSetup = &pageSetup } } 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 }