ultisuite-backend/internal/api/richtext/service.go
R3D347HR4Y d4ccf7eb6e
Some checks are pending
CI / Go tests (push) Waiting to run
CI / Integration tests (push) Waiting to run
CI / DB migrations (push) Waiting to run
hocuspocus lol 2
2026-06-09 14:30:34 +02:00

327 lines
8.6 KiB
Go

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
}