From 20c4fef3c642945faebe4660187bddb276e7263a Mon Sep 17 00:00:00 2001 From: R3D347HR4Y Date: Wed, 10 Jun 2026 00:27:21 +0200 Subject: [PATCH] docxi import lol --- .env.example | 6 +- README.md | 2 +- deploy/authentik/blueprints/ulti-oidc.yaml | 4 +- deploy/docker-compose.yml | 2 +- deploy/nginx/default.conf.template | 4 +- internal/api/mail/handlers_account_oauth.go | 2 +- internal/api/richtext/document.go | 168 +++++++++++++++++++- internal/api/richtext/document_test.go | 25 +++ internal/api/richtext/handlers.go | 75 ++++++++- internal/api/richtext/public_handlers.go | 21 ++- internal/api/richtext/public_share.go | 52 +++++- internal/api/richtext/service.go | 78 +++++++-- internal/authentik/catalog.go | 8 +- internal/config/config.go | 2 +- internal/integrationtest/harness.go | 2 +- services/hocuspocus/package.json | 10 ++ services/hocuspocus/pnpm-lock.yaml | 104 ++++++++++++ services/hocuspocus/server.mjs | 46 +++++- 18 files changed, 566 insertions(+), 45 deletions(-) create mode 100644 internal/api/richtext/document_test.go diff --git a/.env.example b/.env.example index 8135b94..5da0d44 100644 --- a/.env.example +++ b/.env.example @@ -36,7 +36,7 @@ DOMAIN=localhost ULTID_PORT=8080 # Origines navigateur autorisees (web app sur autre port/origine que l'API). # Vide = auto : localhost/127.0.0.1/LAN prive en dev ; http(s)://${DOMAIN} en prod. -# Exemple dev explicite : ULTID_CORS_ALLOWED_ORIGINS=http://localhost:3000,http://127.0.0.1:3000 +# Exemple dev explicite : ULTID_CORS_ALLOWED_ORIGINS=http://localhost:3004,http://127.0.0.1:3004 # ULTID_CORS_ALLOWED_ORIGINS= # ----------------------------------------------------------------------------- @@ -245,8 +245,8 @@ MAIL_MICROSOFT_OAUTH_CLIENT_SECRET= MAIL_MICROSOFT_OAUTH_TENANT=common MAIL_OAUTH_REDIRECT_URL= MAIL_APP_URL=http://localhost/mail -# Cible nginx → suite frontend unifié mail+drive (dev: Next sur l'hôte ; prod: suite-frontend:3000) -MAIL_FRONTEND_UPSTREAM=host.docker.internal:3000 +# Cible nginx → suite frontend unifié mail+drive (dev: Next sur l'hôte :3004 ; prod: suite-frontend:3000) +MAIL_FRONTEND_UPSTREAM=host.docker.internal:3004 MAIL_WEBHOOK_SHARED_SECRET_ROTATED_AT=2026-01-01T00:00:00Z # ----------------------------------------------------------------------------- diff --git a/README.md b/README.md index 4cc3196..87c1079 100644 --- a/README.md +++ b/README.md @@ -104,7 +104,7 @@ Un seul **nginx** expose l’entrée HTTP (`:80`) et route : | `/auth/*` | Authentik | | `/meet/*` | Jitsi (si `JITSI_ENABLED=true`) | | `/cloud/*` | Nextcloud nginx+FPM (si `NEXTCLOUD_ENABLED=true`) | -| `/mail/*`, `/drive/*`, `/contacts`, `/admin/*` | Suite frontend (`MAIL_FRONTEND_UPSTREAM`, défaut `host.docker.internal:3000` ; Docker : `suite-frontend:3000`) | +| `/mail/*`, `/drive/*`, `/contacts`, `/admin/*` | Suite frontend (`MAIL_FRONTEND_UPSTREAM`, défaut `host.docker.internal:3004` ; Docker : `suite-frontend:3000`) | Nextcloud : FPM + nginx dédié ; ultid appelle `NEXTCLOUD_URL` en interne (`http://nextcloud:80`). Caddy retiré : un seul proxy évite la double couche ; TLS plus tard (certbot, Traefik, ou `listen 443` nginx). diff --git a/deploy/authentik/blueprints/ulti-oidc.yaml b/deploy/authentik/blueprints/ulti-oidc.yaml index a5ac725..fb725ec 100644 --- a/deploy/authentik/blueprints/ulti-oidc.yaml +++ b/deploy/authentik/blueprints/ulti-oidc.yaml @@ -30,9 +30,9 @@ entries: - matching_mode: strict url: http://127.0.0.1/api/auth/callback - matching_mode: strict - url: http://localhost:3000/api/auth/callback + url: http://localhost:3004/api/auth/callback - matching_mode: strict - url: http://127.0.0.1:3000/api/auth/callback + url: http://127.0.0.1:3004/api/auth/callback signing_key: !Find [authentik_crypto.certificatekeypair, [name, authentik Self-signed Certificate]] - model: authentik_core.application diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index ec2395b..3325e1b 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -8,7 +8,7 @@ services: - ./nginx/default.conf.template:/etc/nginx/templates/default.conf.template:ro environment: DOMAIN: ${DOMAIN:-localhost} - MAIL_FRONTEND_UPSTREAM: ${MAIL_FRONTEND_UPSTREAM:-host.docker.internal:3000} + MAIL_FRONTEND_UPSTREAM: ${MAIL_FRONTEND_UPSTREAM:-host.docker.internal:3004} env_file: ../.env.resolved extra_hosts: - "host.docker.internal:host-gateway" diff --git a/deploy/nginx/default.conf.template b/deploy/nginx/default.conf.template index bd4be35..61686f9 100644 --- a/deploy/nginx/default.conf.template +++ b/deploy/nginx/default.conf.template @@ -6,7 +6,7 @@ map $http_upgrade $connection_upgrade { '' close; } -# Reflect browser Origin for cross-origin API calls (web app on :3000, API on :80). +# Reflect browser Origin for cross-origin API calls (web app on :3004, API on :80). map $http_origin $cors_allow_origin { default $http_origin; '' '*'; @@ -202,7 +202,7 @@ server { proxy_set_header Connection $connection_upgrade; } - # Ulti Suite frontend (mail + drive + contacts) — dev: pnpm dev on host (MAIL_FRONTEND_UPSTREAM=host.docker.internal:3000) + # Ulti Suite frontend (mail + drive + contacts) — dev: pnpm dev on host (MAIL_FRONTEND_UPSTREAM=host.docker.internal:3004) # Prod: set MAIL_FRONTEND_UPSTREAM=suite-frontend:3000 location ^~ /api/auth/ { resolver 127.0.0.11 valid=10s ipv6=off; diff --git a/internal/api/mail/handlers_account_oauth.go b/internal/api/mail/handlers_account_oauth.go index d367dfa..96bd97a 100644 --- a/internal/api/mail/handlers_account_oauth.go +++ b/internal/api/mail/handlers_account_oauth.go @@ -186,7 +186,7 @@ func (h *Handler) oauthPendingFromRequest(req *oauthStartRequest) mailoauth.Pend func (h *Handler) oauthReturnURL(status, code string) string { base := strings.TrimRight(h.appURL, "/") if base == "" { - base = "http://localhost:3000" + base = "http://localhost:3004" } u := base + "/mail/settings/accounts?oauth=" + status if code != "" { diff --git a/internal/api/richtext/document.go b/internal/api/richtext/document.go index e51c9b4..bef4867 100644 --- a/internal/api/richtext/document.go +++ b/internal/api/richtext/document.go @@ -11,12 +11,13 @@ 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"` + SchemaVersion int `json:"schemaVersion"` + Editor string `json:"editor"` + Source *UltiDocSource `json:"source,omitempty"` + Content json.RawMessage `json:"content"` + PageSetup *UltiDocPageSetup `json:"pageSetup,omitempty"` + YjsState string `json:"yjsState,omitempty"` + UpdatedAt string `json:"updatedAt,omitempty"` } type UltiDocSource struct { @@ -25,10 +26,96 @@ type UltiDocSource struct { ImportedAt string `json:"importedAt,omitempty"` } +type UltiDocPageMargins struct { + Top float64 `json:"top"` + Right float64 `json:"right"` + Bottom float64 `json:"bottom"` + Left float64 `json:"left"` +} + +type UltiDocPageFillImage struct { + Src string `json:"src"` + Mode string `json:"mode"` +} + +type UltiDocPageWatermark struct { + Kind string `json:"kind"` + Text string `json:"text,omitempty"` + Src string `json:"src,omitempty"` + Color string `json:"color,omitempty"` + Opacity float64 `json:"opacity,omitempty"` + RotationDeg float64 `json:"rotationDeg,omitempty"` +} + +type UltiDocPageBackground struct { + FillImage *UltiDocPageFillImage `json:"fillImage,omitempty"` + Watermark *UltiDocPageWatermark `json:"watermark,omitempty"` + GradientCss string `json:"gradientCss,omitempty"` +} + +type UltiDocPageSetup struct { + WidthMm float64 `json:"widthMm"` + HeightMm float64 `json:"heightMm"` + MarginsMm UltiDocPageMargins `json:"marginsMm"` + FormatID string `json:"formatId,omitempty"` + Orientation string `json:"orientation,omitempty"` + PageColor string `json:"pageColor,omitempty"` + PageBackground *UltiDocPageBackground `json:"pageBackground,omitempty"` + Borders *UltiDocPageBorders `json:"borders,omitempty"` +} + +type UltiDocPageBorderSide struct { + Style string `json:"style"` + WidthPx float64 `json:"widthPx"` + Color string `json:"color"` +} + +type UltiDocPageBorders struct { + Top UltiDocPageBorderSide `json:"top"` + Right UltiDocPageBorderSide `json:"right"` + Bottom UltiDocPageBorderSide `json:"bottom"` + Left UltiDocPageBorderSide `json:"left"` + OffsetFrom string `json:"offsetFrom,omitempty"` +} + func emptyDocContent() json.RawMessage { return json.RawMessage(`{"type":"doc","content":[{"type":"paragraph"}]}`) } +func isEmptyDocContent(content json.RawMessage) bool { + return !docContentHasText(content) +} + +func docContentHasText(content json.RawMessage) bool { + if len(content) == 0 { + return false + } + var root any + if err := json.Unmarshal(content, &root); err != nil { + return false + } + return tipTapNodeHasText(root) +} + +func tipTapNodeHasText(v any) bool { + switch node := v.(type) { + case map[string]any: + if text, ok := node["text"].(string); ok && strings.TrimSpace(text) != "" { + return true + } + if children, ok := node["content"]; ok { + return tipTapNodeHasText(children) + } + case []any: + for _, child := range node { + if tipTapNodeHasText(child) { + return true + } + } + } + return false +} + func NewUltiDoc(content json.RawMessage, source *UltiDocSource) UltiDoc { if len(content) == 0 { content = emptyDocContent() @@ -42,6 +129,75 @@ func NewUltiDoc(content json.RawMessage, source *UltiDocSource) UltiDoc { } } +func preserveUltiDocMetadata(dst *UltiDoc, existing UltiDoc) { + if dst.Source == nil && existing.Source != nil { + dst.Source = existing.Source + } + if dst.PageSetup == nil && existing.PageSetup != nil { + dst.PageSetup = existing.PageSetup + } + if dst.PageSetup != nil && existing.PageSetup != nil { + if dst.PageSetup.PageBackground == nil && existing.PageSetup.PageBackground != nil { + dst.PageSetup.PageBackground = existing.PageSetup.PageBackground + } + } + if len(dst.Content) == 0 || isEmptyDocContent(dst.Content) { + if len(existing.Content) > 0 && !isEmptyDocContent(existing.Content) { + dst.Content = existing.Content + } + } + if dst.YjsState == "" && existing.YjsState != "" { + dst.YjsState = existing.YjsState + } +} + +type ultiDocPatch struct { + SchemaVersion int `json:"schemaVersion"` + Editor string `json:"editor"` + Content json.RawMessage `json:"content"` + Document json.RawMessage `json:"document"` + PageSetup *UltiDocPageSetup `json:"pageSetup"` + YjsState string `json:"yjsState"` +} + +// ApplyUltiDocPatch merges a partial JSON payload into an existing UltiDoc. +func ApplyUltiDocPatch(existing UltiDoc, raw json.RawMessage) (UltiDoc, error) { + var patch ultiDocPatch + if err := json.Unmarshal(raw, &patch); err != nil { + return UltiDoc{}, err + } + + doc := existing + if doc.SchemaVersion == 0 { + doc = NewUltiDoc(nil, nil) + if len(existing.Content) > 0 { + doc.Content = existing.Content + } + } + + content := patch.Content + if len(content) == 0 && len(patch.Document) > 0 { + content = patch.Document + } + if len(content) > 0 { + doc.Content = content + } + if patch.PageSetup != nil { + doc.PageSetup = patch.PageSetup + } + if patch.YjsState != "" { + doc.YjsState = patch.YjsState + } + if patch.SchemaVersion > 0 { + doc.SchemaVersion = patch.SchemaVersion + } + if patch.Editor != "" { + doc.Editor = patch.Editor + } + preserveUltiDocMetadata(&doc, existing) + return doc, nil +} + func ParseUltiDoc(raw []byte) (UltiDoc, error) { var doc UltiDoc if err := json.Unmarshal(raw, &doc); err != nil { diff --git a/internal/api/richtext/document_test.go b/internal/api/richtext/document_test.go new file mode 100644 index 0000000..ac5bd0d --- /dev/null +++ b/internal/api/richtext/document_test.go @@ -0,0 +1,25 @@ +package richtext + +import "testing" + +func TestIsEmptyDocContent(t *testing.T) { + tests := []struct { + name string + content string + empty bool + }{ + {"blank", "", true}, + {"empty paragraph", `{"type":"doc","content":[{"type":"paragraph"}]}`, true}, + {"whitespace text", `{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":" "}]}]}`, true}, + {"has text", `{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Hello"}]}]}`, false}, + {"heading text", `{"type":"doc","content":[{"type":"heading","attrs":{"level":1},"content":[{"type":"text","text":"Title"}]}]}`, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := isEmptyDocContent([]byte(tt.content)) + if got != tt.empty { + t.Fatalf("isEmptyDocContent() = %v, want %v", got, tt.empty) + } + }) + } +} diff --git a/internal/api/richtext/handlers.go b/internal/api/richtext/handlers.go index 6487975..aff0abe 100644 --- a/internal/api/richtext/handlers.go +++ b/internal/api/richtext/handlers.go @@ -132,9 +132,10 @@ func (h *Handler) Export(w http.ResponseWriter, r *http.Request) { } type saveRequest struct { - Path string `json:"path"` - Document json.RawMessage `json:"document"` - YjsState string `json:"yjsState,omitempty"` + Path string `json:"path"` + Document json.RawMessage `json:"document"` + PageSetup json.RawMessage `json:"pageSetup,omitempty"` + YjsState string `json:"yjsState,omitempty"` } func (h *Handler) Save(w http.ResponseWriter, r *http.Request) { @@ -148,8 +149,42 @@ func (h *Handler) Save(w http.ResponseWriter, r *http.Request) { if err := apivalidate.DecodeJSON(w, r, 32<<20, &req); err != nil { return } - doc := NewUltiDoc(req.Document, nil) - doc.YjsState = req.YjsState + path := normalizePath(req.Path) + existingRaw, _ := h.svc.LoadDocument(r.Context(), ncUser, path) + var existing UltiDoc + if len(existingRaw) > 0 { + if parsed, err := ParseUltiDoc(existingRaw); err == nil { + existing = parsed + } + } + + var doc UltiDoc + switch { + case len(req.Document) > 0: + doc = NewUltiDoc(req.Document, nil) + case len(req.PageSetup) > 0: + if existing.SchemaVersion > 0 { + doc = existing + } else { + doc = NewUltiDoc(nil, nil) + } + default: + apivalidate.WriteValidationError(w, r, apivalidate.NewValidationError( + apivalidate.FieldDetail{Field: "document", Message: "document or pageSetup required"}, + )) + return + } + + 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 + } + preserveUltiDocMetadata(&doc, existing) payload, err := doc.Marshal() if err != nil { apivalidate.WriteInternal(w, r) @@ -197,7 +232,26 @@ func (h *Handler) PutDocument(w http.ResponseWriter, r *http.Request) { apivalidate.WriteInternal(w, r) return } - if err := h.svc.SaveDocument(r.Context(), user, path, raw, platformUser); err != nil { + existingRaw, _ := h.svc.LoadDocumentForUser(r.Context(), user, path) + var existing UltiDoc + if len(existingRaw) > 0 { + if parsed, parseErr := ParseUltiDoc(existingRaw); parseErr == nil { + existing = parsed + } + } + doc, err := ApplyUltiDocPatch(existing, raw) + if err != nil { + apivalidate.WriteValidationError(w, r, apivalidate.NewValidationError( + apivalidate.FieldDetail{Field: "document", Message: "invalid JSON"}, + )) + return + } + payload, err := doc.Marshal() + if err != nil { + apivalidate.WriteInternal(w, r) + return + } + if err := h.svc.SaveDocument(r.Context(), user, path, payload, platformUser); err != nil { http.Error(w, "save failed", http.StatusInternalServerError) return } @@ -241,10 +295,18 @@ func (h *Handler) HookStore(w http.ResponseWriter, r *http.Request) { return } path := normalizePath(payload.Path) + existingRaw, _ := h.svc.LoadDocumentForUser(r.Context(), payload.User, path) + var existing UltiDoc + if len(existingRaw) > 0 { + if parsed, err := ParseUltiDoc(existingRaw); err == nil { + existing = parsed + } + } var raw []byte if len(payload.Document) > 0 { doc := NewUltiDoc(payload.Document, nil) doc.YjsState = payload.YjsState + preserveUltiDocMetadata(&doc, existing) var err error raw, err = doc.Marshal() if err != nil { @@ -253,6 +315,7 @@ func (h *Handler) HookStore(w http.ResponseWriter, r *http.Request) { } } else if payload.YjsState != "" { doc := UltiDoc{SchemaVersion: schemaVersion, Editor: "tiptap", YjsState: payload.YjsState, Content: emptyDocContent()} + preserveUltiDocMetadata(&doc, existing) var err error raw, err = doc.Marshal() if err != nil { diff --git a/internal/api/richtext/public_handlers.go b/internal/api/richtext/public_handlers.go index 8e6d8e1..b6ff39d 100644 --- a/internal/api/richtext/public_handlers.go +++ b/internal/api/richtext/public_handlers.go @@ -160,7 +160,26 @@ func (h *Handler) PublicSharePutDocument(w http.ResponseWriter, r *http.Request) apivalidate.WriteInternal(w, r) return } - if err := h.svc.SavePublicDocumentLegacy(r.Context(), token, path, password, raw); err != nil { + existingRaw, loadErr := h.svc.LoadPublicDocumentLegacy(r.Context(), token, path, password) + var existing UltiDoc + if loadErr == nil && len(existingRaw) > 0 { + if parsed, parseErr := ParseUltiDoc(existingRaw); parseErr == nil { + existing = parsed + } + } + doc, err := ApplyUltiDocPatch(existing, raw) + if err != nil { + apivalidate.WriteValidationError(w, r, apivalidate.NewValidationError( + apivalidate.FieldDetail{Field: "document", Message: "invalid JSON"}, + )) + return + } + payload, err := doc.Marshal() + if err != nil { + apivalidate.WriteInternal(w, r) + return + } + if err := h.svc.SavePublicDocumentLegacy(r.Context(), token, path, password, payload); err != nil { http.Error(w, "save failed", http.StatusInternalServerError) return } diff --git a/internal/api/richtext/public_share.go b/internal/api/richtext/public_share.go index c73ebc4..e0c9fa8 100644 --- a/internal/api/richtext/public_share.go +++ b/internal/api/richtext/public_share.go @@ -56,6 +56,8 @@ func (s *Service) CreatePublicSession(ctx context.Context, token, filePath, mode wsURL := strings.TrimSpace(s.Cfg.HocuspocusPublicURL) collab := wsURL != "" && s.Cfg.HocuspocusSecret != "" + pageSetup, _ := s.loadPublicPageSetupFromSidecar(ctx, token, password, canonical, displayName) + return &PublicSessionResult{ SessionResult: SessionResult{ RoomID: roomID, @@ -66,6 +68,7 @@ func (s *Service) CreatePublicSession(ctx context.Context, token, filePath, mode Mode: mode, ImportRequired: importRequired, Collaboration: collab, + PageSetup: pageSetup, }, DocumentURL: docURL, SaveURL: saveURL, @@ -89,7 +92,8 @@ func (s *Service) resolvePublicCanonicalPath(ctx context.Context, token, filePat sidecar := sidecarPathForSource(source) if s.publicSidecarExists(ctx, token, password, sidecar, displayName) { - return sidecar, source, false, nil + needsImport := s.publicSidecarImportRequired(ctx, token, password, sidecar, source, displayName) + return sidecar, source, needsImport, nil } if _, err := s.publicFileExists(ctx, token, source, password); err != nil { @@ -99,6 +103,34 @@ func (s *Service) resolvePublicCanonicalPath(ctx context.Context, token, filePat return sidecar, source, true, nil } +func (s *Service) publicSidecarImportRequired(ctx context.Context, token, password, sidecarPath, sourcePath, displayName string) bool { + body, err := s.LoadPublicDocument(ctx, token, sidecarPath, password, displayName) + if err != nil { + return true + } + doc, err := ParseUltiDoc(body) + if err != nil { + return true + } + if !isEmptyDocContent(doc.Content) { + return false + } + 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 + } + exists, err := s.publicFileExists(ctx, token, src, password) + if err == nil && exists { + return true + } + } + return false +} + func (s *Service) publicClientSourcePath(ctx context.Context, token, password, clientPath, displayName string) string { binding, err := s.nc.ResolvePublicShareBinding(ctx, token, password) if err == nil { @@ -153,6 +185,12 @@ func (s *Service) ImportPublicDocument(ctx context.Context, token, password, dis 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 + } + } payload, err := doc.Marshal() if err != nil { return "", err @@ -163,6 +201,18 @@ func (s *Service) ImportPublicDocument(ctx context.Context, token, password, dis return canonical, nil } +func (s *Service) loadPublicPageSetupFromSidecar(ctx context.Context, token, password, canonicalPath, displayName string) (json.RawMessage, error) { + body, err := s.LoadPublicDocument(ctx, token, canonicalPath, password, displayName) + 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) publicFileExists(ctx context.Context, token, path, password string) (bool, error) { _, err := s.nc.PublicShareFileRevision(ctx, token, path, password) if err != nil { diff --git a/internal/api/richtext/service.go b/internal/api/richtext/service.go index 5f1f389..12c4f6c 100644 --- a/internal/api/richtext/service.go +++ b/internal/api/richtext/service.go @@ -34,14 +34,15 @@ func (s *Service) EnsureNextcloudUser(ctx context.Context, claims *auth.Claims) } 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"` + 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) { @@ -79,6 +80,8 @@ func (s *Service) CreateSession(ctx context.Context, ncUser, filePath, mode, edi wsURL := strings.TrimSpace(s.Cfg.HocuspocusPublicURL) collab := wsURL != "" && s.Cfg.HocuspocusSecret != "" + pageSetup, _ := s.loadPageSetupFromSidecar(ctx, ncUser, canonical) + return &SessionResult{ RoomID: roomID, CanonicalPath: canonical, @@ -88,6 +91,7 @@ func (s *Service) CreateSession(ctx context.Context, ncUser, filePath, mode, edi Mode: mode, ImportRequired: importRequired, Collaboration: collab, + PageSetup: pageSetup, }, nil } @@ -100,7 +104,11 @@ func (s *Service) resolveCanonicalPath(ctx context.Context, ncUser, filePath str if s.Cfg.StorageMode == "overwrite" { target := sidecarPathForSource(filePath) if _, err := s.nc.FileRevision(ctx, ncUser, target); err == nil { - return target, filePath, false, 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 { @@ -110,7 +118,11 @@ func (s *Service) resolveCanonicalPath(ctx context.Context, ncUser, filePath str } if _, err := s.nc.FileRevision(ctx, ncUser, sidecar); err == nil { - return sidecar, filePath, false, 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) @@ -118,6 +130,45 @@ func (s *Service) resolveCanonicalPath(ctx context.Context, ncUser, filePath str 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) @@ -156,6 +207,7 @@ func (s *Service) LoadDocumentForUser(ctx context.Context, ncUser, path string) 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"` } @@ -194,6 +246,12 @@ func (s *Service) ImportDocument(ctx context.Context, ncUser, platformUserID str 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 } diff --git a/internal/authentik/catalog.go b/internal/authentik/catalog.go index c6e7651..a4d63dc 100644 --- a/internal/authentik/catalog.go +++ b/internal/authentik/catalog.go @@ -103,8 +103,8 @@ func ultimailRedirectURIs(cfg *config.Config) []string { mail := strings.TrimRight(cfg.MailAppURL, "/") return uniqueURIs( mail+"/api/auth/callback", - "http://localhost:3000/api/auth/callback", - "http://127.0.0.1:3000/api/auth/callback", + "http://localhost:3004/api/auth/callback", + "http://127.0.0.1:3004/api/auth/callback", base+"/api/auth/callback", ) } @@ -143,8 +143,8 @@ func driveRedirectURIs(cfg *config.Config) []string { base := baseURL(cfg) return uniqueURIs( base+"/api/auth/callback", - "http://localhost:3000/api/auth/callback", - "http://127.0.0.1:3000/api/auth/callback", + "http://localhost:3004/api/auth/callback", + "http://127.0.0.1:3004/api/auth/callback", ) } diff --git a/internal/config/config.go b/internal/config/config.go index ac302f0..702d5bd 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -221,7 +221,7 @@ func Load() (*Config, error) { MailMicrosoftOAuthSecret: secrets.Env("MAIL_MICROSOFT_OAUTH_CLIENT_SECRET"), MailMicrosoftOAuthTenant: envOrDefault("MAIL_MICROSOFT_OAUTH_TENANT", "common"), MailOAuthRedirectURL: os.Getenv("MAIL_OAUTH_REDIRECT_URL"), - MailAppURL: envOrDefault("MAIL_APP_URL", envOrDefault("NEXT_PUBLIC_APP_URL", "http://localhost:3000")), + MailAppURL: envOrDefault("MAIL_APP_URL", envOrDefault("NEXT_PUBLIC_APP_URL", "http://localhost:3004")), SecretRotationMaxAge: envDuration("SECRET_ROTATION_MAX_AGE", 90*24*time.Hour), OIDCSecretRotatedAt: envTime("ULTID_OIDC_CLIENT_SECRET_ROTATED_AT"), diff --git a/internal/integrationtest/harness.go b/internal/integrationtest/harness.go index 9113a84..45ff005 100644 --- a/internal/integrationtest/harness.go +++ b/internal/integrationtest/harness.go @@ -192,7 +192,7 @@ func buildTestConfig(env Env, infra *infra, oidc *OIDCServer) *config.Config { MailCredentialKeys: "v1:MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=", MailActiveCredentialKeyID: "v1", MailWebhookSharedSecret: "test-webhook-secret", - MailAppURL: "http://localhost:3000", + MailAppURL: "http://localhost:3004", SearchEngine: "postgres", MeilisearchURL: env.MeilisearchURL, MeilisearchKey: env.MeilisearchKey, diff --git a/services/hocuspocus/package.json b/services/hocuspocus/package.json index 0c9887a..414ad42 100644 --- a/services/hocuspocus/package.json +++ b/services/hocuspocus/package.json @@ -10,6 +10,16 @@ "dependencies": { "@hocuspocus/server": "^4.1.0", "@hocuspocus/transformer": "^4.1.0", + "@tiptap/extension-highlight": "^3.23.2", + "@tiptap/extension-image": "^3.23.2", + "@tiptap/extension-link": "^3.23.2", + "@tiptap/extension-table": "^3.23.2", + "@tiptap/extension-table-cell": "^3.23.2", + "@tiptap/extension-table-header": "^3.23.2", + "@tiptap/extension-table-row": "^3.23.2", + "@tiptap/extension-text-align": "^3.23.2", + "@tiptap/extension-text-style": "^3.23.2", + "@tiptap/extension-underline": "^3.23.2", "@tiptap/starter-kit": "^3.23.2", "yjs": "^13.6.27" } diff --git a/services/hocuspocus/pnpm-lock.yaml b/services/hocuspocus/pnpm-lock.yaml index 96c9f61..159d0d4 100644 --- a/services/hocuspocus/pnpm-lock.yaml +++ b/services/hocuspocus/pnpm-lock.yaml @@ -14,6 +14,36 @@ importers: '@hocuspocus/transformer': specifier: ^4.1.0 version: 4.1.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0)(y-prosemirror@1.3.7(prosemirror-model@1.25.7)(prosemirror-state@1.4.4)(prosemirror-view@1.41.8)(y-protocols@1.0.7(yjs@13.6.31))(yjs@13.6.31))(yjs@13.6.31) + '@tiptap/extension-highlight': + specifier: ^3.23.2 + version: 3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0)) + '@tiptap/extension-image': + specifier: ^3.23.2 + version: 3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0)) + '@tiptap/extension-link': + specifier: ^3.23.2 + version: 3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0) + '@tiptap/extension-table': + specifier: ^3.23.2 + version: 3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0) + '@tiptap/extension-table-cell': + specifier: ^3.23.2 + version: 3.26.0(@tiptap/extension-table@3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0)) + '@tiptap/extension-table-header': + specifier: ^3.23.2 + version: 3.26.0(@tiptap/extension-table@3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0)) + '@tiptap/extension-table-row': + specifier: ^3.23.2 + version: 3.26.0(@tiptap/extension-table@3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0)) + '@tiptap/extension-text-align': + specifier: ^3.23.2 + version: 3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0)) + '@tiptap/extension-text-style': + specifier: ^3.23.2 + version: 3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0)) + '@tiptap/extension-underline': + specifier: ^3.23.2 + version: 3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0)) '@tiptap/starter-kit': specifier: ^3.23.2 version: 3.26.0 @@ -97,12 +127,22 @@ packages: peerDependencies: '@tiptap/core': 3.26.0 + '@tiptap/extension-highlight@3.26.0': + resolution: {integrity: sha512-/b6pImBTdgOc8UVMJiJNQ/QAtZ0Z4+2XyxmzoNIiOPGjQDy/IxLtci8+M2BYWRIRbgjCS9XllsfnKWVyO8pe+w==} + peerDependencies: + '@tiptap/core': 3.26.0 + '@tiptap/extension-horizontal-rule@3.26.0': resolution: {integrity: sha512-a+N/C4wkQV+/8x4ShdoiC2JdTW3Tw84C5cAloYLFMeaWmRa2me9ACSI+zo0SO9bbH9RJwsoRp7eaxBbk27eF1Q==} peerDependencies: '@tiptap/core': 3.26.0 '@tiptap/pm': 3.26.0 + '@tiptap/extension-image@3.26.0': + resolution: {integrity: sha512-vinrbKa9Awmlb/UPpnes1pezL+ZeUC2v7XczZyNbggvcHKhlVkuXZIKytFQDXEYOTaKYzYE8B/Gz098PiJ9NYQ==} + peerDependencies: + '@tiptap/core': 3.26.0 + '@tiptap/extension-italic@3.26.0': resolution: {integrity: sha512-s8oFpH+0xmhvY19f452/2dExO3p1tjxh761g6cg4irwEUNUEAJKF2VLcjiaeOhNJ+pmnQYxb+VSkwkXvO+7vHQ==} peerDependencies: @@ -145,6 +185,37 @@ packages: peerDependencies: '@tiptap/core': 3.26.0 + '@tiptap/extension-table-cell@3.26.0': + resolution: {integrity: sha512-RhsCSJXgC7AxltrAZ7ePya+OG6H8fDEnQsr/jhWuucdnkH2jXPnYnXhzgOoJifyHwxRS+Sx0FRQWGHDA3peGMA==} + peerDependencies: + '@tiptap/extension-table': 3.26.0 + + '@tiptap/extension-table-header@3.26.0': + resolution: {integrity: sha512-kQx5RSjPygmhgNnkZtoD2j5AQ1Vj2Pzzif/sm8I75MlWNqp7yIWhCOcK7cwL6JQqf8GovzjAeMVY+5SDAOuVxw==} + peerDependencies: + '@tiptap/extension-table': 3.26.0 + + '@tiptap/extension-table-row@3.26.0': + resolution: {integrity: sha512-uBlCG1QIhgBwwXQROqolDlMwv9f2Wy3dH/2o5mlNwSQPI0B1+hdLV1eVep3MKRDweJqlP7kIhNNJxCUZQO+WYA==} + peerDependencies: + '@tiptap/extension-table': 3.26.0 + + '@tiptap/extension-table@3.26.0': + resolution: {integrity: sha512-pLL1+tKeUWSF/w4se84tjWUnuraKVELQtIHwi1XKoq6vkevotwwMb99xY6cJ752FaUFVDbViFe/JUYWBoU+bIw==} + peerDependencies: + '@tiptap/core': 3.26.0 + '@tiptap/pm': 3.26.0 + + '@tiptap/extension-text-align@3.26.0': + resolution: {integrity: sha512-yEtgrEJyE7sfcIAzk9cmJUQUMQZ/J0RU3k7mE+hy5o7t8j78Zs+KcsH+hrczn0MNzblg4gfX2erN+1/SGfSlpA==} + peerDependencies: + '@tiptap/core': 3.26.0 + + '@tiptap/extension-text-style@3.26.0': + resolution: {integrity: sha512-4IeIdiubF5/Em9AodaLkCKUNwkQaK3KuwnneXS61x4Yoyr0zK21i3kZAFK7ils7jUwSrRdGdM1FglBnB4tK8nA==} + peerDependencies: + '@tiptap/core': 3.26.0 + '@tiptap/extension-text@3.26.0': resolution: {integrity: sha512-yZXdevp3/8omGbb40Z52VfvID+tsRNhPQ1GNUToD56XSr2BjdJyAzAb9rWGgDKgVMUPLgJ26yT0O278RFqOKhA==} peerDependencies: @@ -335,11 +406,19 @@ snapshots: dependencies: '@tiptap/core': 3.26.0(@tiptap/pm@3.26.0) + '@tiptap/extension-highlight@3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))': + dependencies: + '@tiptap/core': 3.26.0(@tiptap/pm@3.26.0) + '@tiptap/extension-horizontal-rule@3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0)': dependencies: '@tiptap/core': 3.26.0(@tiptap/pm@3.26.0) '@tiptap/pm': 3.26.0 + '@tiptap/extension-image@3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))': + dependencies: + '@tiptap/core': 3.26.0(@tiptap/pm@3.26.0) + '@tiptap/extension-italic@3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))': dependencies: '@tiptap/core': 3.26.0(@tiptap/pm@3.26.0) @@ -375,6 +454,31 @@ snapshots: dependencies: '@tiptap/core': 3.26.0(@tiptap/pm@3.26.0) + '@tiptap/extension-table-cell@3.26.0(@tiptap/extension-table@3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0))': + dependencies: + '@tiptap/extension-table': 3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0) + + '@tiptap/extension-table-header@3.26.0(@tiptap/extension-table@3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0))': + dependencies: + '@tiptap/extension-table': 3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0) + + '@tiptap/extension-table-row@3.26.0(@tiptap/extension-table@3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0))': + dependencies: + '@tiptap/extension-table': 3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0) + + '@tiptap/extension-table@3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0)': + dependencies: + '@tiptap/core': 3.26.0(@tiptap/pm@3.26.0) + '@tiptap/pm': 3.26.0 + + '@tiptap/extension-text-align@3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))': + dependencies: + '@tiptap/core': 3.26.0(@tiptap/pm@3.26.0) + + '@tiptap/extension-text-style@3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))': + dependencies: + '@tiptap/core': 3.26.0(@tiptap/pm@3.26.0) + '@tiptap/extension-text@3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))': dependencies: '@tiptap/core': 3.26.0(@tiptap/pm@3.26.0) diff --git a/services/hocuspocus/server.mjs b/services/hocuspocus/server.mjs index ea0ea74..51adeaf 100644 --- a/services/hocuspocus/server.mjs +++ b/services/hocuspocus/server.mjs @@ -6,6 +6,28 @@ import { createRequire } from "node:module" const require = createRequire(import.meta.url) const StarterKit = require("@tiptap/starter-kit").default +const Underline = require("@tiptap/extension-underline").default +const Link = require("@tiptap/extension-link").default +const { TextStyle } = require("@tiptap/extension-text-style") +const Highlight = require("@tiptap/extension-highlight").default +const TextAlign = require("@tiptap/extension-text-align").default +const { Table, TableRow, TableCell, TableHeader } = require("@tiptap/extension-table") +const Image = require("@tiptap/extension-image").default + +/** Match TipTap editor extensions so imported DOCX content survives Yjs seeding. */ +const transformerExtensions = [ + StarterKit.configure({ undoRedo: false }), + Underline, + Link.configure({ openOnClick: false }), + TextStyle, + Highlight.configure({ multicolor: true }), + TextAlign.configure({ types: ["heading", "paragraph"] }), + Table.configure({ resizable: true }), + TableRow, + TableCell, + TableHeader, + Image.configure({ inline: true, allowBase64: true }), +] const PORT = Number(process.env.HOCUSPOCUS_PORT || 1234) const SECRET = process.env.HOCUSPOCUS_SECRET || "" @@ -26,6 +48,19 @@ function hookContext(payload) { return payload?.lastContext ?? payload?.context ?? {} } +function tipTapContentHasText(content) { + if (!content || typeof content !== "object") return false + const walk = (node) => { + if (!node || typeof node !== "object") return false + if (typeof node.text === "string" && node.text.trim()) return true + if (Array.isArray(node.content)) { + return node.content.some(walk) + } + return false + } + return walk(content) +} + async function loadFromUltid(context) { if (!context?.path || !context?.user) return null const params = new URLSearchParams({ user: context.user, path: context.path }) @@ -38,13 +73,14 @@ async function loadFromUltid(context) { if (!raw.trim()) return null try { const doc = JSON.parse(raw) + const hasStoredText = tipTapContentHasText(doc.content) + if (doc.content && hasStoredText) { + const ydoc = TiptapTransformer.toYdoc(doc.content, "default", transformerExtensions) + return Buffer.from(Y.encodeStateAsUpdate(ydoc)) + } if (doc.yjsState) { return Buffer.from(doc.yjsState, "base64") } - if (doc.content) { - const ydoc = TiptapTransformer.toYdoc(doc.content, "default", [StarterKit]) - return Buffer.from(Y.encodeStateAsUpdate(ydoc)) - } } catch (err) { console.error("[onLoadDocument] parse", err) } @@ -58,7 +94,7 @@ async function storeToUltid(context, document) { const state = Buffer.from(Y.encodeStateAsUpdate(document)).toString("base64") let tiptapJson = null try { - tiptapJson = TiptapTransformer.fromYdoc(document, "default") + tiptapJson = TiptapTransformer.fromYdoc(document, "default", transformerExtensions) } catch { /* optional */ }