327 lines
8.6 KiB
Go
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("&", "&", "<", "<", ">", ">", `"`, """)
|
|
return r.Replace(s)
|
|
}
|
|
|
|
func hashPath(p string) string {
|
|
h := sha256Hex([]byte(normalizePath(p)))
|
|
if len(h) > 16 {
|
|
return h[:16]
|
|
}
|
|
return h
|
|
}
|