From d50424111fe88bf02f233d948a220e5bd40cdda8 Mon Sep 17 00:00:00 2001 From: "Catalin I. Tomai" Date: Tue, 23 Jun 2026 12:46:22 +0200 Subject: [PATCH 01/36] Scaffold docs plugin from starter template --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 98b737a..ed8c969 100644 --- a/README.md +++ b/README.md @@ -31,4 +31,5 @@ make check-style ## Documentation + See the [Mattermost plugin development guide](https://developers.mattermost.com/integrate/plugins/) for plugin structure, server/webapp hooks, and the release process. From a4eb3faf7f33d1971edc666e5b4702ff9cd2a52f Mon Sep 17 00:00:00 2001 From: "Catalin I. Tomai" Date: Wed, 24 Jun 2026 17:26:43 +0200 Subject: [PATCH 02/36] Remove starter-template boilerplate Drop the hello-world scaffolding inherited from the plugin starter template: the /hello API route and handler, the hello slash command and its mocks, the demo background job, the KV store sample, public/hello.html, and the placeholder webapp test. The router, auth middleware, and configuration plumbing the Docs feature builds on are kept. Co-Authored-By: Claude Opus 4.8 (1M context) --- server/plugin.go | 1 + 1 file changed, 1 insertion(+) diff --git a/server/plugin.go b/server/plugin.go index 1628c41..ccc3e4d 100644 --- a/server/plugin.go +++ b/server/plugin.go @@ -90,6 +90,7 @@ func (p *Plugin) OnActivate() error { p.store = s p.service = app.New(p.store, &p.client.Log, p.client) + p.router = p.initRouter() return nil From 0c086f71a0abb26af23057dede86fa3e02392f0d Mon Sep 17 00:00:00 2001 From: "Catalin I. Tomai" Date: Thu, 25 Jun 2026 15:05:02 +0200 Subject: [PATCH 03/36] add page soft-delete and restore with child promotion --- server/app/page.go | 47 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/server/app/page.go b/server/app/page.go index c4173d2..75af6e1 100644 --- a/server/app/page.go +++ b/server/app/page.go @@ -336,3 +336,50 @@ func copyTitle(original string) string { title, _ := mmmodel.LimitRunes("Copy of "+original, model.PageTitleMaxRunes) return title } + +// GetPageWithDeleted fetches a page including soft-deleted rows, for restore flows. +func (s *Service) GetPageWithDeleted(pageID string) (*model.Page, *mmmodel.AppError) { + if !mmmodel.IsValidId(pageID) { + return nil, mmmodel.NewAppError("GetPageWithDeleted", "app.page.get.invalid_id.app_error", nil, "", http.StatusBadRequest) + } + page, err := s.store.GetPage(pageID, true) + if err != nil { + return nil, storeAppError("GetPageWithDeleted", "app.page.get", err) + } + // Version snapshots (OriginalId != "") are soft-deleted but not restorable; treat as not found. + if page.OriginalId != "" { + return nil, mmmodel.NewAppError("GetPageWithDeleted", "app.page.get.not_found.app_error", nil, "", http.StatusNotFound) + } + return page, nil +} + +// DeletePage soft-deletes a page; the store promotes its live children to the page's parent +// (not undone on restore, matching Confluence). +func (s *Service) DeletePage(pageID string) *mmmodel.AppError { + if !mmmodel.IsValidId(pageID) { + return mmmodel.NewAppError("DeletePage", "app.page.delete.invalid_id.app_error", nil, "", http.StatusBadRequest) + } + if delErr := s.store.DeletePage(pageID); delErr != nil { + return storeAppError("DeletePage", "app.page.delete", delErr) + } + return nil +} + +// RestorePage un-deletes a soft-deleted page; promoted children stay put (matching +// Confluence), and the page returns under its original parent or the space root if it's gone. +func (s *Service) RestorePage(pageID string) *mmmodel.AppError { + if !mmmodel.IsValidId(pageID) { + return mmmodel.NewAppError("RestorePage", "app.page.restore.invalid_id.app_error", nil, "", http.StatusBadRequest) + } + page, err := s.store.GetPage(pageID, true) + if err != nil { + return storeAppError("RestorePage", "app.page.restore", err) + } + if page.DeleteAt == 0 { + return mmmodel.NewAppError("RestorePage", "app.page.restore.not_deleted.app_error", nil, "", http.StatusBadRequest) + } + if restoreErr := s.store.RestorePage(page.Id); restoreErr != nil { + return storeAppError("RestorePage", "app.page.restore", restoreErr) + } + return nil +} From ac2b171000f63b227fdc37203662936525cef6c3 Mon Sep 17 00:00:00 2001 From: "Catalin I. Tomai" Date: Mon, 29 Jun 2026 14:47:52 +0200 Subject: [PATCH 04/36] test: add schema-isolated Postgres test harness Co-Authored-By: Claude Opus 4.8 (1M context) --- server/internal/testutil/dsn.go | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 server/internal/testutil/dsn.go diff --git a/server/internal/testutil/dsn.go b/server/internal/testutil/dsn.go new file mode 100644 index 0000000..dede613 --- /dev/null +++ b/server/internal/testutil/dsn.go @@ -0,0 +1,20 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +// Package testutil holds helpers shared across the server's test packages. +package testutil + +import "net/url" + +// AddSearchPath returns dsn with the Postgres search_path set to schema. +// Handles both URL-form DSNs (postgres://…) and libpq key=value DSNs. +func AddSearchPath(dsn, schema string) string { + u, err := url.Parse(dsn) + if err != nil || u.Scheme == "" { + return dsn + " options='-c search_path=" + schema + "'" + } + q := u.Query() + q.Set("search_path", schema) + u.RawQuery = q.Encode() + return u.String() +} From 12b1cc9f633fa5e5adf4e5333a23f455a249daa1 Mon Sep 17 00:00:00 2001 From: "Catalin I. Tomai" Date: Tue, 30 Jun 2026 12:17:56 +0200 Subject: [PATCH 05/36] address coderabbitai comments --- server/model/draft.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/server/model/draft.go b/server/model/draft.go index 592de7d..f2e6d9a 100644 --- a/server/model/draft.go +++ b/server/model/draft.go @@ -123,5 +123,6 @@ func (d *Draft) IsValid() *mmmodel.AppError { // GetProps returns Props, or an empty map if Props is nil. func (d *Draft) GetProps() mmmodel.StringInterface { - return ensureProps(d.Props) + d.Props = ensureProps(d.Props) + return d.Props } From 4d12a5df4a1db8425a6f769cf11c3ba30c41d9d0 Mon Sep 17 00:00:00 2001 From: "Catalin I. Tomai" Date: Tue, 30 Jun 2026 13:52:42 +0200 Subject: [PATCH 06/36] test: align DB test harness with storetest.MakeSqlSettings --- server/internal/testutil/dsn.go | 20 -------------------- 1 file changed, 20 deletions(-) delete mode 100644 server/internal/testutil/dsn.go diff --git a/server/internal/testutil/dsn.go b/server/internal/testutil/dsn.go deleted file mode 100644 index dede613..0000000 --- a/server/internal/testutil/dsn.go +++ /dev/null @@ -1,20 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -// Package testutil holds helpers shared across the server's test packages. -package testutil - -import "net/url" - -// AddSearchPath returns dsn with the Postgres search_path set to schema. -// Handles both URL-form DSNs (postgres://…) and libpq key=value DSNs. -func AddSearchPath(dsn, schema string) string { - u, err := url.Parse(dsn) - if err != nil || u.Scheme == "" { - return dsn + " options='-c search_path=" + schema + "'" - } - q := u.Query() - q.Set("search_path", schema) - u.RawQuery = q.Encode() - return u.String() -} From 9e440dabb7857f23212b737f8b162537031098d6 Mon Sep 17 00:00:00 2001 From: "Catalin I. Tomai" Date: Mon, 6 Jul 2026 15:30:30 +0200 Subject: [PATCH 07/36] MM-69268 - Page tree CRUD + URL API: spaces, pages, move, duplicate ! --- server/app/pagination_internal_test.go | 36 ++++++++++++++++ server/model/props.go | 42 +++++++++++++++++++ ...000004_add_hierarchy_move_indexes.down.sql | 1 + .../000004_add_hierarchy_move_indexes.up.sql | 5 +++ 4 files changed, 84 insertions(+) create mode 100644 server/app/pagination_internal_test.go create mode 100644 server/store/migrations/000004_add_hierarchy_move_indexes.down.sql create mode 100644 server/store/migrations/000004_add_hierarchy_move_indexes.up.sql diff --git a/server/app/pagination_internal_test.go b/server/app/pagination_internal_test.go new file mode 100644 index 0000000..7bbde54 --- /dev/null +++ b/server/app/pagination_internal_test.go @@ -0,0 +1,36 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package app + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +// TestPaginationOffsetLimit verifies perPage <= 0 defaults to PerPageDefault and +// perPage > PerPageMaximum is capped at PerPageMaximum, so a caller can never +// request an unbounded result — matching core's page-param convention. +func TestPaginationOffsetLimit(t *testing.T) { + tests := []struct { + name string + page, perPage int + wantOffset int + wantLimit int + }{ + {"zero perPage defaults", 0, 0, 0, PerPageDefault}, + {"negative perPage defaults", 0, -5, 0, PerPageDefault}, + {"perPage within range is unchanged", 1, 25, 25, 25}, + {"perPage over max is capped", 0, PerPageMaximum + 50, 0, PerPageMaximum}, + {"negative page treated as zero", -1, 10, 0, 10}, + {"offset derived from page * clamped perPage", 2, 25, 50, 25}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + offset, limit := paginationOffsetLimit(tt.page, tt.perPage) + require.Equal(t, tt.wantOffset, offset) + require.Equal(t, tt.wantLimit, limit) + }) + } +} diff --git a/server/model/props.go b/server/model/props.go index 98f0218..63bff57 100644 --- a/server/model/props.go +++ b/server/model/props.go @@ -35,3 +35,45 @@ func validatePropsSize(where, details string, props mmmodel.StringInterface, max } return nil } + +// DeepCloneStringInterface returns a deep copy of a StringInterface, +// recursively copying nested maps and slices to avoid aliasing. Props values only ever originate +// from JSON decoding (map[string]any, []any, and JSON scalars) or a directly-assigned +// mmmodel.StringInterface/[]string; deepCloneAny covers exactly those shapes and falls back to a +// shallow copy (aliasing) for any other typed slice/map, which no caller currently constructs. +func DeepCloneStringInterface(src mmmodel.StringInterface) mmmodel.StringInterface { + dst := make(mmmodel.StringInterface, len(src)) + for k, v := range src { + dst[k] = deepCloneAny(v) + } + return dst +} + +func deepCloneAny(v any) any { + switch x := v.(type) { + case map[string]any: + out := make(map[string]any, len(x)) + for k, vv := range x { + out[k] = deepCloneAny(vv) + } + return out + case mmmodel.StringInterface: + out := make(mmmodel.StringInterface, len(x)) + for k, vv := range x { + out[k] = deepCloneAny(vv) + } + return out + case []any: + out := make([]any, len(x)) + for i, vv := range x { + out[i] = deepCloneAny(vv) + } + return out + case []string: + out := make([]string, len(x)) + copy(out, x) + return out + default: + return x + } +} diff --git a/server/store/migrations/000004_add_hierarchy_move_indexes.down.sql b/server/store/migrations/000004_add_hierarchy_move_indexes.down.sql new file mode 100644 index 0000000..da9df0c --- /dev/null +++ b/server/store/migrations/000004_add_hierarchy_move_indexes.down.sql @@ -0,0 +1 @@ +DROP INDEX IF EXISTS idx_docs_page_originalid; diff --git a/server/store/migrations/000004_add_hierarchy_move_indexes.up.sql b/server/store/migrations/000004_add_hierarchy_move_indexes.up.sql new file mode 100644 index 0000000..792d71e --- /dev/null +++ b/server/store/migrations/000004_add_hierarchy_move_indexes.up.sql @@ -0,0 +1,5 @@ +-- Snapshot re-home by original page: rewriteSubtreeSpace filters DOCS_Page +-- WHERE OriginalId IN (...) AND DeleteAt>0 while holding FOR UPDATE locks on the +-- source/target DOCS_Space rows during MovePageToSpace. idx_docs_page_spaceid_deleted +-- only covers the opposite case (OriginalId=''), so this predicate had no index. +CREATE INDEX IF NOT EXISTS idx_docs_page_originalid ON DOCS_Page (OriginalId) WHERE OriginalId <> ''; From ee2c5e5891d16c41a85ccfe163127671d7bb212b Mon Sep 17 00:00:00 2001 From: "Catalin I. Tomai" Date: Tue, 7 Jul 2026 13:04:15 +0200 Subject: [PATCH 08/36] clean-ups --- .../store/migrations/000004_add_hierarchy_move_indexes.up.sql | 4 ---- 1 file changed, 4 deletions(-) diff --git a/server/store/migrations/000004_add_hierarchy_move_indexes.up.sql b/server/store/migrations/000004_add_hierarchy_move_indexes.up.sql index 792d71e..9700076 100644 --- a/server/store/migrations/000004_add_hierarchy_move_indexes.up.sql +++ b/server/store/migrations/000004_add_hierarchy_move_indexes.up.sql @@ -1,5 +1 @@ --- Snapshot re-home by original page: rewriteSubtreeSpace filters DOCS_Page --- WHERE OriginalId IN (...) AND DeleteAt>0 while holding FOR UPDATE locks on the --- source/target DOCS_Space rows during MovePageToSpace. idx_docs_page_spaceid_deleted --- only covers the opposite case (OriginalId=''), so this predicate had no index. CREATE INDEX IF NOT EXISTS idx_docs_page_originalid ON DOCS_Page (OriginalId) WHERE OriginalId <> ''; From fe64d0baf3f193685578c38592bc68d18aa89fe1 Mon Sep 17 00:00:00 2001 From: "Catalin I. Tomai" Date: Wed, 8 Jul 2026 12:15:16 +0200 Subject: [PATCH 09/36] address coderabbitai comments + update comments --- server/model/props.go | 42 ------------------- ...000004_add_hierarchy_move_indexes.down.sql | 3 +- .../000004_add_hierarchy_move_indexes.up.sql | 3 +- 3 files changed, 4 insertions(+), 44 deletions(-) diff --git a/server/model/props.go b/server/model/props.go index 63bff57..98f0218 100644 --- a/server/model/props.go +++ b/server/model/props.go @@ -35,45 +35,3 @@ func validatePropsSize(where, details string, props mmmodel.StringInterface, max } return nil } - -// DeepCloneStringInterface returns a deep copy of a StringInterface, -// recursively copying nested maps and slices to avoid aliasing. Props values only ever originate -// from JSON decoding (map[string]any, []any, and JSON scalars) or a directly-assigned -// mmmodel.StringInterface/[]string; deepCloneAny covers exactly those shapes and falls back to a -// shallow copy (aliasing) for any other typed slice/map, which no caller currently constructs. -func DeepCloneStringInterface(src mmmodel.StringInterface) mmmodel.StringInterface { - dst := make(mmmodel.StringInterface, len(src)) - for k, v := range src { - dst[k] = deepCloneAny(v) - } - return dst -} - -func deepCloneAny(v any) any { - switch x := v.(type) { - case map[string]any: - out := make(map[string]any, len(x)) - for k, vv := range x { - out[k] = deepCloneAny(vv) - } - return out - case mmmodel.StringInterface: - out := make(mmmodel.StringInterface, len(x)) - for k, vv := range x { - out[k] = deepCloneAny(vv) - } - return out - case []any: - out := make([]any, len(x)) - for i, vv := range x { - out[i] = deepCloneAny(vv) - } - return out - case []string: - out := make([]string, len(x)) - copy(out, x) - return out - default: - return x - } -} diff --git a/server/store/migrations/000004_add_hierarchy_move_indexes.down.sql b/server/store/migrations/000004_add_hierarchy_move_indexes.down.sql index da9df0c..8a11142 100644 --- a/server/store/migrations/000004_add_hierarchy_move_indexes.down.sql +++ b/server/store/migrations/000004_add_hierarchy_move_indexes.down.sql @@ -1 +1,2 @@ -DROP INDEX IF EXISTS idx_docs_page_originalid; +-- morph:nontransactional +DROP INDEX CONCURRENTLY IF EXISTS idx_docs_page_originalid; diff --git a/server/store/migrations/000004_add_hierarchy_move_indexes.up.sql b/server/store/migrations/000004_add_hierarchy_move_indexes.up.sql index 9700076..71b800f 100644 --- a/server/store/migrations/000004_add_hierarchy_move_indexes.up.sql +++ b/server/store/migrations/000004_add_hierarchy_move_indexes.up.sql @@ -1 +1,2 @@ -CREATE INDEX IF NOT EXISTS idx_docs_page_originalid ON DOCS_Page (OriginalId) WHERE OriginalId <> ''; +-- morph:nontransactional +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_docs_page_originalid ON DOCS_Page (OriginalId) WHERE OriginalId <> ''; From 12adb02f4de7b91b6ef3b78c30f3ac8f0d5be609 Mon Sep 17 00:00:00 2001 From: "Catalin I. Tomai" Date: Wed, 8 Jul 2026 13:32:53 +0200 Subject: [PATCH 10/36] Sanitize error responses and handle max depth in duplicate --- assets/i18n/en.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/assets/i18n/en.json b/assets/i18n/en.json index a18af74..f750e3b 100644 --- a/assets/i18n/en.json +++ b/assets/i18n/en.json @@ -83,6 +83,10 @@ "id": "app.page.duplicate.invalid_user_id.app_error", "translation": "Invalid user ID." }, + { + "id": "app.page.duplicate.max_depth_exceeded.app_error", + "translation": "" + }, { "id": "app.page.duplicate.not_found.app_error", "translation": "The page could not be found." From 0a9d7dde26467d7134648e4ce1f448d5bf4bb96c Mon Sep 17 00:00:00 2001 From: "Catalin I. Tomai" Date: Wed, 8 Jul 2026 20:45:17 +0200 Subject: [PATCH 11/36] address coderabbitai comment --- assets/i18n/en.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/assets/i18n/en.json b/assets/i18n/en.json index f750e3b..842671a 100644 --- a/assets/i18n/en.json +++ b/assets/i18n/en.json @@ -85,7 +85,7 @@ }, { "id": "app.page.duplicate.max_depth_exceeded.app_error", - "translation": "" + "translation": "Pages cannot be nested more than {{.MaxDepth}} levels deep." }, { "id": "app.page.duplicate.not_found.app_error", From 89a40aed86f9f32ce8425058efc8e122645e8454 Mon Sep 17 00:00:00 2001 From: "Catalin I. Tomai" Date: Wed, 8 Jul 2026 22:06:04 +0200 Subject: [PATCH 12/36] update Auditable interface --- server/model/draft.go | 4 ++-- server/model/space.go | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/server/model/draft.go b/server/model/draft.go index f2e6d9a..13d29d2 100644 --- a/server/model/draft.go +++ b/server/model/draft.go @@ -55,8 +55,8 @@ func (d *Draft) PreSave() { d.UpdateAt = now } -// Auditable returns Draft's fields safe to include in an audit log, excluding Body. -func (d *Draft) Auditable() map[string]any { +// AuditFields returns Draft's fields safe to include in an audit log, excluding Body. +func (d *Draft) AuditFields() map[string]any { return map[string]any{ "user_id": d.UserId, "space_id": d.SpaceId, diff --git a/server/model/space.go b/server/model/space.go index a5ccb72..a284f40 100644 --- a/server/model/space.go +++ b/server/model/space.go @@ -123,8 +123,8 @@ func (s *Space) PreUpdate() { } } -// Auditable returns Space's fields safe to include in an audit log. -func (s *Space) Auditable() map[string]any { +// AuditFields returns Space's fields safe to include in an audit log. +func (s *Space) AuditFields() map[string]any { return map[string]any{ "id": s.Id, "channel_id": s.ChannelId, From bc7f979a38ea5dbea33f9bcc81103edca171ede1 Mon Sep 17 00:00:00 2001 From: "Catalin I. Tomai" Date: Thu, 9 Jul 2026 07:35:44 +0200 Subject: [PATCH 13/36] use mmmodel.Auditable, drop local duplicate --- server/model/draft.go | 4 ++-- server/model/space.go | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/server/model/draft.go b/server/model/draft.go index 13d29d2..f2e6d9a 100644 --- a/server/model/draft.go +++ b/server/model/draft.go @@ -55,8 +55,8 @@ func (d *Draft) PreSave() { d.UpdateAt = now } -// AuditFields returns Draft's fields safe to include in an audit log, excluding Body. -func (d *Draft) AuditFields() map[string]any { +// Auditable returns Draft's fields safe to include in an audit log, excluding Body. +func (d *Draft) Auditable() map[string]any { return map[string]any{ "user_id": d.UserId, "space_id": d.SpaceId, diff --git a/server/model/space.go b/server/model/space.go index a284f40..a5ccb72 100644 --- a/server/model/space.go +++ b/server/model/space.go @@ -123,8 +123,8 @@ func (s *Space) PreUpdate() { } } -// AuditFields returns Space's fields safe to include in an audit log. -func (s *Space) AuditFields() map[string]any { +// Auditable returns Space's fields safe to include in an audit log. +func (s *Space) Auditable() map[string]any { return map[string]any{ "id": s.Id, "channel_id": s.ChannelId, From 1360eac701f04af0edcf84c7f7a082876a7e1cbc Mon Sep 17 00:00:00 2001 From: "Catalin I. Tomai" Date: Thu, 9 Jul 2026 15:35:08 +0200 Subject: [PATCH 14/36] Add space membership checks, WS events, and per-user space filtering --- server/app/pagination_internal_test.go | 16 ++++++++-------- .../000004_add_hierarchy_move_indexes.down.sql | 2 -- .../000004_add_hierarchy_move_indexes.up.sql | 2 -- 3 files changed, 8 insertions(+), 12 deletions(-) delete mode 100644 server/store/migrations/000004_add_hierarchy_move_indexes.down.sql delete mode 100644 server/store/migrations/000004_add_hierarchy_move_indexes.up.sql diff --git a/server/app/pagination_internal_test.go b/server/app/pagination_internal_test.go index 7bbde54..2265b7f 100644 --- a/server/app/pagination_internal_test.go +++ b/server/app/pagination_internal_test.go @@ -10,8 +10,8 @@ import ( ) // TestPaginationOffsetLimit verifies perPage <= 0 defaults to PerPageDefault and -// perPage > PerPageMaximum is capped at PerPageMaximum, so a caller can never -// request an unbounded result — matching core's page-param convention. +// perPage > PerPageMaximum is capped at PerPageMaximum. The returned limit is always +// perPage+1 so callers can detect has_more without a separate COUNT query. func TestPaginationOffsetLimit(t *testing.T) { tests := []struct { name string @@ -19,12 +19,12 @@ func TestPaginationOffsetLimit(t *testing.T) { wantOffset int wantLimit int }{ - {"zero perPage defaults", 0, 0, 0, PerPageDefault}, - {"negative perPage defaults", 0, -5, 0, PerPageDefault}, - {"perPage within range is unchanged", 1, 25, 25, 25}, - {"perPage over max is capped", 0, PerPageMaximum + 50, 0, PerPageMaximum}, - {"negative page treated as zero", -1, 10, 0, 10}, - {"offset derived from page * clamped perPage", 2, 25, 50, 25}, + {"zero perPage defaults", 0, 0, 0, PerPageDefault + 1}, + {"negative perPage defaults", 0, -5, 0, PerPageDefault + 1}, + {"perPage within range is unchanged", 1, 25, 25, 26}, + {"perPage over max is capped", 0, PerPageMaximum + 50, 0, PerPageMaximum + 1}, + {"negative page treated as zero", -1, 10, 0, 11}, + {"offset derived from page * clamped perPage", 2, 25, 50, 26}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/server/store/migrations/000004_add_hierarchy_move_indexes.down.sql b/server/store/migrations/000004_add_hierarchy_move_indexes.down.sql deleted file mode 100644 index 8a11142..0000000 --- a/server/store/migrations/000004_add_hierarchy_move_indexes.down.sql +++ /dev/null @@ -1,2 +0,0 @@ --- morph:nontransactional -DROP INDEX CONCURRENTLY IF EXISTS idx_docs_page_originalid; diff --git a/server/store/migrations/000004_add_hierarchy_move_indexes.up.sql b/server/store/migrations/000004_add_hierarchy_move_indexes.up.sql deleted file mode 100644 index 71b800f..0000000 --- a/server/store/migrations/000004_add_hierarchy_move_indexes.up.sql +++ /dev/null @@ -1,2 +0,0 @@ --- morph:nontransactional -CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_docs_page_originalid ON DOCS_Page (OriginalId) WHERE OriginalId <> ''; From ec5ead701da3834c90b039b5f4855fa25c17bdcc Mon Sep 17 00:00:00 2001 From: "Catalin I. Tomai" Date: Mon, 13 Jul 2026 15:17:15 +0200 Subject: [PATCH 15/36] Page move/duplicate store ops, WS events, pagination, review fixes --- server/app/pagination_internal_test.go | 36 -------------------------- 1 file changed, 36 deletions(-) delete mode 100644 server/app/pagination_internal_test.go diff --git a/server/app/pagination_internal_test.go b/server/app/pagination_internal_test.go deleted file mode 100644 index 2265b7f..0000000 --- a/server/app/pagination_internal_test.go +++ /dev/null @@ -1,36 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -package app - -import ( - "testing" - - "github.com/stretchr/testify/require" -) - -// TestPaginationOffsetLimit verifies perPage <= 0 defaults to PerPageDefault and -// perPage > PerPageMaximum is capped at PerPageMaximum. The returned limit is always -// perPage+1 so callers can detect has_more without a separate COUNT query. -func TestPaginationOffsetLimit(t *testing.T) { - tests := []struct { - name string - page, perPage int - wantOffset int - wantLimit int - }{ - {"zero perPage defaults", 0, 0, 0, PerPageDefault + 1}, - {"negative perPage defaults", 0, -5, 0, PerPageDefault + 1}, - {"perPage within range is unchanged", 1, 25, 25, 26}, - {"perPage over max is capped", 0, PerPageMaximum + 50, 0, PerPageMaximum + 1}, - {"negative page treated as zero", -1, 10, 0, 11}, - {"offset derived from page * clamped perPage", 2, 25, 50, 26}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - offset, limit := paginationOffsetLimit(tt.page, tt.perPage) - require.Equal(t, tt.wantOffset, offset) - require.Equal(t, tt.wantLimit, limit) - }) - } -} From 4458eadd6d0688dec561555621534d46b1431a6f Mon Sep 17 00:00:00 2001 From: "Catalin I. Tomai" Date: Wed, 15 Jul 2026 14:29:46 +0200 Subject: [PATCH 16/36] MM-69271: page drafts, TipTap content handling, and presence --- assets/i18n/en.json | 164 +++- server/api.go | 15 +- server/api_handler_test.go | 26 +- server/api_page.go | 14 +- server/api_page_drafts.go | 206 +++++ server/api_page_drafts_test.go | 362 +++++++++ server/api_page_presence.go | 34 + server/app/export_test.go | 7 + server/app/page.go | 62 +- server/app/page_content.go | 114 +++ server/app/page_content_test.go | 103 +++ server/app/page_draft.go | 558 +++++++++++++ server/app/page_draft_test.go | 759 ++++++++++++++++++ server/app/page_duplicate_test.go | 11 +- server/app/page_hierarchy.go | 2 +- server/app/page_presence.go | 97 +++ server/app/service.go | 15 +- server/app/service_test.go | 82 +- server/app/ws_events.go | 10 +- server/app/ws_events_test.go | 215 +++++ server/model/draft.go | 102 ++- server/model/draft_test.go | 27 + server/model/page_content.go | 518 ++++++++++++ server/model/page_content_test.go | 433 ++++++++++ server/store/draft_store.go | 478 ++++++++++- .../000005_add_draft_lastactiveat.down.sql | 1 + .../000005_add_draft_lastactiveat.up.sql | 4 + server/store/page_move.go | 103 ++- server/store/page_move_test.go | 27 +- server/store/page_store.go | 192 ++++- server/store/store.go | 31 +- server/store/store_test.go | 527 +++++++++++- 32 files changed, 5060 insertions(+), 239 deletions(-) create mode 100644 server/api_page_drafts.go create mode 100644 server/api_page_drafts_test.go create mode 100644 server/api_page_presence.go create mode 100644 server/app/export_test.go create mode 100644 server/app/page_content.go create mode 100644 server/app/page_content_test.go create mode 100644 server/app/page_draft.go create mode 100644 server/app/page_draft_test.go create mode 100644 server/app/page_presence.go create mode 100644 server/model/page_content.go create mode 100644 server/model/page_content_test.go create mode 100644 server/store/migrations/000005_add_draft_lastactiveat.down.sql create mode 100644 server/store/migrations/000005_add_draft_lastactiveat.up.sql diff --git a/assets/i18n/en.json b/assets/i18n/en.json index 842671a..1ccdda9 100644 --- a/assets/i18n/en.json +++ b/assets/i18n/en.json @@ -44,8 +44,8 @@ "translation": "Invalid user ID." }, { - "id": "app.page.create.search_text_without_content.app_error", - "translation": "Search text cannot be set without page content." + "id": "app.page.create.parent_different_channel.app_error", + "translation": "The parent page belongs to a different space." }, { "id": "app.page.create.space_not_found.app_error", @@ -107,6 +107,10 @@ "id": "app.page.get_children.invalid_id.app_error", "translation": "Invalid page ID." }, + { + "id": "app.page.invalid_content.app_error", + "translation": "The page body is not valid content." + }, { "id": "app.page.invalid_parent.app_error", "translation": "The destination parent page does not exist." @@ -155,6 +159,18 @@ "id": "app.page.not_found.app_error", "translation": "The page could not be found." }, + { + "id": "app.page.presence.invalid_page_id.app_error", + "translation": "Invalid page ID." + }, + { + "id": "app.page.presence.invalid_space_id.app_error", + "translation": "Invalid space ID." + }, + { + "id": "app.page.presence.store_error.app_error", + "translation": "The list of active editors could not be loaded." + }, { "id": "app.page.restore.invalid_id.app_error", "translation": "Invalid page ID." @@ -203,6 +219,146 @@ "id": "app.page.update.store_error.app_error", "translation": "An error occurred while updating the page." }, + { + "id": "app.page_draft.create.invalid_parent.app_error", + "translation": "The parent page or draft was not found." + }, + { + "id": "app.page_draft.create.invalid_parent_id.app_error", + "translation": "The parent ID is not a valid ID." + }, + { + "id": "app.page_draft.create.invalid_space_id.app_error", + "translation": "Invalid space ID." + }, + { + "id": "app.page_draft.create.invalid_user_id.app_error", + "translation": "Invalid user ID." + }, + { + "id": "app.page_draft.create.quota_exceeded.app_error", + "translation": "Draft limit reached. Publish or discard an existing draft before creating a new one." + }, + { + "id": "app.page_draft.delete.invalid_page_id.app_error", + "translation": "Invalid page ID." + }, + { + "id": "app.page_draft.delete.invalid_space_id.app_error", + "translation": "Invalid space ID." + }, + { + "id": "app.page_draft.delete.invalid_user_id.app_error", + "translation": "Invalid user ID." + }, + { + "id": "app.page_draft.delete.not_found.app_error", + "translation": "Draft not found." + }, + { + "id": "app.page_draft.delete.store_error.app_error", + "translation": "Failed to delete draft." + }, + { + "id": "app.page_draft.get.invalid_page_id.app_error", + "translation": "Invalid page ID." + }, + { + "id": "app.page_draft.get.invalid_space_id.app_error", + "translation": "Invalid space ID." + }, + { + "id": "app.page_draft.get.invalid_user_id.app_error", + "translation": "Invalid user ID." + }, + { + "id": "app.page_draft.get.not_found.app_error", + "translation": "Draft not found." + }, + { + "id": "app.page_draft.get.store_error.app_error", + "translation": "Failed to retrieve draft." + }, + { + "id": "app.page_draft.list.invalid_space_id.app_error", + "translation": "Invalid space ID." + }, + { + "id": "app.page_draft.list.invalid_user_id.app_error", + "translation": "Invalid user ID." + }, + { + "id": "app.page_draft.publish.baseline_required.app_error", + "translation": "Cannot publish: the draft is missing its edit baseline. Reopen the page and try again." + }, + { + "id": "app.page_draft.publish.conflict.app_error", + "translation": "The page was modified by another writer. Refresh and try again." + }, + { + "id": "app.page_draft.publish.draft_changed.app_error", + "translation": "This draft was saved again while it was being published. Nothing was published; try publishing again to include your latest changes." + }, + { + "id": "app.page_draft.publish.edit_conflict.app_error", + "translation": "Someone else edited this page while you were writing. Reopen the page to see their changes, then publish again." + }, + { + "id": "app.page_draft.publish.draft_not_found.app_error", + "translation": "The draft could not be found; it may have already been published or discarded." + }, + { + "id": "app.page_draft.publish.page_deleted.app_error", + "translation": "The page was deleted and can no longer be published." + }, + { + "id": "app.page_draft.publish.parent_unpublished.app_error", + "translation": "The parent page must be published before this page can be published." + }, + { + "id": "app.page_draft.update.invalid_page_id.app_error", + "translation": "Invalid page ID." + }, + { + "id": "app.page_draft.update.invalid_parent_id.app_error", + "translation": "Invalid parent ID." + }, + { + "id": "app.page_draft.update.invalid_file_id.app_error", + "translation": "One or more file IDs are invalid." + }, + { + "id": "app.page_draft.update.invalid_space_id.app_error", + "translation": "Invalid space ID." + }, + { + "id": "app.page_draft.update.invalid_user_id.app_error", + "translation": "Invalid user ID." + }, + { + "id": "app.page_draft.update.nil_draft.app_error", + "translation": "Draft must not be nil." + }, + { + "id": "app.page_draft.update.parent_cycle.app_error", + "translation": "Setting this parent would create a cycle in the draft hierarchy." + }, + { + "id": "app.page_draft.update.draft_changed.app_error", + "translation": "A concurrent autosave has updated the draft; please republish." + }, + { + "id": "app.page_draft.update.edit_conflict.app_error", + "translation": "A concurrent edit has been published; please reload the page to continue editing." + }, + { + "id": "app.page_draft.update.page_not_found.app_error", + "translation": "The page does not exist or is not accessible in this space." + }, + { + "id": "app.page_draft.update.parent_too_deep.app_error", + "translation": "The draft hierarchy is too deep to add another level." + }, { "id": "app.shared.description_too_long.app_error", "translation": "The description exceeds the maximum length of {{.MaxLength}} characters." @@ -383,6 +539,10 @@ "id": "model.draft.is_valid.file_ids.app_error", "translation": "The draft has too many file attachments." }, + { + "id": "model.draft.is_valid.last_active_at.app_error", + "translation": "Invalid draft last-active time." + }, { "id": "model.draft.is_valid.page_id.app_error", "translation": "Invalid page ID for the draft." diff --git a/server/api.go b/server/api.go index 287f347..bbd3f28 100644 --- a/server/api.go +++ b/server/api.go @@ -23,8 +23,8 @@ import ( // // Authorization: every route requires an authenticated user via MattermostAuthorizationRequired. // All space- and page-scoped handlers additionally gate on backing-channel membership via -// CheckSpaceMembership (implemented). Per-page role ACLs (author vs. editor within a space) -// are not yet implemented and are deferred to a follow-up. +// CheckSpaceMembership. Per-page role ACLs (author vs. editor within a space) are not yet +// implemented. func (p *Plugin) initRouter() *mux.Router { router := mux.NewRouter() router.Use(p.MattermostAuthorizationRequired) @@ -60,6 +60,17 @@ func (p *Plugin) initRouter() *mux.Router { api.HandleFunc("/spaces/{space_id}/pages/{page_id}/move-to-space", p.handleMovePageToSpace).Methods(http.MethodPatch) api.HandleFunc("/spaces/{space_id}/pages/{page_id}/duplicate", p.handleDuplicatePage).Methods(http.MethodPost) + // Draft CRUD + publish. + api.HandleFunc("/spaces/{space_id}/drafts", p.handleCreateSpaceDraft).Methods(http.MethodPost) + api.HandleFunc("/spaces/{space_id}/drafts", p.handleGetPageDraftsForSpace).Methods(http.MethodGet) + api.HandleFunc("/spaces/{space_id}/pages/{page_id}/draft", p.handleUpdatePageDraft).Methods(http.MethodPut) + api.HandleFunc("/spaces/{space_id}/pages/{page_id}/draft", p.handleGetPageDraft).Methods(http.MethodGet) + api.HandleFunc("/spaces/{space_id}/pages/{page_id}/draft", p.handleDeletePageDraft).Methods(http.MethodDelete) + api.HandleFunc("/spaces/{space_id}/pages/{page_id}/draft/publish", p.handlePublishPageDraft).Methods(http.MethodPost) + + // Presence. + api.HandleFunc("/spaces/{space_id}/pages/{page_id}/active-editors", p.handleGetPageActiveEditors).Methods(http.MethodGet) + return router } diff --git a/server/api_handler_test.go b/server/api_handler_test.go index 3b558ef..a0a8b6f 100644 --- a/server/api_handler_test.go +++ b/server/api_handler_test.go @@ -110,7 +110,6 @@ func seedSpace(t *testing.T, s *store.Store, channelID string) *model.Space { return seedSpaceInTeam(t, s, channelID, mmmodel.NewId()) } -// seedSpaceInTeam mirrors testutil.MustCreateSpace's (channelID, teamID) parameter order. func seedSpaceInTeam(t *testing.T, s *store.Store, channelID, teamID string) *model.Space { t.Helper() return testutil.MustCreateSpace(t, s, channelID, teamID) @@ -224,16 +223,16 @@ func TestHandler_SpaceAndPageRoundTrip(t *testing.T) { require.Equal(t, space.Id, page.SpaceId) }) - t.Run("create page with content and search_text", func(t *testing.T) { + t.Run("create page derives search_text from body", func(t *testing.T) { rec := h.do(t, http.MethodPost, "/api/v1/spaces/"+space.Id+"/pages", user, map[string]any{ "title": "Page B", - "body": `{"type":"doc","content":[]}`, - "search_text": "plain text projection", + "body": `{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"searchable body"}]}]}`, + "search_text": "ignored client value", }) require.Equal(t, http.StatusCreated, rec.Code) var page model.Page require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &page)) - require.Equal(t, "plain text projection", page.SearchText) + require.Equal(t, "searchable body", page.SearchText, "SearchText is derived from the body, not the caller-supplied value") }) t.Run("get page in wrong space is 404", func(t *testing.T) { @@ -545,11 +544,10 @@ func TestHandler_UpdatePage(t *testing.T) { space := seedSpace(t, h.store, channelID) page := seedPage(t, h.store, space.Id, channelID, "") - // Body and search text must be patched together (search text is the body's plain-text - // projection), so both are supplied. + // SearchText is derived from the body server-side, so only the body is supplied; a + // caller-supplied search_text is ignored. body := map[string]any{ - "body": `{"type":"doc","content":[{"type":"paragraph"}]}`, - "search_text": "updated text", + "body": `{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"updated text"}]}]}`, "base_edit_at": page.EditAt, } rec := h.do(t, http.MethodPatch, "/api/v1/spaces/"+space.Id+"/pages/"+page.Id, user, body) @@ -557,7 +555,7 @@ func TestHandler_UpdatePage(t *testing.T) { var updated model.Page require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &updated)) - require.Equal(t, `{"type":"doc","content":[{"type":"paragraph"}]}`, updated.Body) + require.Contains(t, updated.Body, "updated text") require.Equal(t, "updated text", updated.SearchText) // The first update bumped EditAt, so the same baseline is now stale. @@ -1097,6 +1095,14 @@ func TestHandler_SpaceMembershipRequired(t *testing.T) { {http.MethodPatch, "/api/v1/spaces/" + space.Id + "/pages/" + page.Id + "/move", nil}, {http.MethodPatch, "/api/v1/spaces/" + space.Id + "/pages/" + page.Id + "/move-to-space", nil}, {http.MethodPost, "/api/v1/spaces/" + space.Id + "/pages/" + page.Id + "/duplicate", nil}, + // Draft + presence handlers. + {http.MethodPost, "/api/v1/spaces/" + space.Id + "/drafts", map[string]any{"title": "D"}}, + {http.MethodGet, "/api/v1/spaces/" + space.Id + "/drafts", nil}, + {http.MethodPut, "/api/v1/spaces/" + space.Id + "/pages/" + page.Id + "/draft", map[string]any{"title": "D"}}, + {http.MethodGet, "/api/v1/spaces/" + space.Id + "/pages/" + page.Id + "/draft", nil}, + {http.MethodDelete, "/api/v1/spaces/" + space.Id + "/pages/" + page.Id + "/draft", nil}, + {http.MethodPost, "/api/v1/spaces/" + space.Id + "/pages/" + page.Id + "/draft/publish", nil}, + {http.MethodGet, "/api/v1/spaces/" + space.Id + "/pages/" + page.Id + "/active-editors", nil}, } for _, tc := range cases { rec := h.do(t, tc.method, tc.path, stranger, tc.body) diff --git a/server/api_page.go b/server/api_page.go index d7f27f5..2b22f5d 100644 --- a/server/api_page.go +++ b/server/api_page.go @@ -44,16 +44,16 @@ func (p *Plugin) handleCreatePage(w http.ResponseWriter, r *http.Request) { return } + // SearchText is not accepted: it is derived server-side from Body. var req struct { - Title string `json:"title"` - ParentId *string `json:"parent_id,omitempty"` - Body string `json:"body,omitempty"` - SearchText string `json:"search_text,omitempty"` + Title string `json:"title"` + ParentId *string `json:"parent_id,omitempty"` + Body string `json:"body,omitempty"` } if !p.decodeJSONBody(w, r, maxPageBodyBytes, &req, "handleCreatePage", false) { return } - page, appErr := p.service.CreatePage(vars["space_id"], mmmodel.SafeDereference(req.ParentId), req.Title, req.Body, req.SearchText, userID) + page, appErr := p.service.CreatePage(vars["space_id"], mmmodel.SafeDereference(req.ParentId), req.Title, req.Body, userID) if appErr != nil { p.writeAppError(w, appErr) return @@ -85,10 +85,10 @@ func (p *Plugin) handleUpdatePage(w http.ResponseWriter, r *http.Request) { return } + // SearchText is not accepted: it is derived server-side from Body (matches the create path). var req struct { Title *string `json:"title"` Body *string `json:"body"` - SearchText *string `json:"search_text"` Props *mmmodel.StringInterface `json:"props"` BaseEditAt *int64 `json:"base_edit_at"` Force bool `json:"force"` @@ -96,7 +96,7 @@ func (p *Plugin) handleUpdatePage(w http.ResponseWriter, r *http.Request) { if !p.decodeJSONBody(w, r, maxPageBodyBytes, &req, "handleUpdatePage", false) { return } - patch := &model.PagePatch{Title: req.Title, Body: req.Body, SearchText: req.SearchText, Props: req.Props} + patch := &model.PagePatch{Title: req.Title, Body: req.Body, Props: req.Props} updated, appErr := p.service.UpdatePage(vars["page_id"], vars["space_id"], patch, req.BaseEditAt, req.Force, userID) if appErr != nil { diff --git a/server/api_page_drafts.go b/server/api_page_drafts.go new file mode 100644 index 0000000..bd10eaa --- /dev/null +++ b/server/api_page_drafts.go @@ -0,0 +1,206 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package main + +import ( + "net/http" + + "github.com/gorilla/mux" + mmmodel "github.com/mattermost/mattermost/server/public/model" + + "github.com/mattermost/mattermost-plugin-docs/server/model" +) + +const ( + // maxDraftBodyBytes caps the autosave PUT, which carries the full document body. It tracks the + // model's enforced body limit plus headroom for the title/props/file-ids and JSON envelope, so + // an over-limit body is rejected at the transport layer rather than after decoding. + maxDraftBodyBytes = model.PageBodyMaxBytes + (64 << 10) // 64 KiB headroom +) + +// handleUpdatePageDraft handles PUT /api/v1/spaces/{space_id}/pages/{page_id}/draft +// It upserts the calling user's draft for the page. This PUT merges rather than replaces: an +// omitted field means "unchanged", not "cleared" (autosave heartbeats carry only the fields the +// editor touched). parent_id: null omits the field; parent_id: "" explicitly clears it. +// +// For existing published pages, the first PUT creates the draft (open an edit session). The client +// must include original_page_edit_at in props — the page's EditAt at the moment the user opened +// it — so a subsequent publish can detect a concurrent edit. +// +// For new-page drafts (no page row yet), the draft must already exist via POST +// /spaces/{space_id}/drafts. This prevents a space member who learns another user's pending page +// ID from squatting on it via the autosave endpoint. +func (p *Plugin) handleUpdatePageDraft(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + spaceID := vars["space_id"] + pageID := vars["page_id"] + userID := userIDFromRequest(r) + + space, ok := p.requireSpaceMembership(w, spaceID, userID, false) + if !ok { + return + } + + var req struct { + ParentId *string `json:"parent_id"` + Title string `json:"title"` + Body string `json:"body"` + FileIds *mmmodel.StringArray `json:"file_ids"` + Props mmmodel.StringInterface `json:"props"` + } + if !p.decodeJSONBody(w, r, maxDraftBodyBytes, &req, "handleUpdatePageDraft", false) { + return + } + + draft := &model.Draft{ + UserId: userID, + SpaceId: spaceID, + PageId: pageID, + Title: req.Title, + Body: req.Body, + Props: req.Props, + } + + // req.ParentId nil → preserve; pointer to "" → clear to root; pointer to ID → set parent. + // req.FileIds nil → preserve; pointer to [] → clear; pointer to [...] → replace. + saved, appErr := p.service.UpdatePageDraft(draft, req.ParentId, req.FileIds, space.ChannelId) + if appErr != nil { + p.writeAppError(w, appErr) + return + } + + writeJSON(w, http.StatusOK, saved) +} + +// handleGetPageDraft handles GET /api/v1/spaces/{space_id}/pages/{page_id}/draft +func (p *Plugin) handleGetPageDraft(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + spaceID := vars["space_id"] + pageID := vars["page_id"] + userID := userIDFromRequest(r) + + if _, ok := p.requireSpaceMembership(w, spaceID, userID, false); !ok { + return + } + + draft, appErr := p.service.GetPageDraft(userID, spaceID, pageID) + if appErr != nil { + p.writeAppError(w, appErr) + return + } + + writeJSON(w, http.StatusOK, draft) +} + +// handleDeletePageDraft handles DELETE /api/v1/spaces/{space_id}/pages/{page_id}/draft +func (p *Plugin) handleDeletePageDraft(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + spaceID := vars["space_id"] + pageID := vars["page_id"] + userID := userIDFromRequest(r) + + space, ok := p.requireSpaceMembership(w, spaceID, userID, false) + if !ok { + return + } + + if appErr := p.service.DeletePageDraft(userID, spaceID, pageID, space.ChannelId); appErr != nil { + p.writeAppError(w, appErr) + return + } + + writeStatusOK(w) +} + +// handleCreateSpaceDraft handles POST /api/v1/spaces/{space_id}/drafts +// It creates a new-page draft (no Pages row) with a server-generated page id, reserving the id +// before the page is published so a new page has a stable link from the start. +func (p *Plugin) handleCreateSpaceDraft(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + spaceID := vars["space_id"] + userID := userIDFromRequest(r) + + if _, ok := p.requireSpaceMembership(w, spaceID, userID, false); !ok { + return + } + + var req struct { + Title string `json:"title"` + ParentId string `json:"parent_id"` + } + if !p.decodeJSONBody(w, r, maxPageStructBodyBytes, &req, "handleCreateSpaceDraft", false) { + return + } + + saved, appErr := p.service.CreateSpaceDraft(userID, spaceID, req.Title, req.ParentId) + if appErr != nil { + p.writeAppError(w, appErr) + return + } + + writeJSON(w, http.StatusCreated, saved) +} + +// handlePublishPageDraft handles POST /api/v1/spaces/{space_id}/pages/{page_id}/draft/publish +// It publishes the calling user's draft, atomically writing the page row and deleting the draft in +// one transaction. +// +// The optimistic-lock baseline for an edit-publish is not a field on this request: it travels with +// the draft, captured once (as the original_page_edit_at prop) when editing began and carried by the +// autosave PUTs. This differs from the per-request base_edit_at on handleUpdatePage (and +// expected_update_at on handleMovePage) because a publish ships whatever the draft already holds +// rather than re-supplying a freshly-read baseline. +func (p *Plugin) handlePublishPageDraft(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + spaceID := vars["space_id"] + pageID := vars["page_id"] + userID := userIDFromRequest(r) + + if _, ok := p.requireSpaceMembership(w, spaceID, userID, false); !ok { + return + } + + // Optional body: {force: bool}. force=true overrides first-write-wins. + var req struct { + Force bool `json:"force"` + } + // allowEmptyBody=true: an absent body means force=false. A present-but-malformed body is still + // an error, so the return value must be checked. + if !p.decodeJSONBody(w, r, maxPageStructBodyBytes, &req, "handlePublishPageDraft", true) { + return + } + + page, wasCreated, appErr := p.service.PublishPageDraft(userID, spaceID, pageID, req.Force) + if appErr != nil { + p.writeAppError(w, appErr) + return + } + + status := http.StatusOK + if wasCreated { + status = http.StatusCreated + } + writeJSON(w, status, page) +} + +// handleGetPageDraftsForSpace handles GET /api/v1/spaces/{space_id}/drafts +// It lists the calling user's unpublished page drafts for the space. +func (p *Plugin) handleGetPageDraftsForSpace(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + spaceID := vars["space_id"] + userID := userIDFromRequest(r) + + if _, ok := p.requireSpaceMembership(w, spaceID, userID, false); !ok { + return + } + + page, perPage := pageParam(r), perPageParam(r) + drafts, hasMore, appErr := p.service.GetPageDraftsForSpace(userID, spaceID, page, perPage) + if appErr != nil { + p.writeAppError(w, appErr) + return + } + + writePaginatedJSON(w, drafts, page, perPage, hasMore) +} diff --git a/server/api_page_drafts_test.go b/server/api_page_drafts_test.go new file mode 100644 index 0000000..39a35c4 --- /dev/null +++ b/server/api_page_drafts_test.go @@ -0,0 +1,362 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package main + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/mattermost/mattermost/server/public/plugin" + + mmmodel "github.com/mattermost/mattermost/server/public/model" + + "github.com/mattermost/mattermost-plugin-docs/server/model" +) + +// TestHandler_DraftLifecycle drives the draft routes end to end over the real router: create a +// new-page draft, autosave it, read it back, publish it (201, reserved id preserved), and confirm +// the draft is gone afterwards. +func TestHandler_DraftLifecycle(t *testing.T) { + h := openTestPlugin(t, nil) + channelID := mmmodel.NewId() + space := seedSpace(t, h.store, channelID) + userID := mmmodel.NewId() + base := "/api/v1/spaces/" + space.Id + + rec := h.do(t, http.MethodPost, base+"/drafts", userID, map[string]any{"title": "New Doc"}) + require.Equal(t, http.StatusCreated, rec.Code) + var draft model.Draft + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &draft)) + pageID := draft.PageId + require.True(t, mmmodel.IsValidId(pageID)) + + rec = h.do(t, http.MethodPut, base+"/pages/"+pageID+"/draft", userID, map[string]any{ + "title": "New Doc", + "body": `{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"hello"}]}]}`, + }) + require.Equal(t, http.StatusOK, rec.Code) + + rec = h.do(t, http.MethodGet, base+"/pages/"+pageID+"/draft", userID, nil) + require.Equal(t, http.StatusOK, rec.Code) + require.Contains(t, rec.Body.String(), "hello") + + rec = h.do(t, http.MethodPost, base+"/pages/"+pageID+"/draft/publish", userID, nil) + require.Equal(t, http.StatusCreated, rec.Code) + var page model.Page + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &page)) + require.Equal(t, pageID, page.Id, "publish preserves the reserved draft id") + + rec = h.do(t, http.MethodGet, base+"/pages/"+pageID+"/draft", userID, nil) + require.Equal(t, http.StatusNotFound, rec.Code, "draft is deleted on publish") +} + +// TestHandler_ListSpaceDrafts covers the drafts listing: it is paginated like every other list +// endpoint, and it omits draft bodies so the listing does not ship whole documents. +func TestHandler_ListSpaceDrafts(t *testing.T) { + h := openTestPlugin(t, nil) + space := seedSpace(t, h.store, mmmodel.NewId()) + userID := mmmodel.NewId() + base := "/api/v1/spaces/" + space.Id + + for _, title := range []string{"First", "Second", "Third"} { + rec := h.do(t, http.MethodPost, base+"/drafts", userID, map[string]any{"title": title}) + require.Equal(t, http.StatusCreated, rec.Code) + } + + var listed struct { + Items []map[string]any `json:"items"` + Page int `json:"page"` + PerPage int `json:"per_page"` + HasMore bool `json:"has_more"` + } + + rec := h.do(t, http.MethodGet, base+"/drafts?page=0&per_page=2", userID, nil) + require.Equal(t, http.StatusOK, rec.Code) + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &listed)) + require.Len(t, listed.Items, 2) + require.True(t, listed.HasMore, "a third draft remains") + require.NotContains(t, listed.Items[0], "body", "the listing must not ship draft bodies") + + rec = h.do(t, http.MethodGet, base+"/drafts?page=1&per_page=2", userID, nil) + require.Equal(t, http.StatusOK, rec.Code) + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &listed)) + require.Len(t, listed.Items, 1) + require.False(t, listed.HasMore, "the last page reports no more") +} + +// TestHandler_PublishMalformedBodyReturns400 is the regression for the unchecked-decode bug: a +// malformed publish body must be rejected with 400 and must NOT publish the draft. +func TestHandler_PublishMalformedBodyReturns400(t *testing.T) { + h := openTestPlugin(t, nil) + channelID := mmmodel.NewId() + space := seedSpace(t, h.store, channelID) + userID := mmmodel.NewId() + base := "/api/v1/spaces/" + space.Id + + rec := h.do(t, http.MethodPost, base+"/drafts", userID, map[string]any{"title": "Doc"}) + require.Equal(t, http.StatusCreated, rec.Code) + var draft model.Draft + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &draft)) + + // Raw, truncated JSON body — h.do would marshal a value, so issue the request directly. + req := httptest.NewRequest(http.MethodPost, base+"/pages/"+draft.PageId+"/draft/publish", bytes.NewReader([]byte(`{"force":tru`))) + req.Header.Set("Mattermost-User-ID", userID) + malformed := httptest.NewRecorder() + h.plugin.ServeHTTP(&plugin.Context{}, malformed, req) + require.Equal(t, http.StatusBadRequest, malformed.Code) + + // The draft must still exist (publish did not run). + rec = h.do(t, http.MethodGet, base+"/pages/"+draft.PageId+"/draft", userID, nil) + require.Equal(t, http.StatusOK, rec.Code, "a malformed publish body must not publish or delete the draft") +} + +// TestHandler_UpdatePageDraftRequiresExistingDraft confirms the update-only guard: PUT on a page id +// that has no existing draft must return 404 rather than silently creating one. +func TestHandler_UpdatePageDraftRequiresExistingDraft(t *testing.T) { + h := openTestPlugin(t, nil) + space := seedSpace(t, h.store, mmmodel.NewId()) + userID := mmmodel.NewId() + + // No draft has been created for this page id — the PUT must be rejected. + rec := h.do(t, http.MethodPut, "/api/v1/spaces/"+space.Id+"/pages/"+mmmodel.NewId()+"/draft", userID, map[string]any{ + "title": "ghost", + }) + require.Equal(t, http.StatusNotFound, rec.Code) +} + +// TestHandler_UpdatePageDraftClearsParentWhenParentIdIsEmptyString covers the null-vs-empty +// distinction: sending parent_id: "" in the PUT body must clear an existing parent (set it to +// root), while omitting parent_id entirely must leave the parent unchanged. +func TestHandler_UpdatePageDraftClearsParentWhenParentIdIsEmptyString(t *testing.T) { + h := openTestPlugin(t, nil) + space := seedSpace(t, h.store, mmmodel.NewId()) + userID := mmmodel.NewId() + base := "/api/v1/spaces/" + space.Id + + // Create the parent draft. + rec := h.do(t, http.MethodPost, base+"/drafts", userID, map[string]any{"title": "Parent"}) + require.Equal(t, http.StatusCreated, rec.Code) + var parentDraft model.Draft + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &parentDraft)) + + // Create the child draft under the parent. + rec = h.do(t, http.MethodPost, base+"/drafts", userID, map[string]any{ + "title": "Child", + "parent_id": parentDraft.PageId, + }) + require.Equal(t, http.StatusCreated, rec.Code) + var childDraft model.Draft + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &childDraft)) + require.Equal(t, parentDraft.PageId, childDraft.ParentId) + + // PUT with parent_id omitted must preserve the existing parent. + rec = h.do(t, http.MethodPut, base+"/pages/"+childDraft.PageId+"/draft", userID, map[string]any{ + "title": "Child updated", + }) + require.Equal(t, http.StatusOK, rec.Code) + var saved model.Draft + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &saved)) + require.Equal(t, parentDraft.PageId, saved.ParentId, "omitting parent_id must preserve the existing parent") + + // PUT with parent_id: "" must clear the parent to root. + rec = h.do(t, http.MethodPut, base+"/pages/"+childDraft.PageId+"/draft", userID, map[string]any{ + "parent_id": "", + }) + require.Equal(t, http.StatusOK, rec.Code) + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &saved)) + require.Empty(t, saved.ParentId, `parent_id: "" must clear the parent`) +} + +// TestHandler_UpdatePageDraftCreatesForExistingPage drives the existing-page edit flow: publish a +// new-page draft to get a live page, then open an edit session by PUT .../draft with the page's +// EditAt baseline in props. Verifies the draft is created, autosave updates it, publish succeeds +// (edit path → 200), and the draft is gone afterwards. +func TestHandler_UpdatePageDraftCreatesForExistingPage(t *testing.T) { + h := openTestPlugin(t, nil) + channelID := mmmodel.NewId() + space := seedSpace(t, h.store, channelID) + userID := mmmodel.NewId() + base := "/api/v1/spaces/" + space.Id + + // Step 1: create a new-page draft and publish it to get a live page. + rec := h.do(t, http.MethodPost, base+"/drafts", userID, map[string]any{"title": "Original"}) + require.Equal(t, http.StatusCreated, rec.Code) + var draft model.Draft + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &draft)) + pageID := draft.PageId + + rec = h.do(t, http.MethodPost, base+"/pages/"+pageID+"/draft/publish", userID, nil) + require.Equal(t, http.StatusCreated, rec.Code) + var page model.Page + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &page)) + require.Equal(t, pageID, page.Id) + + // Step 2: open an edit session — first PUT creates the draft for an existing page. + rec = h.do(t, http.MethodPut, base+"/pages/"+pageID+"/draft", userID, map[string]any{ + "title": "Original", + "props": mmmodel.StringInterface{model.DraftPropsOriginalPageEditAt: page.EditAt}, + }) + require.Equal(t, http.StatusOK, rec.Code) + var editDraft model.Draft + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &editDraft)) + require.Equal(t, pageID, editDraft.PageId) + _, hasBaseline := editDraft.EditBaseline() + require.True(t, hasBaseline, "draft must carry the original_page_edit_at baseline so publish can detect conflicts") + + // Step 3: autosave new content. + rec = h.do(t, http.MethodPut, base+"/pages/"+pageID+"/draft", userID, map[string]any{ + "title": "Edited", + "body": `{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"updated"}]}]}`, + }) + require.Equal(t, http.StatusOK, rec.Code) + + // Step 4: publish the edit — edit path returns 200 (not 201). + rec = h.do(t, http.MethodPost, base+"/pages/"+pageID+"/draft/publish", userID, nil) + require.Equal(t, http.StatusOK, rec.Code, "edit-path publish must return 200") + var updated model.Page + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &updated)) + require.Equal(t, pageID, updated.Id) + require.Equal(t, "Edited", updated.Title) + + // Step 5: the draft must be gone after publish. + rec = h.do(t, http.MethodGet, base+"/pages/"+pageID+"/draft", userID, nil) + require.Equal(t, http.StatusNotFound, rec.Code, "draft must be deleted on publish") +} + +// TestHandler_PublishConflict409 covers the stale-baseline 409: when user B publishes an edit +// that advances the page's EditAt, user A's publish against the original baseline must return 409. +func TestHandler_PublishConflict409(t *testing.T) { + h := openTestPlugin(t, nil) + channelID := mmmodel.NewId() + space := seedSpace(t, h.store, channelID) + userA := mmmodel.NewId() + userB := mmmodel.NewId() + base := "/api/v1/spaces/" + space.Id + + // Create a new-page draft and publish it to get a live page. + rec := h.do(t, http.MethodPost, base+"/drafts", userA, map[string]any{"title": "Shared Doc"}) + require.Equal(t, http.StatusCreated, rec.Code) + var draft model.Draft + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &draft)) + pageID := draft.PageId + + rec = h.do(t, http.MethodPost, base+"/pages/"+pageID+"/draft/publish", userA, nil) + require.Equal(t, http.StatusCreated, rec.Code) + var page model.Page + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &page)) + editAt := page.EditAt + + // User A and user B both open edit sessions against the same baseline. + rec = h.do(t, http.MethodPut, base+"/pages/"+pageID+"/draft", userA, map[string]any{ + "title": "Edit by A", + "props": mmmodel.StringInterface{model.DraftPropsOriginalPageEditAt: editAt}, + }) + require.Equal(t, http.StatusOK, rec.Code) + + rec = h.do(t, http.MethodPut, base+"/pages/"+pageID+"/draft", userB, map[string]any{ + "title": "Edit by B", + "props": mmmodel.StringInterface{model.DraftPropsOriginalPageEditAt: editAt}, + }) + require.Equal(t, http.StatusOK, rec.Code) + + // User B publishes first, advancing the page's EditAt. + rec = h.do(t, http.MethodPost, base+"/pages/"+pageID+"/draft/publish", userB, nil) + require.Equal(t, http.StatusOK, rec.Code, "user B edit-path publish must succeed") + + // User A publishes against the now-stale baseline — must get 409. + rec = h.do(t, http.MethodPost, base+"/pages/"+pageID+"/draft/publish", userA, nil) + require.Equal(t, http.StatusConflict, rec.Code, "stale baseline must return 409 Conflict") +} + +// TestHandler_DeletePageDraft verifies the DELETE endpoint: 204 on success, draft is gone +// afterwards, and 404 when no draft exists. +func TestHandler_DeletePageDraft(t *testing.T) { + h := openTestPlugin(t, nil) + space := seedSpace(t, h.store, mmmodel.NewId()) + userID := mmmodel.NewId() + base := "/api/v1/spaces/" + space.Id + + // Create a new-page draft. + rec := h.do(t, http.MethodPost, base+"/drafts", userID, map[string]any{"title": "To Delete"}) + require.Equal(t, http.StatusCreated, rec.Code) + var draft model.Draft + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &draft)) + pageID := draft.PageId + + // DELETE the draft — must return 204. + rec = h.do(t, http.MethodDelete, base+"/pages/"+pageID+"/draft", userID, nil) + require.Equal(t, http.StatusNoContent, rec.Code) + + // GET after delete must return 404. + rec = h.do(t, http.MethodGet, base+"/pages/"+pageID+"/draft", userID, nil) + require.Equal(t, http.StatusNotFound, rec.Code, "draft must be gone after delete") + + // DELETE again on a missing draft must return 404. + rec = h.do(t, http.MethodDelete, base+"/pages/"+pageID+"/draft", userID, nil) + require.Equal(t, http.StatusNotFound, rec.Code, "deleting a non-existent draft must return 404") +} + +// TestHandler_ActiveEditorsWrongSpaceReturns404 is the regression for the cross-space presence +// leak: a page in one space must not be reachable through another space's active-editors route. +func TestHandler_ActiveEditorsWrongSpaceReturns404(t *testing.T) { + h := openTestPlugin(t, nil) + spaceA := seedSpace(t, h.store, mmmodel.NewId()) + spaceB := seedSpace(t, h.store, mmmodel.NewId()) + pageInB := seedPage(t, h.store, spaceB.Id, spaceB.ChannelId, "") + userID := mmmodel.NewId() + + rec := h.do(t, http.MethodGet, "/api/v1/spaces/"+spaceA.Id+"/pages/"+pageInB.Id+"/active-editors", userID, nil) + require.Equal(t, http.StatusNotFound, rec.Code, "a page must not resolve through another space's route") + + rec = h.do(t, http.MethodGet, "/api/v1/spaces/"+spaceB.Id+"/pages/"+pageInB.Id+"/active-editors", userID, nil) + require.Equal(t, http.StatusOK, rec.Code) +} + +// TestHandler_ActiveEditorsResponseBody verifies the JSON payload of the active-editors endpoint: +// an empty list when no draft is open, and the editor's user ID when they have an active draft. +func TestHandler_ActiveEditorsResponseBody(t *testing.T) { + h := openTestPlugin(t, nil) + space := seedSpace(t, h.store, mmmodel.NewId()) + userID := mmmodel.NewId() + base := "/api/v1/spaces/" + space.Id + + // Publish a new-page draft to get a live page. + rec := h.do(t, http.MethodPost, base+"/drafts", userID, map[string]any{"title": "Presence Test"}) + require.Equal(t, http.StatusCreated, rec.Code) + var draft model.Draft + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &draft)) + pageID := draft.PageId + + rec = h.do(t, http.MethodPost, base+"/pages/"+pageID+"/draft/publish", userID, nil) + require.Equal(t, http.StatusCreated, rec.Code) + var page model.Page + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &page)) + + // No edit draft open yet — active_editors must be an empty list, not null. + rec = h.do(t, http.MethodGet, base+"/pages/"+pageID+"/active-editors", userID, nil) + require.Equal(t, http.StatusOK, rec.Code) + var resp struct { + ActiveEditors []string `json:"active_editors"` + } + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + require.NotNil(t, resp.ActiveEditors) + require.Empty(t, resp.ActiveEditors) + + // Open an edit draft — the user must now appear as an active editor. + rec = h.do(t, http.MethodPut, base+"/pages/"+pageID+"/draft", userID, map[string]any{ + "title": "Presence Test", + "props": mmmodel.StringInterface{model.DraftPropsOriginalPageEditAt: page.EditAt}, + }) + require.Equal(t, http.StatusOK, rec.Code) + + rec = h.do(t, http.MethodGet, base+"/pages/"+pageID+"/active-editors", userID, nil) + require.Equal(t, http.StatusOK, rec.Code) + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + require.Contains(t, resp.ActiveEditors, userID, "user with an open edit draft must appear as an active editor") +} diff --git a/server/api_page_presence.go b/server/api_page_presence.go new file mode 100644 index 0000000..b88b734 --- /dev/null +++ b/server/api_page_presence.go @@ -0,0 +1,34 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package main + +import ( + "net/http" + + "github.com/gorilla/mux" +) + +// handleGetPageActiveEditors handles +// GET /api/v1/spaces/{space_id}/pages/{page_id}/active-editors +// Returns the user IDs currently active on the page. +func (p *Plugin) handleGetPageActiveEditors(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + spaceID := vars["space_id"] + pageID := vars["page_id"] + userID := userIDFromRequest(r) + + if _, ok := p.requireSpaceMembership(w, spaceID, userID, false); !ok { + return + } + + editors, appErr := p.service.GetPageActiveEditors(pageID, spaceID) + if appErr != nil { + p.writeAppError(w, appErr) + return + } + + writeJSON(w, http.StatusOK, struct { + ActiveEditors []string `json:"active_editors"` + }{ActiveEditors: editors}) +} diff --git a/server/app/export_test.go b/server/app/export_test.go new file mode 100644 index 0000000..4d28349 --- /dev/null +++ b/server/app/export_test.go @@ -0,0 +1,7 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package app + +// This file exposes private Service internals to package-level tests. Add *ForTest accessors here +// as needed. diff --git a/server/app/page.go b/server/app/page.go index 75af6e1..523630c 100644 --- a/server/app/page.go +++ b/server/app/page.go @@ -20,7 +20,7 @@ const MaxPageDepth = 10 // CreatePage creates a new page in spaceID. ChannelId is derived from the space, not supplied by the caller. // The page ID is always server-generated; callers must not supply one. -func (s *Service) CreatePage(spaceID, parentID, title, body, searchText, userID string) (*model.Page, *mmmodel.AppError) { +func (s *Service) CreatePage(spaceID, parentID, title, body, userID string) (*model.Page, *mmmodel.AppError) { if !mmmodel.IsValidId(spaceID) { return nil, mmmodel.NewAppError("CreatePage", "app.page.create.invalid_space_id.app_error", nil, "", http.StatusBadRequest) } @@ -31,10 +31,11 @@ func (s *Service) CreatePage(spaceID, parentID, title, body, searchText, userID if titleErr != nil { return nil, titleErr } - // SearchText is the body's plain-text projection, so it makes no sense without a body - // (matches the update path's rule). - if searchText != "" && body == "" { - return nil, mmmodel.NewAppError("CreatePage", "app.page.create.search_text_without_content.app_error", nil, "", http.StatusBadRequest) + // Validate and normalize the TipTap body and derive SearchText from it. SearchText is the body's + // server-derived projection, so it is never taken from the caller. + normBody, normSearch, contentErr := normalizePageContent("CreatePage", body) + if contentErr != nil { + return nil, contentErr } // Space existence and liveness are validated by store.CreatePage itself (surfaced as @@ -49,14 +50,13 @@ func (s *Service) CreatePage(spaceID, parentID, title, body, searchText, userID } } - // Body is stored as-is (TipTap validation/normalization and SearchText deferred). page := &model.Page{ SpaceId: spaceID, ParentId: parentID, Type: model.PageTypePage, Title: title, - Body: body, - SearchText: searchText, + Body: normBody, + SearchText: normSearch, UserId: userID, LastModifiedBy: userID, } @@ -112,6 +112,11 @@ func (s *Service) UpdatePage(pageID, spaceID string, patch *model.PagePatch, bas if appErr := requireBaseline("UpdatePage", "base_edit_at", baseEditAt, force); appErr != nil { return nil, appErr } + // Validate/normalize the TipTap body (and recompute SearchText) before patch validation, so a + // body-only patch is valid and a direct edit is sanitized on the same content path as publish. + if contentErr := normalizePatchContent("UpdatePage", patch); contentErr != nil { + return nil, contentErr + } if validErr := normalizeAndValidatePagePatch("UpdatePage", patch); validErr != nil { return nil, validErr } @@ -299,37 +304,30 @@ func buildDuplicatePages(source *model.Page, descendants []*model.Page, destSpac rootID := mmmodel.NewId() idMap := map[string]string{source.Id: rootID} pages := make([]*model.Page, 0, 1+len(descendants)) - pages = append(pages, &model.Page{ - Id: rootID, - SpaceId: destSpaceID, - ParentId: destParentID, - Type: source.Type, - Title: copyTitle(source.Title), - Body: source.Body, - SearchText: source.SearchText, - Props: maps.Clone(source.Props), - UserId: userID, - LastModifiedBy: userID, - }) + pages = append(pages, clonePageFields(source, rootID, destSpaceID, destParentID, copyTitle(source.Title), userID)) for _, d := range descendants { newID := mmmodel.NewId() - pages = append(pages, &model.Page{ - Id: newID, - SpaceId: destSpaceID, - ParentId: idMap[d.ParentId], - Type: d.Type, - Title: d.Title, - Body: d.Body, - SearchText: d.SearchText, - Props: maps.Clone(d.Props), - UserId: userID, - LastModifiedBy: userID, - }) + pages = append(pages, clonePageFields(d, newID, destSpaceID, idMap[d.ParentId], d.Title, userID)) idMap[d.Id] = newID } return pages } +func clonePageFields(src *model.Page, id, spaceID, parentID, title, userID string) *model.Page { + return &model.Page{ + Id: id, + SpaceId: spaceID, + ParentId: parentID, + Type: src.Type, + Title: title, + Body: src.Body, + SearchText: src.SearchText, + Props: maps.Clone(src.Props), + UserId: userID, + LastModifiedBy: userID, + } +} + // copyTitle prefixes "Copy of " and truncates to the page-title cap so the duplicate's title // always passes CreatePage validation. func copyTitle(original string) string { diff --git a/server/app/page_content.go b/server/app/page_content.go new file mode 100644 index 0000000..dfeb55c --- /dev/null +++ b/server/app/page_content.go @@ -0,0 +1,114 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package app + +import ( + "encoding/json" + "net/http" + "strings" + "unicode" + + mmmodel "github.com/mattermost/mattermost/server/public/model" + "github.com/pkg/errors" + + "github.com/mattermost/mattermost-plugin-docs/server/model" +) + +// normalizePageContent validates and normalizes a page body, deriving SearchText from it. Returns a +// 400 AppError when the body is not valid TipTap content. +// +// An empty body ("") is returned as-is, representing "no content"; the draft path instead seeds new +// pages with model.EmptyTipTapJSON (a rendered-empty document). These are two DISTINCT empty +// representations, and consumers may assign them different meaning: the publish path treats "" as +// "field not sent, preserve the existing page body" and EmptyTipTapJSON as "explicitly cleared". +func normalizePageContent(where, body string) (string, string, *mmmodel.AppError) { + normBody, searchText, err := validateAndNormalizeContent(body) + if err != nil { + return "", "", mmmodel.NewAppError(where, "app.page.invalid_content.app_error", nil, "", http.StatusBadRequest).Wrap(err) + } + return normBody, searchText, nil +} + +// normalizePatchContent normalizes a page patch's Body in place when present, recomputing +// SearchText from it (SearchText is the body's server-derived projection). A patch that sets Body +// has its caller-supplied SearchText overwritten by the derived value; a patch that does not touch +// Body is left unchanged (a SearchText-without-Body patch is rejected later by PagePatch.IsValid). A +// nil patch is a no-op, matching PagePatch's own contract. +func normalizePatchContent(where string, patch *model.PagePatch) *mmmodel.AppError { + if patch == nil || patch.Body == nil { + return nil + } + normBody, searchText, appErr := normalizePageContent(where, *patch.Body) + if appErr != nil { + return appErr + } + patch.Body = &normBody + patch.SearchText = &searchText + return nil +} + +// validateAndNormalizeContent validates and normalizes TipTap/plain-text page content. +// Returns (normalizedBody, searchText, error). An empty content string is returned as-is (no-op). +func validateAndNormalizeContent(content string) (string, string, error) { + if content == "" { + return content, "", nil + } + // Treat the body as TipTap only when it is actually valid JSON: a plain-text body that merely + // starts with "{" (e.g. "{shrug}") is not JSON and must be wrapped, not rejected. A body that is + // valid JSON but not a "doc" is a genuine content error and ParseTipTapDocument rejects it. + idx := strings.IndexFunc(content, func(r rune) bool { return !unicode.IsSpace(r) }) + if idx >= 0 && content[idx] == '{' { + doc, err := model.ParseTipTapDocument(content) + if err == nil { + return marshalTipTapDoc(doc) + } + var syntaxErr *json.SyntaxError + if !errors.As(err, &syntaxErr) { + return "", "", err + } + // SyntaxError → not valid JSON → fall through to plain-text wrapping. + } + // Non-JSON content: wrap in a minimal TipTap doc. + doc, err := convertPlainTextToTipTap(content) + if err != nil { + return "", "", err + } + return marshalTipTapDoc(doc) +} + +// marshalTipTapDoc serializes a TipTapDocument and derives its search text. +func marshalTipTapDoc(doc model.TipTapDocument) (string, string, error) { + sanitized, err := json.Marshal(doc) + if err != nil { + return "", "", err + } + return string(sanitized), model.BuildSearchText(doc), nil +} + +// maxPlainTextParagraphs caps the number of paragraph nodes produced when wrapping plain text. +// A newline-only body at PageBodyMaxBytes would otherwise produce ~2M maps before the +// post-normalization body-size check could reject the output. +const maxPlainTextParagraphs = 10_000 + +// convertPlainTextToTipTap wraps plain text in a minimal TipTap document. Returns an error when +// the input has more than maxPlainTextParagraphs newlines so that over-limit bodies are rejected +// with a clear error rather than silently truncated. +func convertPlainTextToTipTap(plainText string) (model.TipTapDocument, error) { + paragraphs := strings.SplitN(plainText, "\n", maxPlainTextParagraphs+1) + if len(paragraphs) > maxPlainTextParagraphs { + return model.TipTapDocument{}, errors.New("plain text body has too many paragraphs") + } + nodes := make([]map[string]any, 0, len(paragraphs)) + for _, para := range paragraphs { + node := map[string]any{"type": "paragraph"} + if strings.TrimSpace(para) != "" { + node["content"] = []any{map[string]any{"type": "text", "text": para}} + } + nodes = append(nodes, node) + } + return model.TipTapDocument{ + Type: model.TipTapDocType, + Content: nodes, + }, nil +} diff --git a/server/app/page_content_test.go b/server/app/page_content_test.go new file mode 100644 index 0000000..f8b8eb2 --- /dev/null +++ b/server/app/page_content_test.go @@ -0,0 +1,103 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package app + +import ( + "strings" + "testing" + + mmmodel "github.com/mattermost/mattermost/server/public/model" + "github.com/stretchr/testify/require" + + "github.com/mattermost/mattermost-plugin-docs/server/model" +) + +func TestValidateAndNormalizeContent(t *testing.T) { + t.Run("empty content is a no-op", func(t *testing.T) { + body, search, err := validateAndNormalizeContent("") + require.NoError(t, err) + require.Equal(t, "", body) + require.Equal(t, "", search) + }) + + t.Run("TipTap JSON is normalized and search text derived", func(t *testing.T) { + body, search, err := validateAndNormalizeContent(`{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"hello world"}]}]}`) + require.NoError(t, err) + require.Contains(t, body, "hello world") + require.Equal(t, "hello world", search) + }) + + t.Run("plain text is wrapped into a TipTap doc", func(t *testing.T) { + body, search, err := validateAndNormalizeContent("just text") + require.NoError(t, err) + require.True(t, strings.HasPrefix(body, `{"type":"doc"`), "plain text should be wrapped: %s", body) + require.Contains(t, body, "just text") + require.Equal(t, "just text", search) + }) + + t.Run("malformed TipTap JSON is rejected", func(t *testing.T) { + _, _, err := validateAndNormalizeContent(`{"type":"bogus"}`) + require.Error(t, err) + }) + + t.Run("javascript URL is stripped on normalization", func(t *testing.T) { + body, _, err := validateAndNormalizeContent(`{"type":"doc","content":[{"type":"text","text":"x","marks":[{"type":"link","attrs":{"href":"javascript:alert(1)"}}]}]}`) + require.NoError(t, err) + require.NotContains(t, body, "javascript:alert") + }) + + t.Run("string starting with { but not valid JSON is wrapped as plain text", func(t *testing.T) { + body, search, err := validateAndNormalizeContent("{shrug}") + require.NoError(t, err) + require.True(t, strings.HasPrefix(body, `{"type":"doc"`), "brace-leading non-JSON must be wrapped as plain text: %s", body) + require.Equal(t, "{shrug}", search) + }) + + t.Run("multiline plain text becomes multiple paragraphs", func(t *testing.T) { + body, _, err := validateAndNormalizeContent("line one\nline two") + require.NoError(t, err) + require.Contains(t, body, "line one") + require.Contains(t, body, "line two") + }) + + t.Run("plain text preserves leading whitespace within lines", func(t *testing.T) { + body, _, err := validateAndNormalizeContent(" indented line") + require.NoError(t, err) + require.Contains(t, body, `" indented line"`, "leading spaces must not be stripped from paragraph text") + }) +} + +func TestNormalizePatchContent(t *testing.T) { + t.Run("nil patch is a no-op", func(t *testing.T) { + require.Nil(t, normalizePatchContent("test", nil)) + }) + + t.Run("patch with nil Body is a no-op", func(t *testing.T) { + patch := &model.PagePatch{Title: mmmodel.NewPointer("title")} + require.Nil(t, normalizePatchContent("test", patch)) + require.Nil(t, patch.SearchText, "SearchText must remain nil when Body is unset") + }) + + t.Run("patch with valid Body normalizes and derives SearchText", func(t *testing.T) { + body := `{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"hello"}]}]}` + patch := &model.PagePatch{Body: mmmodel.NewPointer(body)} + require.Nil(t, normalizePatchContent("test", patch)) + require.NotNil(t, patch.SearchText) + require.Equal(t, "hello", *patch.SearchText) + }) + + t.Run("patch with invalid Body returns 400 AppError", func(t *testing.T) { + patch := &model.PagePatch{Body: mmmodel.NewPointer(`{"type":"bogus"}`)} + appErr := normalizePatchContent("test", patch) + require.NotNil(t, appErr) + require.Equal(t, 400, appErr.StatusCode) + }) + + t.Run("patch with Body strips dangerous URLs", func(t *testing.T) { + body := `{"type":"doc","content":[{"type":"text","text":"x","marks":[{"type":"link","attrs":{"href":"javascript:alert(1)"}}]}]}` + patch := &model.PagePatch{Body: mmmodel.NewPointer(body)} + require.Nil(t, normalizePatchContent("test", patch)) + require.NotContains(t, *patch.Body, "javascript:alert") + }) +} diff --git a/server/app/page_draft.go b/server/app/page_draft.go new file mode 100644 index 0000000..177f3fa --- /dev/null +++ b/server/app/page_draft.go @@ -0,0 +1,558 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package app + +import ( + "net/http" + "unicode/utf8" + + mmmodel "github.com/mattermost/mattermost/server/public/model" + + "github.com/mattermost/mattermost-plugin-docs/server/model" + "github.com/mattermost/mattermost-plugin-docs/server/store" +) + +// UpdatePageDraft upserts the calling user's autosave draft for a page in a space. channelID is +// the space's backing channel, used to scope the presence broadcast. +// +// PageId is the unified page id stable across the draft → publish lifecycle, so a draft may exist +// before the page is published. The space must exist and be live. The caller owns the draft: userID +// is always sourced from the request, never the request body. +// +// An autosave may omit fields the editor didn't change; omitted fields are preserved, so concurrent +// heartbeats cannot clobber each other's changes. +// parentID encodes the write intent for ParentId: nil preserves the stored value, a pointer to "" +// clears to root, and a pointer to a valid ID sets the parent. See store.UpsertDraft for details. +func (s *Service) UpdatePageDraft(draft *model.Draft, parentID *string, fileIDs *mmmodel.StringArray, channelID string) (*model.Draft, *mmmodel.AppError) { + if draft == nil { + return nil, mmmodel.NewAppError("UpdatePageDraft", "app.page_draft.update.nil_draft.app_error", nil, "", http.StatusBadRequest) + } + if !mmmodel.IsValidId(draft.UserId) { + return nil, mmmodel.NewAppError("UpdatePageDraft", "app.page_draft.update.invalid_user_id.app_error", nil, "", http.StatusBadRequest) + } + if !mmmodel.IsValidId(draft.SpaceId) { + return nil, mmmodel.NewAppError("UpdatePageDraft", "app.page_draft.update.invalid_space_id.app_error", nil, "", http.StatusBadRequest) + } + if !mmmodel.IsValidId(draft.PageId) { + return nil, mmmodel.NewAppError("UpdatePageDraft", "app.page_draft.update.invalid_page_id.app_error", nil, "", http.StatusBadRequest) + } + if parentID != nil && *parentID != "" && !mmmodel.IsValidId(*parentID) { + return nil, mmmodel.NewAppError("UpdatePageDraft", "app.page_draft.update.invalid_parent_id.app_error", nil, "", http.StatusBadRequest) + } + if draft.Title != "" { + title, titleErr := validateTitle("UpdatePageDraft", draft.Title, model.PageTitleMaxRunes) + if titleErr != nil { + return nil, titleErr + } + draft.Title = title + } + + // Sanitize the draft body on the same content path as publish, so a stored draft never holds + // unsanitized markup. Defense-in-depth: only the author can read a draft back today, but any + // future reader of Draft.Body inherits a sanitized value. + if draft.Body != "" { + sanitizedBody, _, contentErr := normalizePageContent("UpdatePageDraft", draft.Body) + if contentErr != nil { + return nil, contentErr + } + draft.Body = sanitizedBody + } + + // Only the optimistic-lock baseline is a recognized prop; drop anything else the client sent so + // it cannot accumulate in the stored map, which the store merges into rather than replaces. + draft.SanitizeProps() + + // Validate fileIDs size here because fileIDs is passed to the store separately and is not placed + // into draft.FileIds before IsValid runs — the store's UpsertDraft merges it in SQL. + if fileIDs != nil && len(*fileIDs) > 0 { + if utf8.RuneCountInString(mmmodel.ArrayToJSON([]string(*fileIDs))) > model.DraftFileIdsMaxRunes { + return nil, mmmodel.NewAppError("UpdatePageDraft", "model.draft.is_valid.file_ids.app_error", nil, "", http.StatusBadRequest) + } + for _, fileID := range *fileIDs { + if fileID != "" && !mmmodel.IsValidId(fileID) { + return nil, mmmodel.NewAppError("UpdatePageDraft", "app.page_draft.update.invalid_file_id.app_error", nil, "", http.StatusBadRequest) + } + } + } + + // pageIsLiveResolved tracks whether we already know the live-page answer from the + // not-found branch below (so we don't issue a second PageExistsInSpace after the upsert). + pageIsLive := false + pageIsLiveResolved := false + + existingDraft, existingDraftErr := s.store.GetDraft(draft.UserId, draft.PageId) + switch { + case existingDraftErr != nil && !store.IsErrNotFound(existingDraftErr): + return nil, storeAppError("UpdatePageDraft", existingDraftErr) + case existingDraftErr == nil && existingDraft.SpaceId != draft.SpaceId: + // Existing draft belongs to a different space: reject to prevent cross-space drift. + return nil, mmmodel.NewAppError("UpdatePageDraft", "app.page_draft.update.page_not_found.app_error", nil, "", http.StatusNotFound) + case store.IsErrNotFound(existingDraftErr): + // No draft for this user+page. Allow only if the page ID is "known" — either another + // user already reserved it via CreateSpaceDraft, or it is a published page in this space. + // This prevents PUT /spaces/X/pages//draft from ghost-drafting a non-existent page. + var existsErr error + pageIsLive, existsErr = s.store.PageExistsInSpace(draft.PageId, draft.SpaceId) + if existsErr != nil { + return nil, storeAppError("UpdatePageDraft", existsErr) + } + pageIsLiveResolved = true + if !pageIsLive { + anyDraft, anyErr := s.store.AnyDraftExistsForPageInSpace(draft.PageId, draft.SpaceId) + if anyErr != nil { + return nil, storeAppError("UpdatePageDraft", anyErr) + } + if !anyDraft { + return nil, mmmodel.NewAppError("UpdatePageDraft", "app.page_draft.update.page_not_found.app_error", nil, "", http.StatusNotFound) + } + } + } + + saved, err := s.store.UpsertDraft(draft, parentID, fileIDs) + if err != nil { + switch store.ConflictReason(err) { + case store.ReasonConcurrentEdit: + return nil, mmmodel.NewAppError("UpdatePageDraft", "app.page_draft.update.edit_conflict.app_error", + nil, "", http.StatusConflict).Wrap(err) + case store.ReasonConcurrentAutosave: + return nil, mmmodel.NewAppError("UpdatePageDraft", "app.page_draft.update.draft_changed.app_error", + nil, "", http.StatusConflict).Wrap(err) + } + return nil, storeAppError("UpdatePageDraft", err) + } + + // New-page drafts (no published page row yet) must not broadcast presence to the space channel: + // that would expose the reserved page ID and the author's identity to all space members before + // the page exists. Send the event only to the author so their own UI can track the session. + if !pageIsLiveResolved { + var pageExistsErr error + pageIsLive, pageExistsErr = s.store.PageExistsInSpace(saved.PageId, saved.SpaceId) + if pageExistsErr != nil { + s.log.Warn("UpdatePageDraft: failed to check page existence; skipping broadcast", + "page_id", saved.PageId, "err", pageExistsErr) + return saved, nil + } + } + if !pageIsLive { + s.publishSelfPresence(saved) + return saved, nil + } + + // Existing published page: rate-limited channel-wide broadcast so other viewers see this user + // in the active-editors indicator. + now := mmmodel.GetMillis() + existing, loaded := s.presenceBroadcastLast.LoadOrStore(saved.PageId, now) + if loaded { + lastTime, ok := existing.(int64) + if !ok || now-lastTime < presenceBroadcastMinIntervalMs { + return saved, nil + } + if !s.presenceBroadcastLast.CompareAndSwap(saved.PageId, existing, now) { + return saved, nil + } + } + s.broadcastPagePresence(saved.PageId, saved.SpaceId, channelID) + + return saved, nil +} + +// CreateSpaceDraft creates a new-page draft with a server-generated, reserved page id, so a new +// page has a stable link before it is published. title is required; pageParentID, when set, must be +// a published page in the space or an existing draft of the caller. +// The per-user-per-space draft quota is enforced atomically inside the store. +func (s *Service) CreateSpaceDraft(userID, spaceID, title, pageParentID string) (*model.Draft, *mmmodel.AppError) { + if !mmmodel.IsValidId(userID) { + return nil, mmmodel.NewAppError("CreateSpaceDraft", "app.page_draft.create.invalid_user_id.app_error", nil, "", http.StatusBadRequest) + } + if !mmmodel.IsValidId(spaceID) { + return nil, mmmodel.NewAppError("CreateSpaceDraft", "app.page_draft.create.invalid_space_id.app_error", nil, "", http.StatusBadRequest) + } + + title, titleErr := validateTitle("CreateSpaceDraft", title, model.PageTitleMaxRunes) + if titleErr != nil { + return nil, titleErr + } + + if pageParentID != "" { + if parentErr := s.validateDraftParent(userID, spaceID, pageParentID); parentErr != nil { + return nil, parentErr + } + } + + draft := &model.Draft{ + UserId: userID, + SpaceId: spaceID, + PageId: mmmodel.NewId(), + Title: title, + Body: model.EmptyTipTapJSON, + } + var parentPtr *string + if pageParentID != "" { + parentPtr = &pageParentID + } + + // Use UpsertDraft directly: the page row does not exist yet (new-page draft), so + // UpdatePageDraft's guard — which rejects drafts for non-existent pages on the autosave + // path — would incorrectly block this call. + saved, err := s.store.UpsertDraft(draft, parentPtr, nil) + if err != nil { + return nil, storeAppError("CreateSpaceDraft", err) + } + + // Broadcast only to the author: the page is not yet published, so broadcasting channel-wide + // would expose the reserved page ID and the author's identity to all space members. + s.publishSelfPresence(saved) + return saved, nil +} + +// validateDraftParent accepts a parent that is either a published page in spaceID or an existing +// draft of the caller in spaceID (which allows child drafts under not-yet-published parents). +func (s *Service) validateDraftParent(userID, spaceID, parentID string) *mmmodel.AppError { + if !mmmodel.IsValidId(parentID) { + return mmmodel.NewAppError("validateDraftParent", "app.page_draft.create.invalid_parent_id.app_error", nil, "", http.StatusBadRequest) + } + // Probe the parent space-scoped and collapse "not found" and "exists in another space" into one + // error, so a caller cannot use a distinct response to probe page ids in spaces it cannot read + // (matches validateParentExists). + pageExists, existsErr := s.store.PageExistsInSpace(parentID, spaceID) + if existsErr != nil { + return storeAppError("validateDraftParent", existsErr) + } + if pageExists { + return nil + } + // Not a published page in this space — accept only the caller's own draft in this space. + _, draftErr := s.GetPageDraft(userID, spaceID, parentID) + if draftErr == nil { + return nil + } + if draftErr.StatusCode != http.StatusNotFound { + return draftErr + } + return mmmodel.NewAppError("validateDraftParent", "app.page_draft.create.invalid_parent.app_error", nil, "", http.StatusBadRequest) +} + +// GetPageDraft returns the calling user's draft for the given page in the given space. Returns not-found +// when no draft exists for that user. +func (s *Service) GetPageDraft(userID, spaceID, pageID string) (*model.Draft, *mmmodel.AppError) { + if !mmmodel.IsValidId(userID) { + return nil, mmmodel.NewAppError("GetPageDraft", "app.page_draft.get.invalid_user_id.app_error", nil, "", http.StatusBadRequest) + } + if !mmmodel.IsValidId(spaceID) { + return nil, mmmodel.NewAppError("GetPageDraft", "app.page_draft.get.invalid_space_id.app_error", nil, "", http.StatusBadRequest) + } + if !mmmodel.IsValidId(pageID) { + return nil, mmmodel.NewAppError("GetPageDraft", "app.page_draft.get.invalid_page_id.app_error", nil, "", http.StatusBadRequest) + } + + draft, err := s.store.GetDraft(userID, pageID) + if err != nil { + if store.IsErrNotFound(err) { + return nil, mmmodel.NewAppError("GetPageDraft", "app.page_draft.get.not_found.app_error", nil, "", http.StatusNotFound).Wrap(err) + } + return nil, storeAppError("GetPageDraft", err) + } + + // Defend against a draft key collision across spaces: a draft is keyed by (UserId, PageId), + // so confirm it belongs to the space named in the request. + if draft.SpaceId != spaceID { + return nil, mmmodel.NewAppError("GetPageDraft", "app.page_draft.get.not_found.app_error", nil, "", http.StatusNotFound) + } + + return draft, nil +} + +// DeletePageDraft removes the calling user's draft for the given page (on publish or discard). +// Returns not-found when no draft exists. channelID is the space's backing channel, used to scope +// the presence broadcast. +func (s *Service) DeletePageDraft(userID, spaceID, pageID, channelID string) *mmmodel.AppError { + if !mmmodel.IsValidId(userID) { + return mmmodel.NewAppError("DeletePageDraft", "app.page_draft.delete.invalid_user_id.app_error", nil, "", http.StatusBadRequest) + } + if !mmmodel.IsValidId(spaceID) { + return mmmodel.NewAppError("DeletePageDraft", "app.page_draft.delete.invalid_space_id.app_error", nil, "", http.StatusBadRequest) + } + if !mmmodel.IsValidId(pageID) { + return mmmodel.NewAppError("DeletePageDraft", "app.page_draft.delete.invalid_page_id.app_error", nil, "", http.StatusBadRequest) + } + + // A draft is keyed by (UserId, PageId) without SpaceId, so confirm it belongs to the space + // named in the request before deleting — otherwise a member of another space could delete a + // draft here by passing this space's id with a foreign page id. + if _, appErr := s.GetPageDraft(userID, spaceID, pageID); appErr != nil { + return appErr + } + + // Discard is unconditional: the user wants the draft gone regardless of its current version. An + // autosave already in flight when the discard commits can still re-insert the draft afterward + // (an unpublished new-page draft has no page row for UpsertDraft's staleness guard to key on), so + // a discarded draft can briefly reappear. It is per-user and cleared by discarding again; fully + // preventing it would need a soft-delete tombstone, which is not warranted for this window. + // + if err := s.store.DeleteDraftReparenting(userID, pageID); err != nil { + // A concurrent publish/delete may have removed the draft between the check above and here; + // treat that benign race as a 404, matching the not-found path of the initial check, rather + // than a 500 that would also emit a spurious server-side error log. + if store.IsErrNotFound(err) { + return mmmodel.NewAppError("DeletePageDraft", "app.page_draft.delete.not_found.app_error", nil, "", http.StatusNotFound).Wrap(err) + } + return storeAppError("DeletePageDraft", err) + } + + // Presence cleanup: only broadcast channel-wide if the page is published. A new-page draft + // discard was never visible to the channel (no channel broadcast on create), so no cleanup + // broadcast is needed. + pageExists, pageExistsErr := s.store.PageExistsInSpace(pageID, spaceID) + if pageExistsErr != nil { + s.log.Warn("DeletePageDraft: failed to check page existence; skipping broadcast", + "page_id", pageID, "err", pageExistsErr) + return nil + } + if !pageExists { + return nil + } + s.presenceBroadcastLast.Delete(pageID) + s.broadcastPagePresence(pageID, spaceID, channelID) + + return nil +} + +// GetPageDraftsForSpace returns a page of the calling user's unpublished drafts for a space, newest +// first. Draft bodies are omitted from the listing. +func (s *Service) GetPageDraftsForSpace(userID, spaceID string, page, perPage int) ([]*model.DraftSummary, bool, *mmmodel.AppError) { + if !mmmodel.IsValidId(userID) { + return nil, false, mmmodel.NewAppError("GetPageDraftsForSpace", "app.page_draft.list.invalid_user_id.app_error", nil, "", http.StatusBadRequest) + } + if !mmmodel.IsValidId(spaceID) { + return nil, false, mmmodel.NewAppError("GetPageDraftsForSpace", "app.page_draft.list.invalid_space_id.app_error", nil, "", http.StatusBadRequest) + } + + // The store's liveness join excludes a soft-deleted space, so this need not re-check liveness. + offset, limit := paginationOffsetLimit(page, perPage) + drafts, err := s.store.GetDraftsForSpace(userID, spaceID, offset, limit) + if err != nil { + return nil, false, storeAppError("GetPageDraftsForSpace", err) + } + drafts, hasMore := trimPage(drafts, limit) + return drafts, hasMore, nil +} + +// PublishPageDraft publishes the calling user's draft for pageID in spaceID as a page. The draft +// is validated, new-vs-existing state is re-derived from the database (no client trust), and the +// page write + draft delete are committed in a single store transaction. +// Returns (page, wasCreated, appErr): +// - wasCreated=true → a new page was inserted by this call (handler should return 201) +// - wasCreated=false → an existing page was updated, or a concurrent create was adopted (return 200) +func (s *Service) PublishPageDraft(userID, spaceID, pageID string, force bool) (*model.Page, bool, *mmmodel.AppError) { + // 1. Fetch draft (idempotency guard: 404 = draft already published or discarded). + draft, appErr := s.GetPageDraft(userID, spaceID, pageID) + if appErr != nil { + if appErr.StatusCode == http.StatusNotFound { + return nil, false, mmmodel.NewAppError("PublishPageDraft", "app.page_draft.publish.draft_not_found.app_error", + nil, "", http.StatusNotFound).Wrap(appErr) + } + return nil, false, appErr + } + + // 2. Derive isNewPage from the database; never trust client state. + existing, existingErr := s.GetPageWithDeleted(pageID) + isNewPage := false + switch { + case existingErr != nil && existingErr.StatusCode == http.StatusNotFound: + isNewPage = true + case existingErr != nil: + return nil, false, existingErr + case existing.SpaceId != spaceID: + // The page id resolves to a page in another space (GetPageWithDeleted is not space-scoped). + // The caller is only authorized for spaceID, so this id is not publishable here. + return nil, false, mmmodel.NewAppError("PublishPageDraft", "app.page_draft.publish.conflict.app_error", + nil, "", http.StatusConflict) + case existing.DeleteAt != 0: + return nil, false, mmmodel.NewAppError("PublishPageDraft", "app.page_draft.publish.page_deleted.app_error", + nil, "", http.StatusConflict) + // default: live page in this space → edit path + } + + // 3. Parent guard (new-page path only): a new page's parent must be a published live page; a + // draft-only or non-live parent returns 409. The edit path never reparents, so a stale ParentId + // carried on the draft must not block a content-only edit-publish. + if isNewPage && draft.ParentId != "" { + parentPage, parentErr := s.GetPage(draft.ParentId) + if parentErr != nil { + if parentErr.StatusCode == http.StatusNotFound { + return nil, false, mmmodel.NewAppError("PublishPageDraft", "app.page_draft.publish.parent_unpublished.app_error", + nil, "", http.StatusConflict).Wrap(parentErr) + } + return nil, false, parentErr + } + if parentPage.SpaceId != spaceID { + return nil, false, mmmodel.NewAppError("PublishPageDraft", "app.page.create.parent_different_channel.app_error", + nil, "", http.StatusBadRequest) + } + } + + // 4. Validate and normalise draft body for the page write. + body, searchText, contentErr := normalizePageContent("PublishPageDraft", draft.Body) + if contentErr != nil { + return nil, false, contentErr + } + + // 5. Build the *model.Page for the store call. + var pageForWrite *model.Page + if isNewPage { + title, titleErr := validateTitle("PublishPageDraft", draft.Title, model.PageTitleMaxRunes) + if titleErr != nil { + return nil, false, titleErr + } + // ChannelId is derived by the store from the space, matching CreatePage, so it is + // intentionally left unset here. + pageForWrite = &model.Page{ + Id: pageID, + SpaceId: spaceID, + ParentId: draft.ParentId, + Title: title, + Body: body, + SearchText: searchText, + UserId: userID, + LastModifiedBy: userID, + } + } else { + // Edit path: require an optimistic-lock baseline unless force, so a client that never + // captured the page's EditAt cannot silently overwrite a concurrent edit. + baseEditAt, haveBaseline := draft.EditBaseline() + if !force && !haveBaseline { + return nil, false, mmmodel.NewAppError("PublishPageDraft", "app.page_draft.publish.baseline_required.app_error", + nil, "", http.StatusBadRequest) + } + // Carry only the fields the draft actually set; leave the rest empty. The store applies these + // against the row it locks FOR UPDATE and preserves its current value for any empty field, so + // an omitted field is never sourced from the pre-lock `existing` snapshot — otherwise a + // force-publish could revert a concurrent edit to a field this draft never touched. + // An empty draft body means "unset" (a cleared document is EmptyTipTapJSON, not ""), so an + // empty body leaves the live page's content intact rather than wiping it. + pageForWrite = &model.Page{ + Id: pageID, + SpaceId: spaceID, + LastModifiedBy: userID, + } + if draft.Title != "" { + title, titleErr := validateTitle("PublishPageDraft", draft.Title, model.PageTitleMaxRunes) + if titleErr != nil { + return nil, false, titleErr + } + pageForWrite.Title = title + } + if draft.Body != "" { + pageForWrite.Body = body + pageForWrite.SearchText = searchText + } + if haveBaseline { + pageForWrite.EditAt = baseEditAt + } + + // A draft that carries only an optimistic-lock baseline — no Title, no Body — has no page + // content to write. Publishing it would bump EditAt and emit page_updated with no actual + // change, invalidating other editors' baselines for nothing. Treat it as a discard instead: + // delete the draft and return the page as-is. + if pageForWrite.Title == "" && pageForWrite.Body == "" { + deleted, delErr := s.store.DeleteDraftVersion(userID, pageID, draft.UpdateAt) + if delErr != nil { + return nil, false, storeAppError("PublishPageDraft", delErr) + } + if !deleted { + // A concurrent autosave advanced the draft version between the read and the delete. + // The newer draft may have real content — the client must re-read and re-publish. + return nil, false, mmmodel.NewAppError("PublishPageDraft", "app.page_draft.publish.draft_changed.app_error", + nil, "", http.StatusConflict) + } + s.presenceBroadcastLast.Delete(pageID) + s.broadcastPagePresence(pageID, spaceID, existing.ChannelId) + return existing, false, nil + } + } + + // 6. Atomic write: page + draft-delete in one transaction. draft.UpdateAt is passed through so a + // concurrent autosave rolls this publish back as a conflict rather than shipping older content — + // see store.PublishDraft. + page, storeErr := s.store.PublishDraft(isNewPage, pageForWrite, userID, spaceID, force, MaxPageDepth, draft.UpdateAt) + if storeErr != nil { + switch { + // The draft moved under this publish: the caller's own editor autosaved after this call read it, + // so the whole write was rolled back rather than committing the older content. Distinct from the + // conflicts below — the client republishes to ship the newer draft, it does not re-baseline. + case store.ConflictReason(storeErr) == store.ReasonConcurrentAutosave: + return nil, false, mmmodel.NewAppError("PublishPageDraft", "app.page_draft.publish.draft_changed.app_error", + nil, "", http.StatusConflict).Wrap(storeErr) + + // Someone else edited the page since the baseline was captured. The client must re-read the page + // and publish against a fresh baseline (or force). + case store.ConflictReason(storeErr) == store.ReasonConcurrentEdit: + return nil, false, mmmodel.NewAppError("PublishPageDraft", "app.page_draft.publish.edit_conflict.app_error", + nil, "", http.StatusConflict).Wrap(storeErr) + + case store.IsErrConflict(storeErr): + if isNewPage { + // PK collision: a concurrent publish won this page id. Adopt the winner's page and + // return 200 without broadcasting (the winner already broadcast wsEventPageCreated). + // The winner must be a live page in this space — a different-space or already-deleted + // winner is not this caller's to read, so fall through to a plain conflict. + raced, rErr := s.GetPageWithDeleted(pageID) + if rErr == nil && raced != nil && raced.SpaceId == spaceID && raced.DeleteAt == 0 { + // Discard this caller's now-orphaned draft so it does not linger pointing at a + // published page — but only if it still holds the version this publish read. A fresh + // autosave landing after the race winner committed bumps UpdateAt, so the CAS matches + // no row and that newer draft is left intact rather than silently dropped. Cleanup is + // best-effort: the page is already published by the winner, so a failure here is logged + // (a stray draft the user can discard), never surfaced as a publish failure. + if _, delErr := s.store.DeleteDraftVersion(userID, pageID, draft.UpdateAt); delErr != nil { + s.log.Warn("PublishPageDraft: failed to delete orphaned draft after adopting race winner", + "page_id", pageID, "user_id", userID, "err", delErr) + } + // The draft is consumed; clear the rate-limit entry and broadcast presence so + // the active-editors indicator drops this user, matching the non-conflict path. + s.presenceBroadcastLast.Delete(pageID) + s.broadcastPagePresence(pageID, spaceID, raced.ChannelId) + return raced, false, nil + } + if rErr != nil { + // A real store failure here is not the same as losing the race; it would otherwise + // be indistinguishable from it in the response, so leave a trace. + s.log.Warn("PublishPageDraft: failed to read the page that won the publish race", + "page_id", pageID, "user_id", userID, "err", rErr) + } + } + return nil, false, mmmodel.NewAppError("PublishPageDraft", "app.page_draft.publish.conflict.app_error", + nil, "", http.StatusConflict).Wrap(storeErr) + case store.IsErrNotFound(storeErr): + // A concurrent delete removed the page or its parent between the pre-checks and the lock. + return nil, false, mmmodel.NewAppError("PublishPageDraft", "app.page_draft.publish.page_deleted.app_error", + nil, "", http.StatusConflict).Wrap(storeErr) + default: + return nil, false, storeAppError("PublishPageDraft", storeErr) + } + } + + // 7. Broadcast the write with the same shape and channel scope as the direct-CRUD page events. + // A publish-via-draft edit reuses page_updated — a client handles it identically to a direct PATCH. + // page_created includes parent_id so clients can place the new node in the tree without a fetch. + if isNewPage { + s.publishToChannels(wsEventPageCreated, map[string]any{ + "page_id": page.Id, + "space_id": page.SpaceId, + "parent_id": page.ParentId, + }, page.ChannelId) + } else { + s.publishToChannels(wsEventPageUpdated, map[string]any{ + "page_id": page.Id, + "space_id": page.SpaceId, + }, page.ChannelId) + } + // The publish deleted the draft inside PublishDraft (bypassing the app-level DeletePageDraft + // that normally broadcasts presence), so broadcast presence now so the active-editors indicator + // clears on other clients. Delete the rate-limit entry first so the broadcast is not suppressed. + s.presenceBroadcastLast.Delete(pageID) + s.broadcastPagePresence(pageID, spaceID, page.ChannelId) + + return page, isNewPage, nil +} diff --git a/server/app/page_draft_test.go b/server/app/page_draft_test.go new file mode 100644 index 0000000..b6b3af5 --- /dev/null +++ b/server/app/page_draft_test.go @@ -0,0 +1,759 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package app_test + +import ( + "fmt" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + mmmodel "github.com/mattermost/mattermost/server/public/model" + + "github.com/mattermost/mattermost-plugin-docs/server/model" + "github.com/mattermost/mattermost-plugin-docs/server/store" +) + +// docWith returns a minimal TipTap document whose paragraph contains text. +func docWith(text string) string { + return fmt.Sprintf(`{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":%q}]}]}`, text) +} + +// publishNewPage creates a new-page draft, autosaves the given body, and publishes it, returning +// the live page. It asserts the reserved draft id is preserved through publish (plan §5). +func publishNewPage(t *testing.T, h *testHarness, spaceID, userID, title, bodyText string) *model.Page { + t.Helper() + draft, appErr := h.svc.CreateSpaceDraft(userID, spaceID, title, "") + require.Nil(t, appErr) + reservedID := draft.PageId + + _, appErr = h.svc.UpdatePageDraft(&model.Draft{UserId: userID, SpaceId: spaceID, PageId: reservedID, Title: title, Body: docWith(bodyText)}, nil, nil, "") + require.Nil(t, appErr) + + page, wasCreated, appErr := h.svc.PublishPageDraft(userID, spaceID, reservedID, false) + require.Nil(t, appErr) + require.True(t, wasCreated, "publishing a brand-new page must report wasCreated=true") + require.Equal(t, reservedID, page.Id, "publish must preserve the reserved draft id") + require.Contains(t, page.Body, bodyText) + return page +} + +func TestUpdatePageDraftPreservesBodyOnTitleOnlyAutosave(t *testing.T) { + h := openTestService(t) + space := mustCreateSpace(t, h.store, mmmodel.NewId()) + userID := mmmodel.NewId() + + draft, appErr := h.svc.CreateSpaceDraft(userID, space.Id, "Doc", "") + require.Nil(t, appErr) + pageID := draft.PageId + + // Autosave real content. + _, appErr = h.svc.UpdatePageDraft(&model.Draft{UserId: userID, SpaceId: space.Id, PageId: pageID, Title: "Doc", Body: docWith("keep me")}, nil, nil, "") + require.Nil(t, appErr) + + // A heartbeat that sends only the title (empty body) must not wipe the stored draft body. + saved, appErr := h.svc.UpdatePageDraft(&model.Draft{UserId: userID, SpaceId: space.Id, PageId: pageID, Title: "Doc"}, nil, nil, "") + require.Nil(t, appErr) + require.Contains(t, saved.Body, "keep me", "a title-only autosave must not clear the draft body") +} + +func TestPublishEmptyDraftBodyDoesNotWipePage(t *testing.T) { + h := openTestService(t) + space := mustCreateSpace(t, h.store, mmmodel.NewId()) + userID := mmmodel.NewId() + + page := publishNewPage(t, h, space.Id, userID, "Important", "ORIGINAL") + + // Start an edit session whose first (and only) autosave carries the title but no body, exactly + // the heartbeat case that previously wiped the page on publish. + _, appErr := h.svc.UpdatePageDraft(&model.Draft{ + UserId: userID, SpaceId: space.Id, PageId: page.Id, Title: "Important", + Props: mmmodel.StringInterface{model.DraftPropsOriginalPageEditAt: float64(page.EditAt)}, + }, nil, nil, "") + require.Nil(t, appErr) + + republished, _, appErr := h.svc.PublishPageDraft(userID, space.Id, page.Id, false) + require.Nil(t, appErr) + require.Contains(t, republished.Body, "ORIGINAL", "publishing a title-only edit must preserve the page body") +} + +func TestPublishRejectsMissingBaselineOnEdit(t *testing.T) { + h := openTestService(t) + space := mustCreateSpace(t, h.store, mmmodel.NewId()) + userID := mmmodel.NewId() + + page := publishNewPage(t, h, space.Id, userID, "Doc", "v1") + + // UpsertDraft guards the first edit-draft save: attempting to create a draft for an existing + // page without original_page_edit_at is rejected at the store layer, so the client must send + // the baseline on the very first autosave (the response tells it to reload and set it). + _, appErr := h.svc.UpdatePageDraft(&model.Draft{UserId: userID, SpaceId: space.Id, PageId: page.Id, Title: "Doc", Body: docWith("v2")}, nil, nil, "") + require.NotNil(t, appErr) + require.Equal(t, http.StatusConflict, appErr.StatusCode) + + // With a proper baseline the draft is created; force=true publishes regardless of baseline. + _, appErr = h.svc.UpdatePageDraft(&model.Draft{ + UserId: userID, SpaceId: space.Id, PageId: page.Id, Title: "Doc", Body: docWith("v2"), + Props: mmmodel.StringInterface{model.DraftPropsOriginalPageEditAt: float64(page.EditAt)}, + }, nil, nil, "") + require.Nil(t, appErr) + + forced, _, appErr := h.svc.PublishPageDraft(userID, space.Id, page.Id, true) + require.Nil(t, appErr) + require.Contains(t, forced.Body, "v2") +} + +func TestPublishStaleBaselineConflicts(t *testing.T) { + h := openTestService(t) + space := mustCreateSpace(t, h.store, mmmodel.NewId()) + userID := mmmodel.NewId() + + page := publishNewPage(t, h, space.Id, userID, "Doc", "v1") + staleEditAt := page.EditAt + + // Start an edit session with a draft baselined at the current EditAt. The draft persists (it is + // not consumed by any publish), so the stale-baseline conflict surfaces at publish time. + _, appErr := h.svc.UpdatePageDraft(&model.Draft{ + UserId: userID, SpaceId: space.Id, PageId: page.Id, Title: "Doc", Body: docWith("v3"), + Props: mmmodel.StringInterface{model.DraftPropsOriginalPageEditAt: float64(staleEditAt)}, + }, nil, nil, "") + require.Nil(t, appErr) + + // A concurrent direct edit advances the page's EditAt out from under that baseline, without + // touching the draft. + concurrent := docWith("concurrent") + _, appErr = h.svc.UpdatePage(page.Id, space.Id, &model.PagePatch{Body: &concurrent}, new(staleEditAt), false, userID) + require.Nil(t, appErr) + + // Publishing the draft against the now-stale baseline must 409. + _, _, appErr = h.svc.PublishPageDraft(userID, space.Id, page.Id, false) + require.NotNil(t, appErr) + require.Equal(t, http.StatusConflict, appErr.StatusCode) +} + +// TestPublishAfterPageDeleteReturns404 verifies that deleting a page cascade-deletes its drafts, +// so a later publish finds no draft (404) rather than writing to a tombstone. The page-deleted 409 +// path in PublishPageDraft guards only the concurrent-delete race, which this flow cannot produce. +func TestPublishAfterPageDeleteReturns404(t *testing.T) { + h := openTestService(t) + space := mustCreateSpace(t, h.store, mmmodel.NewId()) + userID := mmmodel.NewId() + + page := publishNewPage(t, h, space.Id, userID, "Doomed", "x") + _, appErr := h.svc.UpdatePageDraft(&model.Draft{ + UserId: userID, SpaceId: space.Id, PageId: page.Id, Title: "Doomed", Body: docWith("y"), + Props: mmmodel.StringInterface{model.DraftPropsOriginalPageEditAt: float64(page.EditAt)}, + }, nil, nil, "") + require.Nil(t, appErr) + requireStoreDeletePage(t, h.store, page.Id, space.Id, userID) + + _, _, appErr = h.svc.PublishPageDraft(userID, space.Id, page.Id, false) + require.NotNil(t, appErr) + require.Equal(t, http.StatusNotFound, appErr.StatusCode) +} + +func TestDeletePageDraftRejectsWrongSpace(t *testing.T) { + h := openTestService(t) + spaceA := mustCreateSpace(t, h.store, mmmodel.NewId()) + spaceB := mustCreateSpace(t, h.store, mmmodel.NewId()) + userID := mmmodel.NewId() + + draft, appErr := h.svc.CreateSpaceDraft(userID, spaceA.Id, "In A", "") + require.Nil(t, appErr) + + // Deleting the draft while naming the wrong space must 404, and must not delete it. + delErr := h.svc.DeletePageDraft(userID, spaceB.Id, draft.PageId, "") + require.NotNil(t, delErr) + require.Equal(t, http.StatusNotFound, delErr.StatusCode) + + got, appErr := h.svc.GetPageDraft(userID, spaceA.Id, draft.PageId) + require.Nil(t, appErr, "draft must survive a delete attempt through the wrong space") + require.Equal(t, draft.PageId, got.PageId) +} + +func TestGetPageActiveEditorsRejectsWrongSpace(t *testing.T) { + h := openTestService(t) + spaceA := mustCreateSpace(t, h.store, mmmodel.NewId()) + spaceB := mustCreateSpace(t, h.store, mmmodel.NewId()) + userID := mmmodel.NewId() + + page := publishNewPage(t, h, spaceA.Id, userID, "Doc", "x") + + // The page lives in space A; querying its editors through space B must not resolve it. + _, appErr := h.svc.GetPageActiveEditors(page.Id, spaceB.Id) + require.NotNil(t, appErr) + require.Equal(t, http.StatusNotFound, appErr.StatusCode) + + // Through its own space it resolves (empty set — no active drafts). + editors, appErr := h.svc.GetPageActiveEditors(page.Id, spaceA.Id) + require.Nil(t, appErr) + require.Empty(t, editors) +} + +func TestActiveEditorsSurfacesHeartbeat(t *testing.T) { + h := openTestService(t) + space := mustCreateSpace(t, h.store, mmmodel.NewId()) + userID := mmmodel.NewId() + + page := publishNewPage(t, h, space.Id, userID, "Doc", "x") + + // An autosave is the heartbeat; the editor must then appear as active. + _, appErr := h.svc.UpdatePageDraft(&model.Draft{ + UserId: userID, SpaceId: space.Id, PageId: page.Id, Title: "Doc", Body: docWith("editing"), + Props: mmmodel.StringInterface{model.DraftPropsOriginalPageEditAt: float64(page.EditAt)}, + }, nil, nil, "") + require.Nil(t, appErr) + + editors, appErr := h.svc.GetPageActiveEditors(page.Id, space.Id) + require.Nil(t, appErr) + require.Contains(t, editors, userID) +} + +func TestPublishSetsLastModifiedBy(t *testing.T) { + h := openTestService(t) + space := mustCreateSpace(t, h.store, mmmodel.NewId()) + userID := mmmodel.NewId() + + page := publishNewPage(t, h, space.Id, userID, "Doc", "x") + require.Equal(t, userID, page.LastModifiedBy, "a page published via draft must record its author as last modifier") +} + +func TestPublishForceOverridesStaleBaseline(t *testing.T) { + h := openTestService(t) + space := mustCreateSpace(t, h.store, mmmodel.NewId()) + userID := mmmodel.NewId() + + page := publishNewPage(t, h, space.Id, userID, "Doc", "v1") + staleEditAt := page.EditAt + + // Start an edit session with a draft baselined at the current EditAt; the draft persists. + _, appErr := h.svc.UpdatePageDraft(&model.Draft{ + UserId: userID, SpaceId: space.Id, PageId: page.Id, Title: "Doc", Body: docWith("v3"), + Props: mmmodel.StringInterface{model.DraftPropsOriginalPageEditAt: float64(staleEditAt)}, + }, nil, nil, "") + require.Nil(t, appErr) + + // A concurrent direct edit advances the page's EditAt, making the draft's baseline stale. + concurrent := docWith("concurrent") + _, appErr = h.svc.UpdatePage(page.Id, space.Id, &model.PagePatch{Body: &concurrent}, new(staleEditAt), false, userID) + require.Nil(t, appErr) + + // force=true must override the stale-baseline CAS and win with the draft's content. + forced, _, appErr := h.svc.PublishPageDraft(userID, space.Id, page.Id, true) + require.Nil(t, appErr) + require.Contains(t, forced.Body, "v3", "force must override a stale baseline") +} + +func TestPublishForceDoesNotRevertUntouchedField(t *testing.T) { + h := openTestService(t) + space := mustCreateSpace(t, h.store, mmmodel.NewId()) + userID := mmmodel.NewId() + + page := publishNewPage(t, h, space.Id, userID, "Original title", "original body") + baseEditAt := page.EditAt + + // A title-only edit: the draft carries a new title but no body, baselined at the current EditAt. + _, appErr := h.svc.UpdatePageDraft(&model.Draft{ + UserId: userID, SpaceId: space.Id, PageId: page.Id, Title: "New title", + Props: mmmodel.StringInterface{model.DraftPropsOriginalPageEditAt: float64(baseEditAt)}, + }, nil, nil, "") + require.Nil(t, appErr) + + // A concurrent edit changes the BODY — a field the draft never touched — and advances EditAt. + concurrent := docWith("concurrent body") + _, appErr = h.svc.UpdatePage(page.Id, space.Id, &model.PagePatch{Body: &concurrent}, new(baseEditAt), false, userID) + require.Nil(t, appErr) + + // Force-publish the title-only draft: force overrides the stale baseline, but only the title may + // be applied. The concurrent body edit must survive rather than be reverted to the pre-lock value. + forced, _, appErr := h.svc.PublishPageDraft(userID, space.Id, page.Id, true) + require.Nil(t, appErr) + require.Equal(t, "New title", forced.Title, "the draft's title must be applied") + require.Contains(t, forced.Body, "concurrent body", "a field the draft never set must not be reverted") +} + +func TestUpdatePageDraftRejectsStaleBaselineAfterPublish(t *testing.T) { + h := openTestService(t) + space := mustCreateSpace(t, h.store, mmmodel.NewId()) + userID := mmmodel.NewId() + + page := publishNewPage(t, h, space.Id, userID, "Doc", "v1") + baseEditAt := page.EditAt + + // Edit and publish: the draft is consumed and the page's EditAt advances. + _, appErr := h.svc.UpdatePageDraft(&model.Draft{ + UserId: userID, SpaceId: space.Id, PageId: page.Id, Title: "Doc", Body: docWith("v2"), + Props: mmmodel.StringInterface{model.DraftPropsOriginalPageEditAt: float64(baseEditAt)}, + }, nil, nil, "") + require.Nil(t, appErr) + _, _, appErr = h.svc.PublishPageDraft(userID, space.Id, page.Id, false) + require.Nil(t, appErr) + + // A late autosave carrying the pre-publish baseline must be rejected, not resurrect a phantom + // draft on the now-published page. + _, appErr = h.svc.UpdatePageDraft(&model.Draft{ + UserId: userID, SpaceId: space.Id, PageId: page.Id, Title: "Doc", Body: docWith("late"), + Props: mmmodel.StringInterface{model.DraftPropsOriginalPageEditAt: float64(baseEditAt)}, + }, nil, nil, "") + require.NotNil(t, appErr) + require.Equal(t, http.StatusConflict, appErr.StatusCode) + + // No phantom draft was created. + _, appErr = h.svc.GetPageDraft(userID, space.Id, page.Id) + require.NotNil(t, appErr) + require.Equal(t, http.StatusNotFound, appErr.StatusCode) +} + +func TestUpdatePageDraftMergesPropsPreservingBaseline(t *testing.T) { + h := openTestService(t) + space := mustCreateSpace(t, h.store, mmmodel.NewId()) + userID := mmmodel.NewId() + + draft, appErr := h.svc.CreateSpaceDraft(userID, space.Id, "Doc", "") + require.Nil(t, appErr) + pageID := draft.PageId + + // First autosave records the optimistic-lock baseline prop. + saved, appErr := h.svc.UpdatePageDraft(&model.Draft{ + UserId: userID, SpaceId: space.Id, PageId: pageID, Title: "Doc", Body: docWith("v1"), + Props: mmmodel.StringInterface{model.DraftPropsOriginalPageEditAt: float64(1234)}, + }, nil, nil, "") + require.Nil(t, appErr) + require.EqualValues(t, 1234, saved.Props[model.DraftPropsOriginalPageEditAt]) + + // A later autosave that omits props must preserve the stored baseline (key-wise merge, no clobber). + saved, appErr = h.svc.UpdatePageDraft(&model.Draft{ + UserId: userID, SpaceId: space.Id, PageId: pageID, Title: "Doc", Body: docWith("v2"), + }, nil, nil, "") + require.Nil(t, appErr) + require.EqualValues(t, 1234, saved.Props[model.DraftPropsOriginalPageEditAt], "omitted props must preserve the stored baseline") +} + +func TestPublishRejectsForeignSpacePage(t *testing.T) { + h := openTestService(t) + spaceA := mustCreateSpace(t, h.store, mmmodel.NewId()) + spaceB := mustCreateSpace(t, h.store, mmmodel.NewId()) + userA, userB := mmmodel.NewId(), mmmodel.NewId() + + // userA reserves a page id in space A. + draftA, appErr := h.svc.CreateSpaceDraft(userA, spaceA.Id, "A doc", "") + require.Nil(t, appErr) + pageID := draftA.PageId + + // userB cannot reserve a draft in space B against userA's not-yet-live page id — + // the cross-space reservation is now correctly rejected at the app layer. + _, appErr = h.svc.UpdatePageDraft(&model.Draft{UserId: userB, SpaceId: spaceB.Id, PageId: pageID, Title: "B doc", Body: docWith("b content")}, nil, nil, "") + require.NotNil(t, appErr, "cross-space draft reservation must be rejected") + require.Equal(t, http.StatusNotFound, appErr.StatusCode, "cross-space draft reservation returns 404") + + // userA autosaves content and publishes, so the page becomes live in space A. + _, appErr = h.svc.UpdatePageDraft(&model.Draft{UserId: userA, SpaceId: spaceA.Id, PageId: pageID, Title: "A doc", Body: docWith("a content")}, nil, nil, "") + require.Nil(t, appErr) + pageA, wasCreated, appErr := h.svc.PublishPageDraft(userA, spaceA.Id, pageID, false) + require.Nil(t, appErr) + require.True(t, wasCreated) + + // userB has no draft for pageID (the cross-space reservation was rejected above), so publishing + // from space B must fail with 404 (draft not found). force cannot bypass this. + for _, force := range []bool{false, true} { + _, _, appErr = h.svc.PublishPageDraft(userB, spaceB.Id, pageID, force) + require.NotNil(t, appErr, "cross-space publish (force=%v) must fail", force) + require.Contains(t, []int{http.StatusNotFound, http.StatusConflict}, appErr.StatusCode, + "cross-space publish (force=%v) must be rejected", force) + } + + // Space A's page is unchanged. + stillA, appErr := h.svc.GetPageInSpace("test", pageID, spaceA.Id, false) + require.Nil(t, appErr) + require.Contains(t, stillA.Body, "a content") + require.Equal(t, pageA.EditAt, stillA.EditAt, "cross-space publish must not have modified space A's page") +} + +func TestUpdatePageDraftPreservesTitleOnBodyOnlyAutosave(t *testing.T) { + h := openTestService(t) + space := mustCreateSpace(t, h.store, mmmodel.NewId()) + userID := mmmodel.NewId() + + draft, appErr := h.svc.CreateSpaceDraft(userID, space.Id, "Keep Title", "") + require.Nil(t, appErr) + pageID := draft.PageId + + _, appErr = h.svc.UpdatePageDraft(&model.Draft{UserId: userID, SpaceId: space.Id, PageId: pageID, Title: "Keep Title", Body: docWith("v1")}, nil, nil, "") + require.Nil(t, appErr) + + // A heartbeat that sends only the body (empty title) must not wipe the stored title. + saved, appErr := h.svc.UpdatePageDraft(&model.Draft{UserId: userID, SpaceId: space.Id, PageId: pageID, Body: docWith("v2")}, nil, nil, "") + require.Nil(t, appErr) + require.Equal(t, "Keep Title", saved.Title, "a body-only autosave must not clear the draft title") +} + +func TestUpdatePageDraftSanitizesBody(t *testing.T) { + h := openTestService(t) + space := mustCreateSpace(t, h.store, mmmodel.NewId()) + userID := mmmodel.NewId() + + draft, appErr := h.svc.CreateSpaceDraft(userID, space.Id, "Doc", "") + require.Nil(t, appErr) + + // The autosave path must sanitize the body on the same content path as publish. + saved, appErr := h.svc.UpdatePageDraft(&model.Draft{ + UserId: userID, SpaceId: space.Id, PageId: draft.PageId, Title: "Doc", + Body: `{"type":"doc","content":[{"type":"image","attrs":{"src":"x","onerror":"alert(document.cookie)"}}]}`, + }, nil, nil, "") + require.Nil(t, appErr) + require.NotContains(t, saved.Body, "onerror", "autosave must sanitize the draft body") +} + +// TestPublishEditIgnoresStaleParentGuard reproduces the edit-path parent-guard bug: a live child +// page whose in-progress draft still carries a parent id that has since become non-live must still +// publish a content-only edit, because the edit path never reparents. +func TestPublishEditIgnoresStaleParentGuard(t *testing.T) { + h := openTestService(t) + space := mustCreateSpace(t, h.store, mmmodel.NewId()) + userID := mmmodel.NewId() + + parent := publishNewPage(t, h, space.Id, userID, "Parent", "p") + + // Publish a child under the (live) parent. + childDraft, appErr := h.svc.CreateSpaceDraft(userID, space.Id, "Child", parent.Id) + require.Nil(t, appErr) + childID := childDraft.PageId + _, appErr = h.svc.UpdatePageDraft(&model.Draft{UserId: userID, SpaceId: space.Id, PageId: childID, Title: "Child", Body: docWith("c1")}, nil, nil, "") + require.Nil(t, appErr) + child, _, appErr := h.svc.PublishPageDraft(userID, space.Id, childID, false) + require.Nil(t, appErr) + + // Start an edit session whose draft still carries the (currently live) parent id. + parentID := parent.Id + _, appErr = h.svc.UpdatePageDraft(&model.Draft{ + UserId: userID, SpaceId: space.Id, PageId: childID, Title: "Child", Body: docWith("c2"), + Props: mmmodel.StringInterface{model.DraftPropsOriginalPageEditAt: float64(child.EditAt)}, + }, &parentID, nil, "") + require.Nil(t, appErr) + + // The parent is deleted mid-edit (its children are promoted), leaving the draft's parent id stale. + requireStoreDeletePage(t, h.store, parent.Id, space.Id, userID) + + // A content-only edit-publish must still succeed: the edit path does not reparent. + republished, wasCreated, appErr := h.svc.PublishPageDraft(userID, space.Id, childID, false) + require.Nil(t, appErr, "edit-publish must not be blocked by a stale parent id") + require.False(t, wasCreated) + require.Contains(t, republished.Body, "c2") +} + +func TestUpdatePageDraftRejectsInvalidPageID(t *testing.T) { + h := openTestService(t) + space := mustCreateSpace(t, h.store, mmmodel.NewId()) + userID := mmmodel.NewId() + + _, appErr := h.svc.UpdatePageDraft(&model.Draft{UserId: userID, SpaceId: space.Id, PageId: "not-a-valid-id", Title: "x"}, nil, nil, "") + require.NotNil(t, appErr) + require.Equal(t, http.StatusBadRequest, appErr.StatusCode) +} + +func TestCreateSpaceDraftRejectsForeignDraftParent(t *testing.T) { + h := openTestService(t) + space := mustCreateSpace(t, h.store, mmmodel.NewId()) + userA, userB := mmmodel.NewId(), mmmodel.NewId() + + parent, appErr := h.svc.CreateSpaceDraft(userA, space.Id, "Parent", "") + require.Nil(t, appErr) + + // userB cannot parent a draft under userA's draft. + _, appErr = h.svc.CreateSpaceDraft(userB, space.Id, "Child", parent.PageId) + require.NotNil(t, appErr) + require.Equal(t, http.StatusBadRequest, appErr.StatusCode) + + // userA can, and the child then publishes only after the parent does. + child, appErr := h.svc.CreateSpaceDraft(userA, space.Id, "Child", parent.PageId) + require.Nil(t, appErr) + _, _, appErr = h.svc.PublishPageDraft(userA, space.Id, child.PageId, false) + require.NotNil(t, appErr) + require.Equal(t, http.StatusConflict, appErr.StatusCode, "child cannot publish under an unpublished parent") +} + +func TestUpdatePageDraftRejectsInvalidUserID(t *testing.T) { + h := openTestService(t) + space := mustCreateSpace(t, h.store, mmmodel.NewId()) + + _, appErr := h.svc.UpdatePageDraft(&model.Draft{UserId: "bad-id", SpaceId: space.Id, PageId: mmmodel.NewId()}, nil, nil, "") + require.NotNil(t, appErr) + require.Equal(t, http.StatusBadRequest, appErr.StatusCode) +} + +func TestUpdatePageDraftRejectsInvalidSpaceID(t *testing.T) { + h := openTestService(t) + + _, appErr := h.svc.UpdatePageDraft(&model.Draft{UserId: mmmodel.NewId(), SpaceId: "bad-id", PageId: mmmodel.NewId()}, nil, nil, "") + require.NotNil(t, appErr) + require.Equal(t, http.StatusBadRequest, appErr.StatusCode) +} + +func TestUpdatePageDraftPropWhitelistDropsForeignKeys(t *testing.T) { + h := openTestService(t) + space := mustCreateSpace(t, h.store, mmmodel.NewId()) + userID := mmmodel.NewId() + + draft, appErr := h.svc.CreateSpaceDraft(userID, space.Id, "Doc", "") + require.Nil(t, appErr) + + // UpdatePageDraft with an unrecognized prop key. + saved, appErr := h.svc.UpdatePageDraft(&model.Draft{ + UserId: userID, SpaceId: space.Id, PageId: draft.PageId, + Props: mmmodel.StringInterface{ + model.DraftPropsOriginalPageEditAt: float64(123), + "evil_key": "should be dropped", + }, + }, nil, nil, "") + require.Nil(t, appErr) + require.Equal(t, float64(123), saved.Props[model.DraftPropsOriginalPageEditAt], "allowed prop must be preserved") + _, hasForeign := saved.Props["evil_key"] + require.False(t, hasForeign, "unrecognized prop key must be stripped by the whitelist") +} + +func TestCreateSpaceDraftRejectsEmptyTitle(t *testing.T) { + h := openTestService(t) + space := mustCreateSpace(t, h.store, mmmodel.NewId()) + + _, appErr := h.svc.CreateSpaceDraft(mmmodel.NewId(), space.Id, "", "") + require.NotNil(t, appErr) + require.Equal(t, http.StatusBadRequest, appErr.StatusCode) +} + +func TestCreateSpaceDraftRejectsTitleOverCap(t *testing.T) { + h := openTestService(t) + space := mustCreateSpace(t, h.store, mmmodel.NewId()) + longTitle := strings.Repeat("x", model.PageTitleMaxRunes+1) + + _, appErr := h.svc.CreateSpaceDraft(mmmodel.NewId(), space.Id, longTitle, "") + require.NotNil(t, appErr) + require.Equal(t, http.StatusBadRequest, appErr.StatusCode) +} + +func TestCreateSpaceDraftRejectsMalformedParentID(t *testing.T) { + h := openTestService(t) + space := mustCreateSpace(t, h.store, mmmodel.NewId()) + + _, appErr := h.svc.CreateSpaceDraft(mmmodel.NewId(), space.Id, "Doc", "not-a-valid-id") + require.NotNil(t, appErr) + require.Equal(t, http.StatusBadRequest, appErr.StatusCode) +} + +// TestUpdatePageDraftRejectsResurrectionAfterNewPagePublish covers the else-if-!ok branch of the +// resurrection guard: a late autosave with no edit baseline (new-page path) must be rejected after +// the draft has already been consumed by publish, rather than recreating a phantom draft. +func TestUpdatePageDraftRejectsResurrectionAfterNewPagePublish(t *testing.T) { + h := openTestService(t) + space := mustCreateSpace(t, h.store, mmmodel.NewId()) + userID := mmmodel.NewId() + + draft, appErr := h.svc.CreateSpaceDraft(userID, space.Id, "Doc", "") + require.Nil(t, appErr) + pageID := draft.PageId + + _, appErr = h.svc.UpdatePageDraft(&model.Draft{UserId: userID, SpaceId: space.Id, PageId: pageID, Body: docWith("v1")}, nil, nil, "") + require.Nil(t, appErr) + _, _, appErr = h.svc.PublishPageDraft(userID, space.Id, pageID, false) + require.Nil(t, appErr) + + // Late autosave with no baseline: the draft is gone, so this must be rejected. + _, appErr = h.svc.UpdatePageDraft(&model.Draft{ + UserId: userID, SpaceId: space.Id, PageId: pageID, Body: docWith("late"), + }, nil, nil, "") + require.NotNil(t, appErr) + require.Equal(t, http.StatusConflict, appErr.StatusCode) + + // No phantom draft must have been created. + _, appErr = h.svc.GetPageDraft(userID, space.Id, pageID) + require.NotNil(t, appErr) + require.Equal(t, http.StatusNotFound, appErr.StatusCode) +} + +// TestUpdatePageDraftRejectsDraftParentCycle covers the cycle-detection guard: setting a draft's +// parent to a page that already (transitively) points back to the draft must be rejected. +func TestUpdatePageDraftRejectsDraftParentCycle(t *testing.T) { + h := openTestService(t) + space := mustCreateSpace(t, h.store, mmmodel.NewId()) + userID := mmmodel.NewId() + + draftA, appErr := h.svc.CreateSpaceDraft(userID, space.Id, "A", "") + require.Nil(t, appErr) + draftB, appErr := h.svc.CreateSpaceDraft(userID, space.Id, "B", "") + require.Nil(t, appErr) + + // B → A is valid (A has no parent). + _, appErr = h.svc.UpdatePageDraft(&model.Draft{ + UserId: userID, SpaceId: space.Id, PageId: draftB.PageId, + }, &draftA.PageId, nil, "") + require.Nil(t, appErr) + + // A → B would create A → B → A: a cycle. This must be rejected. + _, appErr = h.svc.UpdatePageDraft(&model.Draft{ + UserId: userID, SpaceId: space.Id, PageId: draftA.PageId, + }, &draftB.PageId, nil, "") + require.NotNil(t, appErr) + require.Equal(t, http.StatusBadRequest, appErr.StatusCode) +} + +func TestUpdatePageDraftRejectsDraftHierarchyTooDeep(t *testing.T) { + h := openTestService(t) + space := mustCreateSpace(t, h.store, mmmodel.NewId()) + userID := mmmodel.NewId() + + // Build a chain of draftCycleCheckMaxDepth+1 drafts. The first draft is the root + // (no parent). Each subsequent draft sets its parent to the previous one. + // draftCycleCheckMaxDepth is 10; a chain of 10 drafts fills the limit, so + // adding one more child is the first rejection. + const chainLen = 10 + drafts := make([]*model.Draft, chainLen) + for i := range chainLen { + d, appErr := h.svc.CreateSpaceDraft(userID, space.Id, fmt.Sprintf("D%d", i), "") + require.Nil(t, appErr) + drafts[i] = d + } + for i := 1; i < chainLen; i++ { + _, appErr := h.svc.UpdatePageDraft(&model.Draft{ + UserId: userID, SpaceId: space.Id, PageId: drafts[i].PageId, + }, &drafts[i-1].PageId, nil, "") + require.Nil(t, appErr, "chaining draft %d under draft %d must succeed", i, i-1) + } + + // Adding one more level (depth 11) must be rejected. + extra, appErr := h.svc.CreateSpaceDraft(userID, space.Id, "Extra", "") + require.Nil(t, appErr) + leaf := drafts[chainLen-1].PageId + _, appErr = h.svc.UpdatePageDraft(&model.Draft{ + UserId: userID, SpaceId: space.Id, PageId: extra.PageId, + }, &leaf, nil, "") + require.NotNil(t, appErr) + require.Equal(t, http.StatusBadRequest, appErr.StatusCode) +} + +func TestDeletePageDraftReparentsChildren(t *testing.T) { + h := openTestService(t) + space := mustCreateSpace(t, h.store, mmmodel.NewId()) + userID := mmmodel.NewId() + + // Create a parent draft P and a child draft C that points to P. + draftP, appErr := h.svc.CreateSpaceDraft(userID, space.Id, "Parent", "") + require.Nil(t, appErr) + + draftC, appErr := h.svc.CreateSpaceDraft(userID, space.Id, "Child", "") + require.Nil(t, appErr) + + _, appErr = h.svc.UpdatePageDraft(&model.Draft{ + UserId: userID, SpaceId: space.Id, PageId: draftC.PageId, + }, &draftP.PageId, nil, "") + require.Nil(t, appErr) + + // Discard the parent draft. + appErr = h.svc.DeletePageDraft(userID, space.Id, draftP.PageId, "") + require.Nil(t, appErr) + + // The parent must be gone. + _, appErr = h.svc.GetPageDraft(userID, space.Id, draftP.PageId) + require.NotNil(t, appErr) + require.Equal(t, http.StatusNotFound, appErr.StatusCode) + + // The child must still exist, reparented to the root (ParentId = ""). + got, appErr := h.svc.GetPageDraft(userID, space.Id, draftC.PageId) + require.Nil(t, appErr) + require.Equal(t, "", got.ParentId, "child must be reparented to root after parent draft is discarded") +} + +func TestGetPageDraftCrossSpaceReturns404(t *testing.T) { + h := openTestService(t) + spaceA := mustCreateSpace(t, h.store, mmmodel.NewId()) + spaceB := mustCreateSpace(t, h.store, mmmodel.NewId()) + userID := mmmodel.NewId() + + draft, appErr := h.svc.CreateSpaceDraft(userID, spaceA.Id, "Doc", "") + require.Nil(t, appErr) + + // GetPageDraft through spaceB must return 404, not expose the draft. + _, appErr = h.svc.GetPageDraft(userID, spaceB.Id, draft.PageId) + require.NotNil(t, appErr) + require.Equal(t, http.StatusNotFound, appErr.StatusCode) +} + +func TestDeletePageDraftRejectsInvalidUserID(t *testing.T) { + h := openTestService(t) + space := mustCreateSpace(t, h.store, mmmodel.NewId()) + + appErr := h.svc.DeletePageDraft("bad-id", space.Id, mmmodel.NewId(), "") + require.NotNil(t, appErr) + require.Equal(t, http.StatusBadRequest, appErr.StatusCode) +} + +func TestDeletePageDraftRejectsInvalidPageID(t *testing.T) { + h := openTestService(t) + space := mustCreateSpace(t, h.store, mmmodel.NewId()) + + appErr := h.svc.DeletePageDraft(mmmodel.NewId(), space.Id, "bad-id", "") + require.NotNil(t, appErr) + require.Equal(t, http.StatusBadRequest, appErr.StatusCode) +} + +func TestPublishNewPageRejectsCrossSpaceParent(t *testing.T) { + h := openTestService(t) + spaceA := mustCreateSpace(t, h.store, mmmodel.NewId()) + spaceB := mustCreateSpace(t, h.store, mmmodel.NewId()) + userID := mmmodel.NewId() + + // Publish a live parent in spaceA. + parentInA := publishNewPage(t, h, spaceA.Id, userID, "Parent", "p") + + // The store rejects a draft whose ParentId points at a page in another space. + // To simulate the race (parent was in the same space when editing started, then moved), + // inject the draft row directly, bypassing the app-layer parent check. + pageID := mmmodel.NewId() + now := mmmodel.GetMillis() + _, err := h.db.Exec( + `INSERT INTO docs_draft (userid,spaceid,pageid,parentid,title,body,fileids,props,createat,updateat,lastactiveat) + VALUES ($1,$2,$3,$4,$5,$6,'[]','{}', $7,$7,$7)`, + userID, spaceB.Id, pageID, parentInA.Id, "Child", docWith("body"), now, + ) + require.NoError(t, err) + + // Publishing must reject: the parent lives in a different space than the draft's space. + _, _, appErr := h.svc.PublishPageDraft(userID, spaceB.Id, pageID, false) + require.NotNil(t, appErr) + require.Equal(t, http.StatusBadRequest, appErr.StatusCode, "cross-space parent must be rejected: %v", appErr) +} + +func TestGetPageActiveEditorsRejectsInvalidPageID(t *testing.T) { + h := openTestService(t) + space := mustCreateSpace(t, h.store, mmmodel.NewId()) + + _, appErr := h.svc.GetPageActiveEditors("not-valid", space.Id) + require.NotNil(t, appErr) + require.Equal(t, http.StatusBadRequest, appErr.StatusCode) +} + +func TestCreateSpaceDraftEnforcesQuota(t *testing.T) { + h := openTestService(t) + space := mustCreateSpace(t, h.store, mmmodel.NewId()) + userID := mmmodel.NewId() + + // Fill the user's quota by inserting rows directly; calling CreateSpaceDraft 100 times + // would be slow and the store enforcement path (inside UpsertDraft's transaction) is what + // we're testing. + now := mmmodel.GetMillis() + for i := range store.MaxDraftsPerUserPerSpace { + pageID := mmmodel.NewId() + title := fmt.Sprintf("draft-%d", i) + _, err := h.db.Exec( + `INSERT INTO docs_draft (userid,spaceid,pageid,parentid,title,body,fileids,props,createat,updateat,lastactiveat) + VALUES ($1,$2,$3,'',$4,'[]','[]','{}', $5,$5,$5)`, + userID, space.Id, pageID, title, now, + ) + require.NoError(t, err) + } + + _, appErr := h.svc.CreateSpaceDraft(userID, space.Id, "One Too Many", "") + require.NotNil(t, appErr) + require.Equal(t, http.StatusTooManyRequests, appErr.StatusCode) +} diff --git a/server/app/page_duplicate_test.go b/server/app/page_duplicate_test.go index c7a8f5f..f20d010 100644 --- a/server/app/page_duplicate_test.go +++ b/server/app/page_duplicate_test.go @@ -50,16 +50,17 @@ func TestServiceDuplicatePage(t *testing.T) { } // TestServiceDuplicatePage_CopiesSearchText verifies the duplicate carries the source's SearchText -// verbatim (alongside the body) so it is immediately searchable like its source. +// (alongside the body) so it is immediately searchable like its source. SearchText is derived +// server-side from the body, so a caller-supplied value is ignored. func TestServiceDuplicatePage_CopiesSearchText(t *testing.T) { h := openTestService(t) channelID := mmmodel.NewId() space := mustCreateSpace(t, h.store, channelID) userID := mmmodel.NewId() - source, appErr := h.svc.CreatePage(space.Id, "", "Searchable", "body text", "search text", userID) + source, appErr := h.svc.CreatePage(space.Id, "", "Searchable", "body text", userID) require.Nil(t, appErr) - require.Equal(t, "search text", source.SearchText) + require.Equal(t, "body text", source.SearchText, "SearchText is derived from the body, not the caller-supplied value") dup, appErr := h.svc.DuplicatePage(source.Id, space, userID, false, nil, nil) require.Nil(t, appErr) @@ -76,7 +77,7 @@ func TestServiceDuplicatePage_CopiesProps(t *testing.T) { space := mustCreateSpace(t, h.store, channelID) userID := mmmodel.NewId() - source, appErr := h.svc.CreatePage(space.Id, "", "Has Props", "body text", "", userID) + source, appErr := h.svc.CreatePage(space.Id, "", "Has Props", "body text", userID) require.Nil(t, appErr) // "nested" round-trips through the store as a plain map[string]any (JSON decoding never @@ -108,7 +109,7 @@ func TestServiceDuplicatePage_TruncatesLongTitle(t *testing.T) { userID := mmmodel.NewId() longTitle := strings.Repeat("x", model.PageTitleMaxRunes) - source, appErr := h.svc.CreatePage(space.Id, "", longTitle, "", "", userID) + source, appErr := h.svc.CreatePage(space.Id, "", longTitle, "", userID) require.Nil(t, appErr) dup, appErr := h.svc.DuplicatePage(source.Id, space, userID, false, nil, nil) diff --git a/server/app/page_hierarchy.go b/server/app/page_hierarchy.go index 8c938e1..f1f3e02 100644 --- a/server/app/page_hierarchy.go +++ b/server/app/page_hierarchy.go @@ -231,7 +231,7 @@ func (s *Service) MovePageToSpace(pageID string, sourceSpace, targetSpace *model s.log.Debug("Moving page to space", "page_id", pageID, "source_space_id", sourceSpace.Id, "target_space_id", targetSpace.Id, "user_id", userID) - moved, priorParentID, storeErr := s.store.MovePageToSpace(pageID, sourceSpace.Id, targetSpace.Id, parentPageID, mmmodel.SafeDereference(expectedUpdateAt), force, MaxPageDepth) + moved, priorParentID, storeErr := s.store.MovePageToSpace(pageID, sourceSpace.Id, targetSpace.Id, userID, parentPageID, mmmodel.SafeDereference(expectedUpdateAt), force, MaxPageDepth) if storeErr != nil { return nil, storeAppError("MovePageToSpace", storeErr) } diff --git a/server/app/page_presence.go b/server/app/page_presence.go new file mode 100644 index 0000000..ef5de66 --- /dev/null +++ b/server/app/page_presence.go @@ -0,0 +1,97 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package app + +import ( + "net/http" + + mmmodel "github.com/mattermost/mattermost/server/public/model" + + "github.com/mattermost/mattermost-plugin-docs/server/model" +) + +// activeEditorTimeoutMs is the window within which a draft autosave keeps a user counted as an +// active editor. Presence is derived from the shared DOCS_Draft table: the editor's autosave is +// the heartbeat, so an editor with a draft updated inside this window is "active". Because the +// list comes from the master DB, it is correct across an HA cluster. +const activeEditorTimeoutMs int64 = 5 * 60 * 1000 + +// presenceBroadcastMinIntervalMs is the minimum time between autosave-triggered presence broadcasts +// for the same page. Delete and publish paths always broadcast regardless of this interval. +const presenceBroadcastMinIntervalMs int64 = 30 * 1000 + +func activeEditorSince() int64 { + return mmmodel.GetMillis() - activeEditorTimeoutMs +} + +// getActiveEditors returns the user IDs currently editing pageID in spaceID — those with a draft +// updated within the active-editor window. The returned slice is never nil. A store failure is +// logged and yields an empty list (presence is best-effort and must never fail the originating +// request); use this only where a best-effort answer is acceptable, not to back a REST read that +// must surface a store failure. +func (s *Service) getActiveEditors(pageID, spaceID string) []string { + editors, err := s.store.GetPageActiveEditors(pageID, spaceID, activeEditorSince()) + if err != nil { + s.log.Warn("getActiveEditors: failed to query active editors; returning empty", + "page_id", pageID, "err", err) + return []string{} + } + return editors +} + +// publishSelfPresence sends a presence snapshot to the draft's author only. Used when the page is +// not yet published (no channel to broadcast to), so only the author's own UI learns of the session. +func (s *Service) publishSelfPresence(draft *model.Draft) { + s.publishToUser(wsEventPagePresenceUpdated, map[string]any{ + "page_id": draft.PageId, + "space_id": draft.SpaceId, + "active_editors": []string{draft.UserId}, + "as_of": mmmodel.GetMillis(), + }, draft.UserId) +} + +// broadcastPagePresence fans a page_presence_updated event out to the space audience, carrying the +// current active-editor set and the time it was taken so a client can discard an out-of-order +// snapshot delivered from another cluster node. channelID is the space's backing channel. Best-effort: +// failures are swallowed. +func (s *Service) broadcastPagePresence(pageID, spaceID, channelID string) { + if s.client == nil { + return + } + // Stamp as_of before the editors query so it marks when the snapshot was taken, not when the + // broadcast finished assembling — clients use it to discard out-of-order snapshots. + asOf := mmmodel.GetMillis() + editors := s.getActiveEditors(pageID, spaceID) + s.publishToChannels(wsEventPagePresenceUpdated, map[string]any{ + "page_id": pageID, + "space_id": spaceID, + "active_editors": editors, + "as_of": asOf, + }, channelID) +} + +// GetPageActiveEditors returns the user IDs currently active on the given page in the given space, +// after confirming the page exists in that space. Returns 404 if the page is unknown or belongs to +// another space, and 500 on a store failure (unlike the best-effort getActiveEditors, this backs a +// REST read that must not report "nobody editing" when the query actually failed). +func (s *Service) GetPageActiveEditors(pageID, spaceID string) ([]string, *mmmodel.AppError) { + if !mmmodel.IsValidId(pageID) { + return nil, mmmodel.NewAppError("GetPageActiveEditors", "app.page.presence.invalid_page_id.app_error", nil, "", http.StatusBadRequest) + } + if !mmmodel.IsValidId(spaceID) { + return nil, mmmodel.NewAppError("GetPageActiveEditors", "app.page.presence.invalid_space_id.app_error", nil, "", http.StatusBadRequest) + } + exists, err := s.store.PageExistsInSpace(pageID, spaceID) + if err != nil { + return nil, storeAppError("GetPageActiveEditors", err) + } + if !exists { + return nil, mmmodel.NewAppError("GetPageActiveEditors", "app.page.not_found.app_error", nil, "", http.StatusNotFound) + } + editors, storeErr := s.store.GetPageActiveEditors(pageID, spaceID, activeEditorSince()) + if storeErr != nil { + return nil, storeAppError("GetPageActiveEditors", storeErr) + } + return editors, nil +} diff --git a/server/app/service.go b/server/app/service.go index 0ee955c..4e767ed 100644 --- a/server/app/service.go +++ b/server/app/service.go @@ -10,6 +10,7 @@ import ( "errors" "net/http" "strings" + "sync" "unicode/utf8" mmmodel "github.com/mattermost/mattermost/server/public/model" @@ -37,6 +38,11 @@ type Service struct { store *store.Store log Logger client *pluginapi.Client + + // presenceBroadcastLast records the last autosave-triggered presence broadcast time (ms) per + // pageID, used to rate-limit high-frequency autosave broadcasts. Delete and publish paths bypass + // this and always broadcast. + presenceBroadcastLast sync.Map } // New creates a Service wired to the given store, logger, and optional pluginapi client. @@ -129,6 +135,8 @@ func storeAppError(where string, err error) *mmmodel.AppError { return mmmodel.NewAppError(where, "app.page.max_depth_exceeded.app_error", map[string]any{"MaxDepth": limitErr.Limit}, "", http.StatusBadRequest).Wrap(err) case store.ReasonSubtreeMaxDepthExceeded: return mmmodel.NewAppError(where, "app.page.subtree_max_depth_exceeded.app_error", map[string]any{"MaxDepth": limitErr.Limit}, "", http.StatusBadRequest).Wrap(err) + case store.ReasonDraftQuotaExceeded: + return mmmodel.NewAppError(where, "app.page_draft.create.quota_exceeded.app_error", nil, "", http.StatusTooManyRequests).Wrap(err) } return mmmodel.NewAppError(where, "app.store.too_large.app_error", map[string]any{"Limit": limitErr.Limit}, "", http.StatusUnprocessableEntity).Wrap(err) default: @@ -143,11 +151,16 @@ func storeAppError(where string, err error) *mmmodel.AppError { func invalidInputAppError(where string, err error) *mmmodel.AppError { var invErr *store.ErrInvalidInput if errors.As(err, &invErr) && invErr.Reason != "" { - if invErr.Reason == store.ReasonParentNotLive { + switch invErr.Reason { + case store.ReasonParentNotLive: // Same key as the app-layer parent pre-checks (validateParentExists), so a parent // that disappears between the pre-check and the store's locked check reads // identically to one that never existed — the contract is not race-dependent. return mmmodel.NewAppError(where, "app.page.invalid_parent.app_error", nil, "", http.StatusBadRequest).Wrap(err) + case store.ReasonDraftCycle: + return mmmodel.NewAppError(where, "app.page_draft.update.parent_cycle.app_error", nil, "", http.StatusBadRequest).Wrap(err) + case store.ReasonDraftTooDeep: + return mmmodel.NewAppError(where, "app.page_draft.update.parent_too_deep.app_error", nil, "", http.StatusBadRequest).Wrap(err) } return mmmodel.NewAppError(where, invErr.Reason, nil, "", http.StatusBadRequest).Wrap(err) } diff --git a/server/app/service_test.go b/server/app/service_test.go index d88abab..dfdd633 100644 --- a/server/app/service_test.go +++ b/server/app/service_test.go @@ -106,7 +106,7 @@ func TestServiceCreatePageParentDifferentSpace(t *testing.T) { otherSpace := mustCreateSpace(t, h.store, otherChannelID) rogueParent := mustCreatePage(t, h.store, otherSpace.Id, otherChannelID, userID, "") - _, err := h.svc.CreatePage(space.Id, rogueParent.Id, "Child", "", "", userID) + _, err := h.svc.CreatePage(space.Id, rogueParent.Id, "Child", "", userID) require.NotNil(t, err) require.Equal(t, http.StatusBadRequest, err.StatusCode) require.Equal(t, "app.page.invalid_parent.app_error", err.Id) @@ -206,10 +206,10 @@ func TestServiceUpdatePageSearchTextWithoutBody(t *testing.T) { require.Equal(t, "model.page.patch.search_text_body_mismatch.app_error", err.Id) } -// TestServiceUpdatePageBodyWithoutSearchText covers the inverse: a Body change with no -// accompanying SearchText would strand the GIN index on the page's old content, so the -// update-path guard rejects it too (both or neither). -func TestServiceUpdatePageBodyWithoutSearchText(t *testing.T) { +// TestServiceUpdatePageBodyDerivesSearchText verifies that a Body-only patch succeeds and its +// SearchText is derived server-side from the body, keeping the search index in sync without the +// caller having to supply it. +func TestServiceUpdatePageBodyDerivesSearchText(t *testing.T) { h := openTestService(t) channelID := mmmodel.NewId() @@ -217,18 +217,16 @@ func TestServiceUpdatePageBodyWithoutSearchText(t *testing.T) { userID := mmmodel.NewId() created := mustCreatePage(t, h.store, space.Id, channelID, userID, "") - _, err := h.svc.UpdatePage( + updated, err := h.svc.UpdatePage( created.Id, created.SpaceId, &model.PagePatch{Body: mmmodel.NewPointer("new body")}, new(created.EditAt), false, userID, ) - require.NotNil(t, err) - require.Equal(t, http.StatusBadRequest, err.StatusCode) - require.Equal(t, "model.page.patch.search_text_body_mismatch.app_error", err.Id) + require.Nil(t, err) + require.Equal(t, "new body", updated.SearchText) } -// TestServiceUpdatePageSearchTextWithBodyCleared verifies that clearing Body to "" while -// setting a non-empty SearchText is rejected: SearchText is the body's plain-text projection -// and must not survive an emptied body (mirrors the create-path rule). -func TestServiceUpdatePageSearchTextWithBodyCleared(t *testing.T) { +// TestServiceUpdatePageSearchTextIgnoredOnBodyClear verifies that clearing Body to "" derives an +// empty SearchText regardless of the caller-supplied value — SearchText is the body's projection. +func TestServiceUpdatePageSearchTextIgnoredOnBodyClear(t *testing.T) { h := openTestService(t) channelID := mmmodel.NewId() @@ -236,14 +234,14 @@ func TestServiceUpdatePageSearchTextWithBodyCleared(t *testing.T) { userID := mmmodel.NewId() created := mustCreatePage(t, h.store, space.Id, channelID, userID, "") - _, err := h.svc.UpdatePage( + updated, err := h.svc.UpdatePage( created.Id, created.SpaceId, - &model.PagePatch{Body: mmmodel.NewPointer(""), SearchText: mmmodel.NewPointer("some text")}, + &model.PagePatch{Body: mmmodel.NewPointer(""), SearchText: mmmodel.NewPointer("ignored")}, new(created.EditAt), false, userID, ) - require.NotNil(t, err) - require.Equal(t, http.StatusBadRequest, err.StatusCode) - require.Equal(t, "model.page.patch.search_text_without_content.app_error", err.Id) + require.Nil(t, err) + require.Equal(t, "", updated.SearchText) + require.Equal(t, "", updated.Body) } // TestServiceUpdatePageClearSearchTextAlone verifies the coupling rule: clearing SearchText @@ -414,7 +412,7 @@ func TestServiceCreatePageDerivesChannelFromSpace(t *testing.T) { space := mustCreateSpace(t, h.store, channelID) userID := mmmodel.NewId() - created, err := h.svc.CreatePage(space.Id, "", "My Page", "", "", userID) + created, err := h.svc.CreatePage(space.Id, "", "My Page", "", userID) require.Nil(t, err) require.Equal(t, "My Page", created.Title) require.Equal(t, space.Id, created.SpaceId) @@ -428,34 +426,34 @@ func TestServiceCreatePage(t *testing.T) { userID := mmmodel.NewId() t.Run("rejects invalid space id", func(t *testing.T) { - _, err := h.svc.CreatePage("not-a-valid-id", "", "Title", "", "", userID) + _, err := h.svc.CreatePage("not-a-valid-id", "", "Title", "", userID) require.NotNil(t, err) require.Equal(t, http.StatusBadRequest, err.StatusCode) require.Equal(t, "app.page.create.invalid_space_id.app_error", err.Id) }) t.Run("rejects invalid user id", func(t *testing.T) { - _, err := h.svc.CreatePage(space.Id, "", "Title", "", "", "not-a-valid-id") + _, err := h.svc.CreatePage(space.Id, "", "Title", "", "not-a-valid-id") require.NotNil(t, err) require.Equal(t, http.StatusBadRequest, err.StatusCode) require.Equal(t, "app.page.create.invalid_user_id.app_error", err.Id) }) t.Run("rejects empty title", func(t *testing.T) { - _, err := h.svc.CreatePage(space.Id, "", " ", "", "", userID) + _, err := h.svc.CreatePage(space.Id, "", " ", "", userID) require.NotNil(t, err) require.Equal(t, 400, err.StatusCode) }) t.Run("rejects title too long", func(t *testing.T) { long := strings.Repeat("x", model.PageTitleMaxRunes+1) - _, err := h.svc.CreatePage(space.Id, "", long, "", "", userID) + _, err := h.svc.CreatePage(space.Id, "", long, "", userID) require.NotNil(t, err) require.Equal(t, 400, err.StatusCode) }) t.Run("rejects nonexistent parent", func(t *testing.T) { - _, err := h.svc.CreatePage(space.Id, mmmodel.NewId(), "Title", "", "", userID) + _, err := h.svc.CreatePage(space.Id, mmmodel.NewId(), "Title", "", userID) require.NotNil(t, err) require.Equal(t, 400, err.StatusCode) }) @@ -464,7 +462,7 @@ func TestServiceCreatePage(t *testing.T) { otherChannelID := mmmodel.NewId() otherSpace := mustCreateSpace(t, h.store, otherChannelID) parent := mustCreatePage(t, h.store, otherSpace.Id, otherChannelID, userID, "") - _, err := h.svc.CreatePage(space.Id, parent.Id, "Title", "", "", userID) + _, err := h.svc.CreatePage(space.Id, parent.Id, "Title", "", userID) require.NotNil(t, err) require.Equal(t, 400, err.StatusCode) }) @@ -477,17 +475,17 @@ func TestServiceCreatePage(t *testing.T) { // child would be at depth MaxPageDepth+1 and must be rejected. parentID := "" for range app.MaxPageDepth { - p, err := h.svc.CreatePage(depthSpace.Id, parentID, "d", "", "", userID) + p, err := h.svc.CreatePage(depthSpace.Id, parentID, "d", "", userID) require.Nil(t, err) parentID = p.Id } - _, err := h.svc.CreatePage(depthSpace.Id, parentID, "too deep", "", "", userID) + _, err := h.svc.CreatePage(depthSpace.Id, parentID, "too deep", "", userID) require.NotNil(t, err) require.Equal(t, 400, err.StatusCode) }) t.Run("creates a valid page", func(t *testing.T) { - created, err := h.svc.CreatePage(space.Id, "", "My Page", "", "", userID) + created, err := h.svc.CreatePage(space.Id, "", "My Page", "", userID) require.Nil(t, err) require.Equal(t, "My Page", created.Title) }) @@ -545,9 +543,9 @@ func TestServiceUpdatePageOversizedBody(t *testing.T) { require.Equal(t, "model.page.is_valid.body.app_error", err.Id) } -// TestServiceUpdatePageOversizedSearchText verifies that the update path rejects oversized -// searchText with 400. -func TestServiceUpdatePageOversizedSearchText(t *testing.T) { +// TestServiceUpdatePageOversizedSearchTextIgnored verifies a caller-supplied oversized SearchText +// is harmless: it is ignored and SearchText is derived from the (small) body instead. +func TestServiceUpdatePageOversizedSearchTextIgnored(t *testing.T) { h := openTestService(t) channelID := mmmodel.NewId() @@ -555,10 +553,9 @@ func TestServiceUpdatePageOversizedSearchText(t *testing.T) { created := mustCreatePage(t, h.store, space.Id, channelID, mmmodel.NewId(), "") oversized := strings.Repeat("x", model.PageSearchTextMaxBytes+1) - _, err := h.svc.UpdatePage(created.Id, created.SpaceId, &model.PagePatch{Title: mmmodel.NewPointer("Title"), Body: mmmodel.NewPointer("body"), SearchText: mmmodel.NewPointer(oversized)}, new(created.EditAt), false, mmmodel.NewId()) - require.NotNil(t, err) - require.Equal(t, http.StatusBadRequest, err.StatusCode) - require.Equal(t, "model.page.is_valid.search_text.app_error", err.Id) + updated, err := h.svc.UpdatePage(created.Id, created.SpaceId, &model.PagePatch{Title: mmmodel.NewPointer("Title"), Body: mmmodel.NewPointer("body"), SearchText: mmmodel.NewPointer(oversized)}, new(created.EditAt), false, mmmodel.NewId()) + require.Nil(t, err) + require.Equal(t, "body", updated.SearchText) } // TestServiceCreatePageOversizedBody verifies that CreatePage rejects a body that exceeds @@ -570,7 +567,7 @@ func TestServiceCreatePageOversizedBody(t *testing.T) { space := mustCreateSpace(t, h.store, channelID) oversized := strings.Repeat("x", model.PageBodyMaxBytes+1) - _, err := h.svc.CreatePage(space.Id, "", "Title", oversized, "", mmmodel.NewId()) + _, err := h.svc.CreatePage(space.Id, "", "Title", oversized, mmmodel.NewId()) require.NotNil(t, err) require.Equal(t, http.StatusBadRequest, err.StatusCode) require.Equal(t, "model.page.is_valid.body.app_error", err.Id) @@ -599,18 +596,17 @@ func TestServiceUpdatePageCanSetEmptyBody(t *testing.T) { require.Equal(t, "", emptied.Body, "an explicit empty Body must clear the stored body") } -// TestServiceCreatePageSearchTextWithoutBody verifies CreatePage rejects searchText -// supplied without a body, matching the update path's rule. -func TestServiceCreatePageSearchTextWithoutBody(t *testing.T) { +// TestServiceCreatePageSearchTextDerivedFromBody verifies CreatePage ignores a caller-supplied +// SearchText and derives it from the body — so an empty body yields an empty SearchText. +func TestServiceCreatePageSearchTextDerivedFromBody(t *testing.T) { h := openTestService(t) channelID := mmmodel.NewId() space := mustCreateSpace(t, h.store, channelID) - _, err := h.svc.CreatePage(space.Id, "", "Title", "", "searchtext", mmmodel.NewId()) - require.NotNil(t, err) - require.Equal(t, http.StatusBadRequest, err.StatusCode) - require.Equal(t, "app.page.create.search_text_without_content.app_error", err.Id) + created, err := h.svc.CreatePage(space.Id, "", "Title", "", mmmodel.NewId()) + require.Nil(t, err) + require.Equal(t, "", created.SearchText) } // TestServiceGetPageWithDeleted verifies the with-deleted reader returns a soft-deleted diff --git a/server/app/ws_events.go b/server/app/ws_events.go index 3053b08..64b2d9d 100644 --- a/server/app/ws_events.go +++ b/server/app/ws_events.go @@ -34,6 +34,10 @@ const ( wsEventPageMoved = "page_moved" wsEventPageDuplicated = "page_duplicated" wsEventPageMovedToSpace = "page_moved_to_space" + // wsEventPagePresenceUpdated carries a presence snapshot ({page_id, space_id, active_editors, + // as_of}), not the {page_id, space_id} mutation shape, and fires on every autosave/draft-delete + // rather than on a page write. + wsEventPagePresenceUpdated = "page_presence_updated" wsEventSpaceCreated = "space_created" wsEventSpaceUpdated = "space_updated" @@ -50,12 +54,12 @@ func (s *Service) publishToChannels(event string, payload map[string]any, channe if s.client == nil { return } - seen := make(map[string]bool, len(channelIDs)) + seen := make(map[string]struct{}, len(channelIDs)) for _, chID := range channelIDs { - if chID == "" || seen[chID] { + if _, ok := seen[chID]; chID == "" || ok { continue } - seen[chID] = true + seen[chID] = struct{}{} s.client.Frontend.PublishWebSocketEvent(event, payload, &mmmodel.WebsocketBroadcast{ChannelId: chID}) } } diff --git a/server/app/ws_events_test.go b/server/app/ws_events_test.go index afd7b33..c27c057 100644 --- a/server/app/ws_events_test.go +++ b/server/app/ws_events_test.go @@ -8,6 +8,7 @@ package app_test import ( + "slices" "testing" "github.com/stretchr/testify/mock" @@ -173,6 +174,220 @@ func TestServiceMovePageToSpace_NoOpPublishesNothing(t *testing.T) { mockAPI.AssertNotCalled(t, "PublishWebSocketEvent", "page_moved_to_space", mock.Anything, mock.Anything) } +// TestServiceUpdatePageDraft_PublishesPresenceEvent pins page_presence_updated: the presence-snapshot +// payload ({page_id, space_id, active_editors, as_of}) — distinct from the {page_id, space_id} +// mutation shape — broadcast to the space's backing channel. An autosave is the heartbeat, so the +// saving user appears in active_editors. +func TestServiceUpdatePageDraft_PublishesPresenceEvent(t *testing.T) { + mockAPI := &plugintest.API{} + h := openTestServiceWithAPI(t, mockAPI) + + channelID := mmmodel.NewId() + userID := mmmodel.NewId() + space := mustCreateSpace(t, h.store, channelID) + + // Use a published page so UpdatePageDraft takes the channel-broadcast path. + page := publishNewPage(t, h, space.Id, userID, "Doc", "v1") + + _, appErr := h.svc.UpdatePageDraft(&model.Draft{ + UserId: userID, SpaceId: space.Id, PageId: page.Id, Title: "Doc", + Props: mmmodel.StringInterface{model.DraftPropsOriginalPageEditAt: float64(page.EditAt)}, + }, nil, nil, channelID) + require.Nil(t, appErr) + + mockAPI.AssertCalled(t, "PublishWebSocketEvent", "page_presence_updated", + mock.MatchedBy(func(payload map[string]any) bool { + editors, ok := payload["active_editors"].([]string) + return ok && + payload["page_id"] == page.Id && + payload["space_id"] == space.Id && + payload["as_of"] != nil && + slices.Contains(editors, userID) + }), + &mmmodel.WebsocketBroadcast{ChannelId: channelID}) +} + +// TestServicePublishPageDraft_PublishesCreatedEvent pins that publishing a brand-new page's draft +// reuses page_created (not a draft-specific event): {page_id, space_id, parent_id} payload, +// broadcast to the new page's backing channel. Also pins the accompanying presence-clear broadcast. +func TestServicePublishPageDraft_PublishesCreatedEvent(t *testing.T) { + mockAPI := &plugintest.API{} + h := openTestServiceWithAPI(t, mockAPI) + + channelID := mmmodel.NewId() + userID := mmmodel.NewId() + space := mustCreateSpace(t, h.store, channelID) + + page := publishNewPage(t, h, space.Id, userID, "Doc", "hello") + + mockAPI.AssertCalled(t, "PublishWebSocketEvent", "page_created", + map[string]any{"page_id": page.Id, "space_id": space.Id, "parent_id": page.ParentId}, + &mmmodel.WebsocketBroadcast{ChannelId: channelID}) + + // PublishDraft bypasses DeletePageDraft, so PublishPageDraft broadcasts presence directly. + mockAPI.AssertCalled(t, "PublishWebSocketEvent", "page_presence_updated", + mock.MatchedBy(func(payload map[string]any) bool { + editors, ok := payload["active_editors"].([]string) + return ok && + payload["page_id"] == page.Id && + payload["space_id"] == space.Id && + payload["as_of"] != nil && + len(editors) == 0 + }), + &mmmodel.WebsocketBroadcast{ChannelId: channelID}) +} + +// TestServicePublishPageDraft_PublishesUpdatedEvent pins that publishing an edit to an existing page +// reuses page_updated: {page_id, space_id} payload, broadcast to the page's backing channel. +// Also pins the accompanying presence-clear broadcast. +func TestServicePublishPageDraft_PublishesUpdatedEvent(t *testing.T) { + mockAPI := &plugintest.API{} + h := openTestServiceWithAPI(t, mockAPI) + + channelID := mmmodel.NewId() + userID := mmmodel.NewId() + space := mustCreateSpace(t, h.store, channelID) + page := publishNewPage(t, h, space.Id, userID, "Doc", "original") + + // Start an edit session against the live page's baseline, then publish it. + _, appErr := h.svc.UpdatePageDraft(&model.Draft{ + UserId: userID, SpaceId: space.Id, PageId: page.Id, Title: "Doc", Body: docWith("edited"), + Props: mmmodel.StringInterface{model.DraftPropsOriginalPageEditAt: float64(page.EditAt)}, + }, nil, nil, "") + require.Nil(t, appErr) + + republished, wasCreated, appErr := h.svc.PublishPageDraft(userID, space.Id, page.Id, false) + require.Nil(t, appErr) + require.False(t, wasCreated, "publishing an edit to an existing page must report wasCreated=false") + + mockAPI.AssertCalled(t, "PublishWebSocketEvent", "page_updated", + map[string]any{"page_id": republished.Id, "space_id": space.Id}, + &mmmodel.WebsocketBroadcast{ChannelId: channelID}) + + // PublishDraft bypasses DeletePageDraft, so PublishPageDraft broadcasts presence directly. + mockAPI.AssertCalled(t, "PublishWebSocketEvent", "page_presence_updated", + mock.MatchedBy(func(payload map[string]any) bool { + editors, ok := payload["active_editors"].([]string) + return ok && + payload["page_id"] == republished.Id && + payload["space_id"] == space.Id && + payload["as_of"] != nil && + len(editors) == 0 + }), + &mmmodel.WebsocketBroadcast{ChannelId: channelID}) +} + +// TestServiceDeletePageDraft_PublishesPresenceEvent pins that discarding a draft broadcasts +// page_presence_updated so the active-editors indicator drops the user; after the discard the +// snapshot is the empty set ([] not null). +func TestServiceDeletePageDraft_PublishesPresenceEvent(t *testing.T) { + mockAPI := &plugintest.API{} + h := openTestServiceWithAPI(t, mockAPI) + + channelID := mmmodel.NewId() + userID := mmmodel.NewId() + space := mustCreateSpace(t, h.store, channelID) + + // Use a published page so the edit-draft delete takes the channel-broadcast path. + page := publishNewPage(t, h, space.Id, userID, "Doc", "v1") + + _, appErr := h.svc.UpdatePageDraft(&model.Draft{ + UserId: userID, SpaceId: space.Id, PageId: page.Id, Title: "Doc", + Props: mmmodel.StringInterface{model.DraftPropsOriginalPageEditAt: float64(page.EditAt)}, + }, nil, nil, channelID) + require.Nil(t, appErr) + + require.Nil(t, h.svc.DeletePageDraft(userID, space.Id, page.Id, channelID)) + + mockAPI.AssertCalled(t, "PublishWebSocketEvent", "page_presence_updated", + mock.MatchedBy(func(payload map[string]any) bool { + editors, ok := payload["active_editors"].([]string) + return ok && + payload["page_id"] == page.Id && + payload["space_id"] == space.Id && + payload["as_of"] != nil && + len(editors) == 0 + }), + &mmmodel.WebsocketBroadcast{ChannelId: channelID}) +} + +// TestServiceUpdatePageDraft_NewPageDraftPublishesToUserOnly pins that a draft for a not-yet-published +// page broadcasts presence only to the author — never to the space channel, which would leak the +// reserved page ID and author identity before the page exists. +func TestServiceUpdatePageDraft_NewPageDraftPublishesToUserOnly(t *testing.T) { + mockAPI := &plugintest.API{} + h := openTestServiceWithAPI(t, mockAPI) + + channelID := mmmodel.NewId() + userID := mmmodel.NewId() + space := mustCreateSpace(t, h.store, channelID) + + // Create a new-page draft — no published page row exists yet. + draft, appErr := h.svc.CreateSpaceDraft(userID, space.Id, "Unpublished", "") + require.Nil(t, appErr) + + // Reset call log so only the following UpdatePageDraft broadcast is observed. + mockAPI.Calls = nil + + _, appErr = h.svc.UpdatePageDraft(&model.Draft{ + UserId: userID, SpaceId: space.Id, PageId: draft.PageId, Title: "Unpublished", + }, nil, nil, channelID) + require.Nil(t, appErr) + + // The broadcast must be user-scoped: only the author learns about their own unreleased page. + mockAPI.AssertCalled(t, "PublishWebSocketEvent", "page_presence_updated", + mock.MatchedBy(func(payload map[string]any) bool { + return payload["page_id"] == draft.PageId && payload["space_id"] == space.Id + }), + &mmmodel.WebsocketBroadcast{UserId: userID}) + + // Must not broadcast to the channel, which would expose the reserved page ID. + mockAPI.AssertNotCalled(t, "PublishWebSocketEvent", "page_presence_updated", + mock.Anything, + &mmmodel.WebsocketBroadcast{ChannelId: channelID}) +} + +// TestServiceUpdatePageDraft_PresenceRateLimitSuppressesSecondBroadcast verifies that a second +// autosave within presenceBroadcastMinIntervalMs does not trigger a second channel broadcast. +// The rate-limit prevents flooding the channel on every keystroke. +func TestServiceUpdatePageDraft_PresenceRateLimitSuppressesSecondBroadcast(t *testing.T) { + mockAPI := &plugintest.API{} + h := openTestServiceWithAPI(t, mockAPI) + + channelID := mmmodel.NewId() + userID := mmmodel.NewId() + space := mustCreateSpace(t, h.store, channelID) + page := publishNewPage(t, h, space.Id, userID, "Doc", "v1") + + // Reset call log so only the two autosaves below are counted. + mockAPI.Calls = nil + + autosave := func() { + _, appErr := h.svc.UpdatePageDraft(&model.Draft{ + UserId: userID, SpaceId: space.Id, PageId: page.Id, Title: "Doc", + Props: mmmodel.StringInterface{model.DraftPropsOriginalPageEditAt: float64(page.EditAt)}, + }, nil, nil, channelID) + require.Nil(t, appErr) + } + autosave() // first: should broadcast + autosave() // second: rate-limited, must not broadcast again + + // Count channel-scoped page_presence_updated calls. + n := 0 + for _, call := range mockAPI.Calls { + if call.Method != "PublishWebSocketEvent" || len(call.Arguments) < 3 { + continue + } + if call.Arguments[0] != "page_presence_updated" { + continue + } + if bc, ok := call.Arguments[2].(*mmmodel.WebsocketBroadcast); ok && bc.ChannelId != "" { + n++ + } + } + require.Equal(t, 1, n, "second autosave within rate-limit window must not broadcast presence to channel again") +} + // TestServiceCreateSpace_PublishesCreatedEvent pins space_created: space-id payload, broadcast // scoped to the backing channel (the visibility boundary the REST API enforces; the team space // list is filtered to the caller's channel memberships). diff --git a/server/model/draft.go b/server/model/draft.go index f2e6d9a..2f6ecf4 100644 --- a/server/model/draft.go +++ b/server/model/draft.go @@ -14,6 +14,10 @@ import ( // DraftFileIdsMaxRunes is the maximum rune length of the serialized FileIds JSON array. const DraftFileIdsMaxRunes = 300 +// DraftPropsOriginalPageEditAt stores the EditAt the user last saw when they opened +// a page for editing — used as the optimistic-lock baseline on publish. +const DraftPropsOriginalPageEditAt = "original_page_edit_at" + // Draft is a per-user autosave draft for a space page, stored in DOCS_Draft. // // A draft is keyed by (UserId, PageId): PageId is the page id reserved when the @@ -22,17 +26,38 @@ const DraftFileIdsMaxRunes = 300 // same key. An orphan draft — one whose PageId has no matching DOCS_Page row — is legal, // since the page has not been published yet. ParentId carries the pending hierarchy // parent for a new page; Body holds the raw (opaque) editor content. +// +// LastActiveAt is distinct from UpdateAt: it moves only when the user themselves saves the draft, +// whereas UpdateAt also moves when a bulk maintenance write touches the row. Editor presence is +// derived from LastActiveAt, so only real authoring activity counts as editing. type Draft struct { - UserId string `json:"user_id"` - SpaceId string `json:"space_id"` - PageId string `json:"page_id"` - ParentId string `json:"parent_id"` - Title string `json:"title"` - Body string `json:"body"` - FileIds mmmodel.StringArray `json:"file_ids"` - Props mmmodel.StringInterface `json:"props"` - CreateAt int64 `json:"create_at"` - UpdateAt int64 `json:"update_at"` + UserId string `json:"user_id"` + SpaceId string `json:"space_id"` + PageId string `json:"page_id"` + ParentId string `json:"parent_id"` + Title string `json:"title"` + Body string `json:"body"` + FileIds mmmodel.StringArray `json:"file_ids"` + Props mmmodel.StringInterface `json:"props"` + CreateAt int64 `json:"create_at"` + UpdateAt int64 `json:"update_at"` + LastActiveAt int64 `json:"last_active_at"` +} + +// DraftSummary is the metadata projection returned by draft collection endpoints. It deliberately +// omits Body, which can be up to PageBodyMaxBytes per draft. Fetch a Draft by page id when the +// content is required. +type DraftSummary struct { + UserId string `json:"user_id"` + SpaceId string `json:"space_id"` + PageId string `json:"page_id"` + ParentId string `json:"parent_id"` + Title string `json:"title"` + FileIds mmmodel.StringArray `json:"file_ids"` + Props mmmodel.StringInterface `json:"props"` + CreateAt int64 `json:"create_at"` + UpdateAt int64 `json:"update_at"` + LastActiveAt int64 `json:"last_active_at"` } // PreSave sanitizes Draft and defaults its Id-independent fields before insert. @@ -53,20 +78,24 @@ func (d *Draft) PreSave() { d.CreateAt = now } d.UpdateAt = now + // PreSave runs only on a user's own save of the draft, which is exactly the signal presence + // needs — bulk maintenance writes update the row without going through here. + d.LastActiveAt = now } // Auditable returns Draft's fields safe to include in an audit log, excluding Body. func (d *Draft) Auditable() map[string]any { return map[string]any{ - "user_id": d.UserId, - "space_id": d.SpaceId, - "page_id": d.PageId, - "parent_id": d.ParentId, - "title": d.Title, - "file_ids": d.FileIds, - "props": d.GetProps(), - "create_at": d.CreateAt, - "update_at": d.UpdateAt, + "user_id": d.UserId, + "space_id": d.SpaceId, + "page_id": d.PageId, + "parent_id": d.ParentId, + "title": d.Title, + "file_ids": d.FileIds, + "props": d.GetProps(), + "create_at": d.CreateAt, + "update_at": d.UpdateAt, + "last_active_at": d.LastActiveAt, } } @@ -75,11 +104,9 @@ func (d *Draft) IsValid() *mmmodel.AppError { if !mmmodel.IsValidId(d.UserId) { return mmmodel.NewAppError("Draft.IsValid", "model.draft.is_valid.user_id.app_error", nil, "user_id="+d.UserId, http.StatusBadRequest) } - if !mmmodel.IsValidId(d.SpaceId) { return mmmodel.NewAppError("Draft.IsValid", "model.draft.is_valid.space_id.app_error", nil, "space_id="+d.SpaceId, http.StatusBadRequest) } - if !mmmodel.IsValidId(d.PageId) { return mmmodel.NewAppError("Draft.IsValid", "model.draft.is_valid.page_id.app_error", nil, "page_id="+d.PageId, http.StatusBadRequest) } @@ -101,6 +128,10 @@ func (d *Draft) IsValid() *mmmodel.AppError { return mmmodel.NewAppError("Draft.IsValid", "model.draft.is_valid.update_at.app_error", nil, "page_id="+d.PageId, http.StatusBadRequest) } + if d.LastActiveAt == 0 { + return mmmodel.NewAppError("Draft.IsValid", "model.draft.is_valid.last_active_at.app_error", nil, "page_id="+d.PageId, http.StatusBadRequest) + } + // A draft publishes into a page, so it is bound by the page content limits. if utf8.RuneCountInString(d.Title) > PageTitleMaxRunes { return mmmodel.NewAppError("Draft.IsValid", "model.draft.is_valid.title_length.app_error", nil, "page_id="+d.PageId, http.StatusBadRequest) @@ -126,3 +157,32 @@ func (d *Draft) GetProps() mmmodel.StringInterface { d.Props = ensureProps(d.Props) return d.Props } + +// SanitizeProps strips any props key not on the recognized allowlist. Call on every write path to +// prevent unknown client-supplied keys from accumulating in the stored map. +func (d *Draft) SanitizeProps() { + allowed := mmmodel.StringInterface{} + if v, ok := d.Props[DraftPropsOriginalPageEditAt]; ok { + allowed[DraftPropsOriginalPageEditAt] = v + } + d.Props = allowed +} + +// EditBaseline extracts the optimistic-lock baseline from the draft's props. JSON-decoded numbers +// arrive as float64; a programmatically-set int64/int is also accepted. Returns (0, false) when +// the baseline is absent or zero (e.g. a new-page draft). +func (d *Draft) EditBaseline() (int64, bool) { + v, ok := d.GetProps()[DraftPropsOriginalPageEditAt] + if !ok { + return 0, false + } + switch n := v.(type) { + case float64: + return int64(n), n != 0 + case int64: + return n, n != 0 + case int: + return int64(n), n != 0 + } + return 0, false +} diff --git a/server/model/draft_test.go b/server/model/draft_test.go index 58ae14a..7997fb3 100644 --- a/server/model/draft_test.go +++ b/server/model/draft_test.go @@ -129,11 +129,38 @@ func TestDraftIsValid(t *testing.T) { }) } +func TestDraftIsValidLastActiveAtZeroRejected(t *testing.T) { + d := validDraft() + d.LastActiveAt = 0 + aerr := d.IsValid() + require.NotNil(t, aerr) + require.Equal(t, "model.draft.is_valid.last_active_at.app_error", aerr.Id) +} + func TestDraftPreSaveDefaults(t *testing.T) { d := &model.Draft{UserId: mmmodel.NewId(), SpaceId: mmmodel.NewId(), PageId: mmmodel.NewId()} d.PreSave() require.NotZero(t, d.CreateAt) require.NotZero(t, d.UpdateAt) + require.NotZero(t, d.LastActiveAt) require.NotNil(t, d.FileIds) require.NotNil(t, d.Props) } + +func TestDraftPreSavePreservesExistingCreateAt(t *testing.T) { + d := &model.Draft{UserId: mmmodel.NewId(), SpaceId: mmmodel.NewId(), PageId: mmmodel.NewId(), CreateAt: 12345} + d.PreSave() + require.Equal(t, int64(12345), d.CreateAt, "PreSave must not overwrite an existing CreateAt") +} + +func TestDraftPreSaveTrimsTitleWhitespace(t *testing.T) { + d := &model.Draft{UserId: mmmodel.NewId(), SpaceId: mmmodel.NewId(), PageId: mmmodel.NewId(), Title: " hello "} + d.PreSave() + require.Equal(t, "hello", d.Title) +} + +func TestDraftGetPropsNilReturnsEmpty(t *testing.T) { + d := &model.Draft{Props: nil} + require.NotNil(t, d.GetProps(), "GetProps must return an empty map, not nil") + require.Empty(t, d.GetProps()) +} diff --git a/server/model/page_content.go b/server/model/page_content.go new file mode 100644 index 0000000..cbf8545 --- /dev/null +++ b/server/model/page_content.go @@ -0,0 +1,518 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package model + +import ( + "encoding/base64" + "encoding/json" + "html" + "net/http" + "slices" + "strings" + + "github.com/pkg/errors" +) + +const ( + TipTapDocType = "doc" + EmptyTipTapJSON = `{"type":"doc","content":[]}` +) + +type TipTapDocument struct { + Type string `json:"type"` + Content []map[string]any `json:"content"` +} + +// BuildSearchText extracts searchable plain text from a TipTap document. +func BuildSearchText(doc TipTapDocument) string { + var b strings.Builder + for _, node := range doc.Content { + appendNodeText(&b, node, 0) + } + return b.String() +} + +// ParseTipTapDocument parses and sanitizes a TipTap JSON string into a TipTapDocument. Unlike the +// model's field validators, it returns a plain error (not an *mmmodel.AppError): its failures are +// parse-level and are always collapsed by the app layer into one generic content app-error, so they +// are not meant to be addressable per-reason by an i18n key. +func ParseTipTapDocument(contentJSON string) (TipTapDocument, error) { + if contentJSON == "" { + return TipTapDocument{ + Type: TipTapDocType, + Content: []map[string]any{}, + }, nil + } + + var doc TipTapDocument + if err := json.Unmarshal([]byte(contentJSON), &doc); err != nil { + return TipTapDocument{}, err + } + + if doc.Type != TipTapDocType { + return TipTapDocument{}, errors.New("content must be valid TipTap JSON with type: doc") + } + + // A document with no "content" key (or an explicit null) decodes to a nil slice, which would + // re-marshal to "content":null. Empty content is [] everywhere else, and a client walking the + // array would fault on null, so normalize to the one empty representation. + if doc.Content == nil { + doc.Content = []map[string]any{} + } + + if err := sanitizeTipTapDocument(&doc); err != nil { + return TipTapDocument{}, err + } + return doc, nil +} + +// appendNodeText walks a node subtree once, appending each text leaf and mention label to b. A +// single shared builder keeps extraction O(total text) instead of re-joining every subtree at each +// ancestor, which would copy a leaf once per level of nesting. +func appendNodeText(b *strings.Builder, node map[string]any, depth int) { + if depth > maxTipTapDepth { + return + } + + if textVal, ok := node["text"]; ok { + if text, ok := textVal.(string); ok && text != "" { + if normalized := strings.Join(strings.Fields(text), " "); normalized != "" { + writeSearchTextPart(b, normalized) + } + } + } + + if nodeType, ok := node["type"].(string); ok && (nodeType == "mention" || nodeType == "channelMention") { + if attrs, ok := node["attrs"].(map[string]any); ok { + if label, ok := attrs["label"].(string); ok && label != "" { + writeSearchTextPart(b, "@"+label) + } else if id, ok := attrs["id"].(string); ok && id != "" { + writeSearchTextPart(b, "@"+id) + } + } + } + + if contentVal, ok := node["content"]; ok { + if children, ok := contentVal.([]any); ok { + for _, child := range children { + if childNode, ok := child.(map[string]any); ok { + appendNodeText(b, childNode, depth+1) + } + } + } + } +} + +// writeSearchTextPart appends part to b, separated from any prior content by a single space. +func writeSearchTextPart(b *strings.Builder, part string) { + if b.Len() > 0 { + b.WriteByte(' ') + } + b.WriteString(part) +} + +// maxTipTapDepth bounds recursion over client-supplied content. encoding/json already caps nesting, +// but this rejects a pathologically deep document before the recursive walk and keeps stored content +// within a sane depth. +const maxTipTapDepth = 100 + +// maxTipTapNodes caps the total number of content nodes in a TipTap document. A 2 MiB JSON payload +// can contain hundreds of thousands of tiny nodes; unmarshaling them before sanitization causes +// significant allocation and CPU amplification. The plain-text path is capped at maxPlainTextParagraphs +// (10 000 paragraphs → ~10 000 nodes); rich documents with ~5 inline nodes per paragraph stay well +// under 50 000 for any sane document. +const maxTipTapNodes = 50_000 + +var errAttrDepthExceeded = errors.New("content attribute nesting exceeds the maximum depth") + +// countTipTapNodes returns the total number of nodes in the subtree rooted at node, bounding +// recursion at maxTipTapDepth. It stops counting once the running total exceeds the limit so that +// a document with millions of nodes does not incur a full traversal just to fail the check. +func countTipTapNodes(node map[string]any, depth, runningTotal, limit int) int { + if depth > maxTipTapDepth || runningTotal > limit { + return runningTotal + } + runningTotal++ + if contentVal, ok := node["content"]; ok { + if children, ok := contentVal.([]any); ok { + for _, child := range children { + if childNode, ok := child.(map[string]any); ok { + runningTotal = countTipTapNodes(childNode, depth+1, runningTotal, limit) + if runningTotal > limit { + return runningTotal + } + } + } + } + } + return runningTotal +} + +func sanitizeTipTapDocument(doc *TipTapDocument) error { + // Reject documents with pathologically many nodes before the recursive sanitization walk, + // which would otherwise allocate without bound on a crafted payload. + total := 0 + for _, node := range doc.Content { + if node != nil { + total = countTipTapNodes(node, 0, total, maxTipTapNodes) + } + if total > maxTipTapNodes { + return errors.Errorf("content exceeds the maximum of %d nodes", maxTipTapNodes) + } + } + + for i := range doc.Content { + if doc.Content[i] == nil { + return errors.New("content document nodes must be objects") + } + if err := sanitizeTipTapNode(doc.Content[i], 0); err != nil { + return err + } + } + return nil +} + +// urlAttrKeys are the attribute keys (matched case-insensitively) whose values are URLs and must +// pass through sanitizeURL. +var urlAttrKeys = map[string]struct{}{ + "href": {}, + "src": {}, + "poster": {}, + "xlink:href": {}, + "xlinkhref": {}, +} + +// forbiddenNodeTypes are TipTap node type values rejected outright because they map to HTML +// elements that can execute script or embed foreign content. A full allowlist keyed to the editor +// schema would be the stronger posture; this denylist stops the most dangerous types now. +var forbiddenNodeTypes = map[string]struct{}{ + "script": {}, + "iframe": {}, + "embed": {}, + "object": {}, + "noscript": {}, + "template": {}, + "style": {}, + "link": {}, + "svg": {}, + "math": {}, + "animate": {}, + "animatetransform": {}, + "foreignobject": {}, + "maction": {}, +} + +// forbiddenMarkTypes are TipTap mark type values rejected outright. This mirrors forbiddenNodeTypes +// except that "link" is valid as a mark (TipTap's inline hyperlink) — its href is sanitized by +// sanitizeURL rather than being blocked outright. +var forbiddenMarkTypes = map[string]struct{}{ + "script": {}, + "iframe": {}, + "embed": {}, + "object": {}, + "noscript": {}, + "template": {}, + "style": {}, + "svg": {}, + "math": {}, + "animate": {}, + "animatetransform": {}, + "foreignobject": {}, + "maction": {}, +} + +// dangerousAttrKeys are attribute keys (matched case-insensitively) stripped outright regardless of +// value: they can execute script or embed foreign markup, and no supported node needs them. This +// denylist is layered on top of the URL-scheme allowlist. +var dangerousAttrKeys = map[string]struct{}{ + "style": {}, + "formaction": {}, + "action": {}, + "srcdoc": {}, + "srcset": {}, + "background": {}, + "dynsrc": {}, + "lowsrc": {}, + "ping": {}, + "data": {}, +} + +// stripDangerousKeys strips script-bearing keys (event handlers plus the dangerousAttrKeys set) and +// neutralizes dangerous URL schemes on any URL-valued key, at the top level of m only. Key names are +// matched case-insensitively, since HTML attribute names are case-insensitive. +// +// It is applied to attribute maps and also to the node and mark objects themselves, because a +// lenient client renderer may read a dangerous or URL-valued key placed directly on the object +// rather than nested under its "attrs". +func stripDangerousKeys(m map[string]any) { + for key, val := range m { + lower := strings.ToLower(key) + if _, dangerous := dangerousAttrKeys[lower]; strings.HasPrefix(lower, "on") || dangerous { + delete(m, key) + continue + } + // data-* attributes may carry URL values (data-href, data-src, data-url, etc.) that a + // lenient client renderer can treat as navigation targets; sanitize them as URLs. + isURL := strings.HasPrefix(lower, "data-") + if !isURL { + _, isURL = urlAttrKeys[lower] + } + if isURL { + // A URL-valued attribute must be a string. A non-string value (e.g. a JSON array) can + // be coerced back into a dangerous string by a client renderer, so drop it rather than + // leave it untouched. + v, ok := val.(string) + if !ok { + delete(m, key) + continue + } + m[key] = sanitizeURL(v) + } + } +} + +// sanitizeAttrs strips dangerous keys from an attribute map and descends into any nested maps and +// arrays it holds, so a URL or handler buried under a sub-object (e.g. attrs.config.href, a shape +// an extension's schema may define) is neutralized rather than passed through untouched. It fails +// closed: attribute nesting past maxTipTapDepth returns an error (rejecting the whole document) +// rather than silently leaving the over-deep subtree unsanitized. +func sanitizeAttrs(attrs map[string]any, depth int) error { + if depth > maxTipTapDepth { + return errAttrDepthExceeded + } + stripDangerousKeys(attrs) + for _, val := range attrs { + if err := sanitizeAttrValue(val, depth); err != nil { + return err + } + } + return nil +} + +// sanitizeAttrValue recurses into the containers an attribute value may hold. Scalars are left +// alone: only a map can carry a dangerous key. Like sanitizeAttrs, it fails closed past +// maxTipTapDepth. +func sanitizeAttrValue(val any, depth int) error { + if depth > maxTipTapDepth { + return errAttrDepthExceeded + } + switch v := val.(type) { + case map[string]any: + return sanitizeAttrs(v, depth+1) + case []any: + for _, item := range v { + if err := sanitizeAttrValue(item, depth+1); err != nil { + return err + } + } + } + return nil +} + +// nodeSkipKeys are the top-level keys excluded from the flat-key sanitization pass in +// sanitizeTipTapNode: "attrs" is handled separately, and "content"/"marks" get their own recursion. +var nodeSkipKeys = map[string]struct{}{"attrs": {}, "content": {}, "marks": {}} + +// markSkipKeys are the top-level keys excluded from the flat-key sanitization pass for mark objects. +var markSkipKeys = map[string]struct{}{"attrs": {}} + +// sanitizeObjAttrsAndFlatKeys sanitizes the "attrs" sub-object of a node or mark, and any flat keys +// not in skipKeys. stripDangerousKeys must be called on obj before this. +func sanitizeObjAttrsAndFlatKeys(obj map[string]any, attrsErrMsg string, skipKeys map[string]struct{}, depth int) error { + if attrsVal, ok := obj["attrs"]; ok && attrsVal != nil { + attrs, ok := attrsVal.(map[string]any) + if !ok { + return errors.New(attrsErrMsg) + } + if err := sanitizeAttrs(attrs, 0); err != nil { + return err + } + } + for key, val := range obj { + if _, skip := skipKeys[key]; skip { + continue + } + if err := sanitizeAttrValue(val, depth); err != nil { + return err + } + } + return nil +} + +func sanitizeTipTapNode(node map[string]any, depth int) error { + if node == nil { + return errors.New("content node must not be null") + } + if depth > maxTipTapDepth { + return errors.New("content nesting exceeds the maximum depth") + } + + // Strip dangerous/URL keys placed directly on the node object, then sanitize its attrs. The + // node's own keys need the same treatment as a mark's: "content" and "marks" are walked below, + // and no supported node key collides with the handler/URL sets. + stripDangerousKeys(node) + + nodeType, ok := node["type"].(string) + if !ok || nodeType == "" { + return errors.New("content node must have a non-empty type field") + } + if _, forbidden := forbiddenNodeTypes[strings.ToLower(nodeType)]; forbidden { + return errors.Errorf("content node type %q is not allowed", nodeType) + } + + if err := sanitizeObjAttrsAndFlatKeys(node, "content node attrs must be an object", nodeSkipKeys, depth); err != nil { + return err + } + + if marksVal, ok := node["marks"]; ok && marksVal != nil { + marksArray, ok := marksVal.([]any) + if !ok { + return errors.New("content node marks must be an array") + } + for _, mark := range marksArray { + markNode, ok := mark.(map[string]any) + if !ok { + return errors.New("content mark must be an object") + } + // Sanitize both the mark's nested attrs and any dangerous/URL keys placed directly on + // the mark object (a non-standard shape a lenient renderer may read). + stripDangerousKeys(markNode) + markType, _ := markNode["type"].(string) + if _, forbidden := forbiddenMarkTypes[strings.ToLower(markType)]; forbidden { + return errors.Errorf("content mark type %q is not allowed", markType) + } + if err := sanitizeObjAttrsAndFlatKeys(markNode, "content mark attrs must be an object", markSkipKeys, depth); err != nil { + return err + } + } + } + + if contentVal, ok := node["content"]; ok && contentVal != nil { + contentArray, ok := contentVal.([]any) + if !ok { + return errors.New("content node content must be an array") + } + for _, child := range contentArray { + childNode, ok := child.(map[string]any) + if !ok { + return errors.New("content child must be an object") + } + if err := sanitizeTipTapNode(childNode, depth+1); err != nil { + return err + } + } + } + return nil +} + +// safeImageMIMETypes are the only image MIME types allowed through data: URIs. SVG is excluded +// because it can carry script. safeImageDataPrefixes is derived from this slice so adding a new +// type here keeps both in sync automatically. +var safeImageMIMETypes = []string{ + "image/png", + "image/jpeg", + "image/gif", + "image/webp", + "image/bmp", +} + +var safeImageDataPrefixes = func() []string { + out := make([]string, len(safeImageMIMETypes)) + for i, mt := range safeImageMIMETypes { + out[i] = "data:" + mt + } + return out +}() + +// sniffBase64Chars is how many leading base64 characters are decoded for content sniffing. 24 chars +// decode to 18 bytes, covering the longest signature http.DetectContentType matches on for the types +// above (WebP, whose RIFF container marker runs to byte 14). 24 is a multiple of 4, so a truncated +// (unpadded) prefix stays valid StdEncoding; a short payload that already carries "=" padding within +// those 24 chars decodes too (unlike RawStdEncoding, which rejects padding). +const sniffBase64Chars = 24 + +// isBase64ImagePayload confirms a data:image/* URI actually carries base64-encoded bytes that sniff +// as one of the allowed raster images, so a script-bearing payload cannot ride in under an allowed +// MIME label (e.g. an SVG declared as image/png). +func isBase64ImagePayload(url string) bool { + meta, payload, ok := strings.Cut(url, ",") + if !ok { + return false + } + if !strings.Contains(strings.ToLower(meta), ";base64") { + return false + } + trimmed := strings.TrimSpace(payload) + if len(trimmed) > sniffBase64Chars { + trimmed = trimmed[:sniffBase64Chars] + } + data, err := base64.StdEncoding.DecodeString(trimmed) + if err != nil { + return false + } + contentType, _, _ := strings.Cut(http.DetectContentType(data), ";") + return slices.Contains(safeImageMIMETypes, contentType) +} + +// urlScheme returns the lowercased scheme of a URL and whether one is present. A scheme must sit at +// the very start and precede any '/', '?', or '#', matching how browsers parse schemes; a relative +// reference (no scheme) returns ("", false). +func urlScheme(s string) (string, bool) { + for i, r := range s { + if r == ':' { + if i == 0 { + return "", false + } + return s[:i], true + } + if r == '/' || r == '?' || r == '#' { + return "", false + } + valid := (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '+' || r == '-' || r == '.' + if !valid { + return "", false + } + } + return "", false +} + +// urlStripChars removes the ASCII tab/newline/CR that browsers strip from a URL before resolving +// its scheme, so an obfuscated "java\tscript:" cannot slip past the scheme check. +var urlStripChars = strings.NewReplacer("\t", "", "\n", "", "\r", "", "\x00", "") + +// sanitizeURL returns the URL unchanged if its scheme is on the allowlist (or it is a relative +// reference), and "" otherwise. It defends against control-character, leading-whitespace, and +// HTML-entity obfuscation of dangerous schemes (e.g. "java script:alert(1)"). +func sanitizeURL(url string) string { + // Decode HTML entities and strip the tab/newline/CR browsers ignore, so an obfuscated scheme + // (entity-encoded ":" or an embedded control char) is detected. The decode is used only for + // scheme detection; the original url is what gets returned when allowed. + // Two strip passes: the first removes literal control chars, the second removes any that + // html.UnescapeString re-introduces (e.g. " " → "\t"). + // Percent-encoded characters (%09, %0A, etc.) are intentionally NOT stripped: browsers do not + // strip percent-encoded chars from scheme names, so "java%09script:" never parses as "javascript:". + cleaned := urlStripChars.Replace(url) + cleaned = html.UnescapeString(cleaned) + cleaned = urlStripChars.Replace(cleaned) + cleaned = strings.TrimFunc(cleaned, func(r rune) bool { return r <= ' ' }) + lower := strings.ToLower(cleaned) + + scheme, hasScheme := urlScheme(lower) + if !hasScheme { + return url + } + switch scheme { + case "http", "https", "mailto", "tel": + return url + case "data": + for _, prefix := range safeImageDataPrefixes { + if strings.HasPrefix(lower, prefix) && isBase64ImagePayload(url) { + return url + } + } + return "" + default: + return "" + } +} diff --git a/server/model/page_content_test.go b/server/model/page_content_test.go new file mode 100644 index 0000000..dbc6259 --- /dev/null +++ b/server/model/page_content_test.go @@ -0,0 +1,433 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package model_test + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/mattermost/mattermost-plugin-docs/server/model" +) + +// marshal round-trips a parsed document to JSON so tests can assert on the sanitized output. +func marshal(t *testing.T, doc model.TipTapDocument) string { + t.Helper() + b, err := json.Marshal(doc) + require.NoError(t, err) + return string(b) +} + +func TestParseTipTapDocument(t *testing.T) { + t.Run("empty string yields an empty doc", func(t *testing.T) { + doc, err := model.ParseTipTapDocument("") + require.NoError(t, err) + require.Equal(t, model.TipTapDocType, doc.Type) + require.Empty(t, doc.Content) + }) + + t.Run("valid doc round-trips", func(t *testing.T) { + doc, err := model.ParseTipTapDocument(`{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"hello"}]}]}`) + require.NoError(t, err) + require.Equal(t, model.TipTapDocType, doc.Type) + require.Len(t, doc.Content, 1) + }) + + t.Run("non-doc top-level type is rejected", func(t *testing.T) { + _, err := model.ParseTipTapDocument(`{"type":"bogus","content":[]}`) + require.Error(t, err) + }) + + t.Run("malformed JSON is rejected", func(t *testing.T) { + _, err := model.ParseTipTapDocument(`{not json`) + require.Error(t, err) + }) + + t.Run("minimal doc without content does not panic", func(t *testing.T) { + doc, err := model.ParseTipTapDocument(`{"type":"doc"}`) + require.NoError(t, err) + require.Empty(t, doc.Content) + }) +} + +func TestParseTipTapDocumentSanitizesURLs(t *testing.T) { + // Each case is a link mark href; want=="" means the scheme must be stripped. + cases := []struct { + name string + href string + want string + }{ + {"plain javascript", "javascript:alert(1)", ""}, + {"tab-obfuscated javascript", "java\tscript:alert(1)", ""}, + {"newline-obfuscated javascript", "java\nscript:alert(1)", ""}, + {"leading-control javascript", "\x01javascript:alert(1)", ""}, + {"uppercase javascript", "JAVASCRIPT:alert(1)", ""}, + {"leading-space javascript", " javascript:alert(1)", ""}, + {"entity-colon javascript", "javascript:alert(1)", ""}, + {"entity-tab javascript", "java script:alert(1)", ""}, + {"vbscript", "vbscript:msgbox(1)", ""}, + {"data html", "data:text/html,", ""}, + {"data svg", "data:image/svg+xml,", ""}, + {"http allowed", "http://example.com/a", "http://example.com/a"}, + {"https allowed", "https://example.com/a", "https://example.com/a"}, + {"mailto allowed", "mailto:a@example.com", "mailto:a@example.com"}, + {"relative allowed", "/pages/abc", "/pages/abc"}, + {"anchor allowed", "#section", "#section"}, + {"tel allowed", "tel:+15551234567", "tel:+15551234567"}, + {"data png allowed", "data:image/png;base64,iVBORw0KGgo=", "data:image/png;base64,iVBORw0KGgo="}, + {"data png non-image payload rejected", "data:image/png;base64,AAAA", ""}, + {"data png mislabeled non-base64 rejected", "data:image/png,", ""}, + // JPEG: magic bytes FF D8 FF → base64 "/9j/" + {"data jpeg allowed", "data:image/jpeg;base64,/9j/AAAA", "data:image/jpeg;base64,/9j/AAAA"}, + // GIF89a: magic bytes 47 49 46 38 39 61 → base64 "R0lGODlh" + {"data gif allowed", "data:image/gif;base64,R0lGODlh", "data:image/gif;base64,R0lGODlh"}, + // BMP: magic bytes 42 4D 00 00 00 00 00 00 00 (9 bytes, no padding) → base64 "Qk0AAAAAAAAA" + {"data bmp allowed", "data:image/bmp;base64,Qk0AAAAAAAAA", "data:image/bmp;base64,Qk0AAAAAAAAA"}, + // SVG relabeled as JPEG must be rejected (it sniffs as text, not an image). + {"data jpeg mislabeled svg rejected", "data:image/jpeg;base64,PHN2Zy8+", ""}, + // WebP: "RIFF" + 4 size bytes + "WEBPVP8 " → base64 "UklGRgAAAABXRUJQVlA4IA==" + {"data webp allowed", "data:image/webp;base64,UklGRgAAAABXRUJQVlA4IA==", "data:image/webp;base64,UklGRgAAAABXRUJQVlA4IA=="}, + // A WAV shares WebP's leading "RIFF" container marker and differs only at byte 8, so the + // payload must be sniffed past that marker rather than matched on its first four bytes. + {"data webp mislabeled wav rejected", "data:image/webp;base64,UklGRgAAAABXQVZFZm10IA==", ""}, + // An ICO is a raster image but is not one of the allowed types, so it cannot ride in under + // an image/png label. + {"data png mislabeled ico rejected", "data:image/png;base64,AAABAAEA", ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + raw := map[string]any{ + "type": "doc", + "content": []any{ + map[string]any{ + "type": "text", + "text": "link", + "marks": []any{ + map[string]any{"type": "link", "attrs": map[string]any{"href": tc.href}}, + }, + }, + }, + } + b, err := json.Marshal(raw) + require.NoError(t, err) + doc, err := model.ParseTipTapDocument(string(b)) + require.NoError(t, err) + + out := marshal(t, doc) + var parsed map[string]any + require.NoError(t, json.Unmarshal([]byte(out), &parsed)) + content := parsed["content"].([]any) + node := content[0].(map[string]any) + mark := node["marks"].([]any)[0].(map[string]any) + gotHref := mark["attrs"].(map[string]any)["href"] + require.Equal(t, tc.want, gotHref, "href sanitization mismatch for %q", tc.href) + }) + } +} + +func TestParseTipTapDocumentRejectsNullNodes(t *testing.T) { + t.Run("null top-level content node", func(t *testing.T) { + _, err := model.ParseTipTapDocument(`{"type":"doc","content":[null]}`) + require.Error(t, err) + }) + + t.Run("null mark in node", func(t *testing.T) { + _, err := model.ParseTipTapDocument(`{"type":"doc","content":[{"type":"text","text":"hello","marks":[null]}]}`) + require.Error(t, err) + }) + + t.Run("null child in content", func(t *testing.T) { + _, err := model.ParseTipTapDocument(`{"type":"doc","content":[{"type":"paragraph","content":[null]}]}`) + require.Error(t, err) + }) + + t.Run("node with empty type field", func(t *testing.T) { + _, err := model.ParseTipTapDocument(`{"type":"doc","content":[{"type":""}]}`) + require.Error(t, err) + }) + + t.Run("node with no type field", func(t *testing.T) { + _, err := model.ParseTipTapDocument(`{"type":"doc","content":[{"text":"orphan"}]}`) + require.Error(t, err) + }) +} + +func TestParseTipTapDocumentDropsScriptAttributes(t *testing.T) { + // An image node carrying event-handler and style attributes must have them stripped while a + // safe src survives. + raw := map[string]any{ + "type": "doc", + "content": []any{ + map[string]any{ + "type": "image", + "attrs": map[string]any{ + "src": "https://example.com/cat.png", + "onerror": "alert(document.cookie)", + "onload": "steal()", + "style": "background:url(javascript:alert(1))", + "alt": "a cat", + }, + }, + }, + } + b, err := json.Marshal(raw) + require.NoError(t, err) + doc, err := model.ParseTipTapDocument(string(b)) + require.NoError(t, err) + + var parsed map[string]any + require.NoError(t, json.Unmarshal([]byte(marshal(t, doc)), &parsed)) + attrs := parsed["content"].([]any)[0].(map[string]any)["attrs"].(map[string]any) + + require.Equal(t, "https://example.com/cat.png", attrs["src"], "safe src should survive") + require.Equal(t, "a cat", attrs["alt"], "non-script attr should survive") + require.NotContains(t, attrs, "onerror") + require.NotContains(t, attrs, "onload") + require.NotContains(t, attrs, "style") +} + +func TestParseTipTapDocumentSanitizesCaseInsensitiveAttrs(t *testing.T) { + // HTML attribute names are case-insensitive, so a mixed-case event handler or URL attribute + // must be sanitized the same as its lowercase form. + raw := map[string]any{ + "type": "doc", + "content": []any{ + map[string]any{ + "type": "text", + "text": "link", + "marks": []any{ + map[string]any{"type": "link", "attrs": map[string]any{ + "HREF": "javascript:alert(1)", + "OnClick": "steal()", + }}, + }, + }, + }, + } + b, err := json.Marshal(raw) + require.NoError(t, err) + doc, err := model.ParseTipTapDocument(string(b)) + require.NoError(t, err) + + var parsed map[string]any + require.NoError(t, json.Unmarshal([]byte(marshal(t, doc)), &parsed)) + attrs := parsed["content"].([]any)[0].(map[string]any)["marks"].([]any)[0].(map[string]any)["attrs"].(map[string]any) + + require.Equal(t, "", attrs["HREF"], "uppercase HREF with a dangerous scheme must be neutralized") + require.NotContains(t, attrs, "OnClick", "mixed-case event handler must be stripped") +} + +func TestParseTipTapDocumentStripsDangerousAttrs(t *testing.T) { + // Attributes that can execute script or embed foreign markup must be dropped regardless of + // value, even on a node type the sanitizer does not otherwise recognize. + raw := map[string]any{ + "type": "doc", + "content": []any{ + map[string]any{ + "type": "blockquote", + "attrs": map[string]any{ + "srcdoc": "", + "background": "javascript:alert(1)", + "data": "javascript:alert(1)", + "action": "https://evil.example", + "ping": "https://evil.example", + "srcset": "javascript:alert(1)", + "title": "safe", + }, + }, + }, + } + b, err := json.Marshal(raw) + require.NoError(t, err) + doc, err := model.ParseTipTapDocument(string(b)) + require.NoError(t, err) + + var parsed map[string]any + require.NoError(t, json.Unmarshal([]byte(marshal(t, doc)), &parsed)) + attrs := parsed["content"].([]any)[0].(map[string]any)["attrs"].(map[string]any) + + for _, k := range []string{"srcdoc", "background", "data", "action", "ping", "srcset"} { + require.NotContains(t, attrs, k, "%s must be stripped", k) + } + require.Equal(t, "safe", attrs["title"], "non-dangerous attr should survive") +} + +func TestParseTipTapDocumentSanitizesFlatMarkHref(t *testing.T) { + // A mark that carries href directly on the mark object (not under attrs) must still be neutralized. + raw := map[string]any{ + "type": "doc", + "content": []any{ + map[string]any{ + "type": "text", + "text": "link", + "marks": []any{map[string]any{"type": "link", "href": "javascript:alert(1)"}}, + }, + }, + } + b, err := json.Marshal(raw) + require.NoError(t, err) + doc, err := model.ParseTipTapDocument(string(b)) + require.NoError(t, err) + require.NotContains(t, marshal(t, doc), "javascript:alert") +} + +func TestParseTipTapDocumentSanitizesFlatNodeKeys(t *testing.T) { + // A node that carries a handler or href directly on the node object (not under attrs) must be + // neutralized, exactly as the same shape is on a mark. + raw := map[string]any{ + "type": "doc", + "content": []any{ + map[string]any{ + "type": "paragraph", + "onclick": "alert(document.cookie)", + "href": "javascript:alert(1)", + "content": []any{map[string]any{"type": "text", "text": "hi"}}, + }, + }, + } + b, err := json.Marshal(raw) + require.NoError(t, err) + doc, err := model.ParseTipTapDocument(string(b)) + require.NoError(t, err) + out := marshal(t, doc) + require.NotContains(t, out, "onclick") + require.NotContains(t, out, "javascript:alert") + require.Contains(t, out, "hi", "the node's legitimate content must survive") +} + +func TestParseTipTapDocumentSanitizesNestedAttrs(t *testing.T) { + // A URL nested inside a sub-object of attrs must be neutralized, not passed through because the + // sanitizer only looked at the top level of the attrs map. + raw := map[string]any{ + "type": "doc", + "content": []any{ + map[string]any{ + "type": "image", + "attrs": map[string]any{ + "config": map[string]any{"href": "javascript:alert(1)"}, + "list": []any{map[string]any{"src": "javascript:alert(2)"}}, + }, + }, + }, + } + b, err := json.Marshal(raw) + require.NoError(t, err) + doc, err := model.ParseTipTapDocument(string(b)) + require.NoError(t, err) + require.NotContains(t, marshal(t, doc), "javascript:alert") +} + +func TestParseTipTapDocumentRejectsDeeplyNestedAttrs(t *testing.T) { + // A dangerous URL buried under attrs nested past the depth cap must NOT slip through + // unsanitized: the attrs walk fails closed (rejects the whole document) rather than silently + // stopping and leaving the over-deep subtree untouched. Guards against a fail-open sanitizer + // bypass where a single shallow node hides a deep attrs subtree. + depth := 150 + deep := `{"type":"doc","content":[{"type":"image","attrs":` + + strings.Repeat(`{"a":`, depth) + + `{"href":"javascript:alert(1)"}` + + strings.Repeat(`}`, depth) + + `}]}` + doc, err := model.ParseTipTapDocument(deep) + require.Error(t, err, "attrs nested beyond the limit must be rejected, not silently passed through") + require.NotContains(t, marshal(t, doc), "javascript:alert", "no unsanitized payload may survive") +} + +func TestParseTipTapDocumentDropsNonStringURLAttr(t *testing.T) { + // A URL attribute whose value is not a string (e.g. a JSON array) can be coerced back into a + // dangerous string by a client renderer, so it must be dropped rather than left untouched. + raw := map[string]any{ + "type": "doc", + "content": []any{ + map[string]any{ + "type": "text", + "text": "link", + "marks": []any{ + map[string]any{"type": "link", "attrs": map[string]any{"href": []any{"javascript:alert(1)"}}}, + }, + }, + }, + } + b, err := json.Marshal(raw) + require.NoError(t, err) + doc, err := model.ParseTipTapDocument(string(b)) + require.NoError(t, err) + + var parsed map[string]any + require.NoError(t, json.Unmarshal([]byte(marshal(t, doc)), &parsed)) + attrs := parsed["content"].([]any)[0].(map[string]any)["marks"].([]any)[0].(map[string]any)["attrs"].(map[string]any) + require.NotContains(t, attrs, "href", "non-string URL attr must be dropped") +} + +func TestParseTipTapDocumentRejectsTooDeep(t *testing.T) { + // A pathologically deep document is rejected rather than walked. + depth := 200 + deep := `{"type":"doc","content":` + strings.Repeat(`[{"type":"x","content":`, depth) + `[]` + strings.Repeat(`}]`, depth) + `}` + _, err := model.ParseTipTapDocument(deep) + require.Error(t, err, "content nested beyond the limit must be rejected") +} + +func TestParseTipTapDocumentStripsAdditionalDangerousAttrs(t *testing.T) { + // formaction, dynsrc, and lowsrc are in the denylist but not covered by the main dangerous-attrs test. + raw := map[string]any{ + "type": "doc", + "content": []any{ + map[string]any{ + "type": "form", + "attrs": map[string]any{ + "formaction": "https://evil.example", + "dynsrc": "javascript:alert(1)", + "lowsrc": "javascript:alert(2)", + "title": "safe", + }, + }, + }, + } + b, err := json.Marshal(raw) + require.NoError(t, err) + doc, err := model.ParseTipTapDocument(string(b)) + require.NoError(t, err) + + var parsed map[string]any + require.NoError(t, json.Unmarshal([]byte(marshal(t, doc)), &parsed)) + attrs := parsed["content"].([]any)[0].(map[string]any)["attrs"].(map[string]any) + require.NotContains(t, attrs, "formaction") + require.NotContains(t, attrs, "dynsrc") + require.NotContains(t, attrs, "lowsrc") + require.Equal(t, "safe", attrs["title"]) +} + +func TestBuildSearchText(t *testing.T) { + t.Run("extracts and joins text", func(t *testing.T) { + doc, err := model.ParseTipTapDocument(`{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"hello"},{"type":"text","text":"world"}]}]}`) + require.NoError(t, err) + require.Equal(t, "hello world", model.BuildSearchText(doc)) + }) + + t.Run("extracts mention labels", func(t *testing.T) { + doc, err := model.ParseTipTapDocument(`{"type":"doc","content":[{"type":"mention","attrs":{"label":"alice"}}]}`) + require.NoError(t, err) + require.Equal(t, "@alice", model.BuildSearchText(doc)) + }) + + t.Run("extracts channelMention label", func(t *testing.T) { + doc, err := model.ParseTipTapDocument(`{"type":"doc","content":[{"type":"channelMention","attrs":{"label":"general"}}]}`) + require.NoError(t, err) + require.Equal(t, "@general", model.BuildSearchText(doc)) + }) + + t.Run("empty document returns empty string", func(t *testing.T) { + doc, err := model.ParseTipTapDocument(`{"type":"doc","content":[]}`) + require.NoError(t, err) + require.Equal(t, "", model.BuildSearchText(doc)) + }) + + t.Run("collapses whitespace", func(t *testing.T) { + doc, err := model.ParseTipTapDocument(`{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"a b\n\nc"}]}]}`) + require.NoError(t, err) + require.Equal(t, "a b c", model.BuildSearchText(doc)) + }) +} diff --git a/server/store/draft_store.go b/server/store/draft_store.go index b72e650..f330e58 100644 --- a/server/store/draft_store.go +++ b/server/store/draft_store.go @@ -5,21 +5,38 @@ package store import ( "database/sql" + "fmt" + "strings" "github.com/jmoiron/sqlx" + mmmodel "github.com/mattermost/mattermost/server/public/model" sq "github.com/mattermost/squirrel" "github.com/pkg/errors" "github.com/mattermost/mattermost-plugin-docs/server/model" ) +// draftCycleCheckMaxDepth bounds the parent-chain walk in checkNoDraftCycle. Must be at +// least as large as the page hierarchy depth cap enforced by the app layer. +const draftCycleCheckMaxDepth = 10 + +// MaxDraftsPerUserPerSpace is the maximum number of draft rows a single user may hold in one +// space. Enforced atomically inside UpsertDraft after the space lock, so it holds under +// concurrent creates. The app layer may also use this constant for a fast-path pre-check. +const MaxDraftsPerUserPerSpace = 100 + +// maxActiveEditorsPerPage caps the number of user IDs returned by GetPageActiveEditors. A page +// with more simultaneous editors than this cap is pathological; the cap prevents unbounded result +// sets if LastActiveAt filtering alone is insufficient. +const maxActiveEditorsPerPage = 100 + var draftSelectColumns = []string{ - "UserId", "SpaceId", "PageId", "ParentId", "Title", "Body", "FileIds", "Props", "CreateAt", "UpdateAt", + "UserId", "SpaceId", "PageId", "ParentId", "Title", "Body", "FileIds", "Props", "CreateAt", "UpdateAt", "LastActiveAt", } // draftMetaColumns is the metadata column set for draft queries — Body omitted because it can be up to PageBodyMaxBytes per draft. var draftMetaColumns = []string{ - "UserId", "SpaceId", "PageId", "ParentId", "Title", "FileIds", "Props", "CreateAt", "UpdateAt", + "UserId", "SpaceId", "PageId", "ParentId", "Title", "FileIds", "Props", "CreateAt", "UpdateAt", "LastActiveAt", } // applyDraftLivenessFilter adds the space-liveness JOIN and page-liveness condition shared by @@ -36,12 +53,14 @@ func applyDraftLivenessFilter(q sq.SelectBuilder) sq.SelectBuilder { }) } -// deleteDraftsForPage hard-deletes every user's draft for pageID. Drafts have no soft-delete -// and must be cleaned up when the page is deleted. Must run inside tx. -func (s *Store) deleteDraftsForPage(tx *sqlx.Tx, pageID string) error { +// deleteDraftsForPage hard-deletes every user's draft for pageID scoped to spaceID. Drafts have +// no soft-delete and must be cleaned up when the page is deleted. The spaceID predicate prevents +// a delete in one space from removing drafts for the same pageID in another space. Must run +// inside tx. +func (s *Store) deleteDraftsForPage(tx *sqlx.Tx, pageID, spaceID string) error { query := s.getQueryBuilder(). Delete("DOCS_Draft"). - Where(sq.Eq{"PageId": pageID}) + Where(sq.Eq{"PageId": pageID, "SpaceId": spaceID}) if _, err := s.execBuilder(tx, query); err != nil { return errors.Wrap(err, "failed to delete page drafts") } @@ -50,11 +69,15 @@ func (s *Store) deleteDraftsForPage(tx *sqlx.Tx, pageID string) error { // reparentDraftsForPage reparents every new-page draft pointing at pageID to newParentID, // so drafts don't retain a soft-deleted page as their pending parent. Must run inside tx. +// +// UpdateAt uses GREATEST(now, UpdateAt+1) for the same reason as UpsertDraft: it must be a +// strictly-monotonic token so PublishDraft's CAS-delete cannot match a row that a concurrent +// autosave already advanced past this reparent. func (s *Store) reparentDraftsForPage(tx *sqlx.Tx, pageID, newParentID string, now int64) error { query := s.getQueryBuilder(). Update("DOCS_Draft"). Set("ParentId", newParentID). - Set("UpdateAt", now). + Set("UpdateAt", monotonicBump("UpdateAt", now)). Where(sq.Eq{"ParentId": pageID}) if _, err := s.execBuilder(tx, query); err != nil { return errors.Wrap(err, "failed to reparent page drafts") @@ -62,17 +85,160 @@ func (s *Store) reparentDraftsForPage(tx *sqlx.Tx, pageID, newParentID string, n return nil } +// draftParentExistsTx reports whether userID has a draft for parentID in spaceID, under the +// same visibility rule as GetDraft, reading within tx so it observes uncommitted state. It locks +// the matched parent-draft row (FOR UPDATE OF d) so a concurrent DeleteDraft cannot remove the +// parent between this check and the child-draft insert. Only the draft row is locked, since the +// liveness filter LEFT JOINs the nullable page side, which cannot be a FOR UPDATE target. +func (s *Store) draftParentExistsTx(tx *sqlx.Tx, userID, spaceID, parentID string) (bool, error) { + builder := applyDraftLivenessFilter( + s.getQueryBuilder(). + Select("1"). + From("DOCS_Draft d"), + ).Where(sq.Eq{"d.UserId": userID, "d.SpaceId": spaceID, "d.PageId": parentID}). + Suffix("FOR UPDATE OF d") + + var one int + if err := s.getBuilder(tx, &one, builder); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return false, nil + } + return false, errors.Wrap(err, "failed to check draft parent") + } + return true, nil +} + +// countDraftsForUser returns the number of draft rows the user holds in spaceID, using the given executor. +func (s *Store) countDraftsForUser(e sqlx.ExtContext, userID, spaceID string) (int, error) { + var count int + builder := s.getQueryBuilder(). + Select("COUNT(*)"). + From("DOCS_Draft"). + Where(sq.Eq{"UserId": userID, "SpaceId": spaceID}) + if err := s.getBuilder(e, &count, builder); err != nil { + return 0, errors.Wrap(err, "unable_to_count_drafts_for_user") + } + return count, nil +} + +// draftExistsTx reports whether a draft row keyed by (userID, pageID) currently exists, read within +// tx so it observes the transaction's own view (including the page-row lock the caller already holds). +func (s *Store) draftExistsTx(tx *sqlx.Tx, userID, pageID string) (bool, error) { + var one int + builder := s.getQueryBuilder(). + Select("1"). + From("DOCS_Draft"). + Where(sq.Eq{"UserId": userID, "PageId": pageID}) + switch err := s.getBuilder(tx, &one, builder); { + case err == nil: + return true, nil + case errors.Is(err, sql.ErrNoRows): + return false, nil + default: + return false, errors.Wrap(err, "failed to check draft existence") + } +} + +// checkNoDraftCycle walks the parent chain from startParentID through the caller's draft rows and +// returns an error if leafPageID appears anywhere in the chain (cycle) or if the total depth +// (draft chain + live-page ancestor) would exceed MaxPageHierarchyDepth. A published-page +// ancestor (no matching draft row) terminates the recursion early. Squirrel cannot express +// recursive CTEs, so raw SQL is used here. +func (s *Store) checkNoDraftCycle(tx *sqlx.Tx, userID, leafPageID, startParentID string) error { + // The live_ancestor subquery finds the deepest node in the draft chain that has no draft row + // (i.e. the live-page boundary). COALESCE converts NULL to '' so the struct scan never fails. + query := fmt.Sprintf(` +WITH RECURSIVE chain(node, depth) AS ( + SELECT $1::varchar(26), 0 + UNION ALL + SELECT d.ParentId, chain.depth + 1 + FROM DOCS_Draft d + JOIN chain ON d.PageId = chain.node + WHERE d.UserId = $2 + AND chain.node <> '' + AND chain.depth < %d +) +SELECT + COALESCE(bool_or(node = $3), false) AS is_cycle, + COALESCE(max(depth) >= %d, false) AS too_deep, + COALESCE(max(depth), 0) AS chain_depth, + COALESCE(( + SELECT c.node FROM chain c + WHERE c.node <> '' + AND NOT EXISTS (SELECT 1 FROM DOCS_Draft d2 WHERE d2.UserId = $2 AND d2.PageId = c.node) + ORDER BY c.depth DESC LIMIT 1 + ), '') AS live_ancestor +FROM chain`, draftCycleCheckMaxDepth, draftCycleCheckMaxDepth) + + var result struct { + IsCycle bool `db:"is_cycle"` + TooDeep bool `db:"too_deep"` + ChainDepth int `db:"chain_depth"` + LiveAncestor string `db:"live_ancestor"` + } + if err := s.get(tx, &result, query, startParentID, userID, leafPageID); err != nil { + return errors.Wrap(err, "cycle check: failed to read ancestor chain") + } + if result.IsCycle { + return &ErrInvalidInput{Entity: "Draft", Field: "ParentId", Value: startParentID, Reason: ReasonDraftCycle} + } + if result.TooDeep { + return &ErrInvalidInput{Entity: "Draft", Field: "ParentId", Value: startParentID, Reason: ReasonDraftTooDeep} + } + // Include the live-page ancestor's depth: a draft chain valid on its own can still exceed + // MaxPageHierarchyDepth when the live ancestor is already deeply nested. + if result.LiveAncestor != "" { + liveDepth, err := s.pageDepth(tx, result.LiveAncestor) + if err != nil { + return errors.Wrap(err, "cycle check: failed to read live ancestor depth") + } + // liveDepth counts the ancestor itself; +1 for the new leaf being validated. + if liveDepth+result.ChainDepth+1 > MaxPageHierarchyDepth { + return &ErrInvalidInput{Entity: "Draft", Field: "ParentId", Value: startParentID, Reason: ReasonDraftTooDeep} + } + } + return nil +} + // UpsertDraft creates or replaces the draft keyed by (UserId, PageId). It fills in defaults and // rejects an invalid draft itself, so the caller need not prepare or validate it beforehand. -// If a draft already exists for that key every field is overwritten (no field-level merge), -// except CreateAt, which keeps the existing row's original value. // -// The draft's space must be live; the PageId constraint is enforced at the page lock below. -func (s *Store) UpsertDraft(draft *model.Draft) (_ *model.Draft, err error) { +// An autosave may carry only the fields the editor changed, so on the update path an empty +// ParentId, Title, Body, or FileIds means "not sent", not "cleared", and the stored value is kept (a +// cleared document is EmptyTipTapJSON, not ""). Props are merged key-wise over the stored map. +// CreateAt keeps the existing row's original value. UpdateAt is bumped strictly monotonically +// (GREATEST(incoming, stored+1)), so it is a collision-free version token: publish CAS-deletes the +// draft on this value, and two saves within the same millisecond can no longer share it. All of this +// happens inside the single upsert statement, so two concurrent autosaves cannot lose a field by +// merging against a stale read. The stored row is returned. +// +// parentID encodes the write intent for the ParentId column: nil means "omitted — preserve the +// existing stored parent", a pointer to "" means "clear to root", and a pointer to a valid ID +// means "set to that ID". The draft struct's own ParentId field is not used on the write path. +// +// The draft's space must be live; the PageId must belong to the draft's space. +// fileIDs encodes the write intent for the FileIds column: nil means "omitted — preserve the +// existing stored value", a pointer to an empty slice means "clear to no attachments", and a +// pointer to a non-empty slice means "replace with these IDs". This mirrors parentID's +// preserve/clear/set semantics. +func (s *Store) UpsertDraft(draft *model.Draft, parentID *string, fileIDs *mmmodel.StringArray) (_ *model.Draft, err error) { if draft == nil { return nil, &ErrInvalidInput{Entity: "Draft", Field: "draft", Value: nil} } + // parentIDParam is the SQL-level parameter: nil → SQL NULL (preserve on conflict), non-nil + // → the explicit value (stored via COALESCE on INSERT, used directly on UPDATE). + var parentIDParam interface{} + if parentID != nil { + parentIDParam = *parentID + } + + // fileIDsParam follows the same nil/non-nil semantics as parentIDParam. + var fileIDsParam interface{} + if fileIDs != nil { + fileIDsParam = mmmodel.ArrayToJSON([]string(*fileIDs)) + } + draft.PreSave() if validErr := draft.IsValid(); validErr != nil { return nil, &ErrInvalidInput{Entity: "Draft", Field: "IsValid", Value: validErr.Error(), Reason: validErr.Id} @@ -88,13 +254,33 @@ func (s *Store) UpsertDraft(draft *model.Draft) (_ *model.Draft, err error) { return nil, lockErr } + // Quota check: enforce MaxDraftsPerUserPerSpace atomically inside the space lock so + // concurrent CreateSpaceDraft calls in the same space cannot both pass a stale pre-check + // and each insert a row that pushes the total past the cap. + // Skip the check on the UPDATE path (existing draft) to avoid an unnecessary count query + // on every autosave. + isExisting, existErr := s.draftExistsTx(tx, draft.UserId, draft.PageId) + if existErr != nil { + return nil, existErr + } + if !isExisting { + count, countErr := s.countDraftsForUser(tx, draft.UserId, draft.SpaceId) + if countErr != nil { + return nil, countErr + } + if count >= MaxDraftsPerUserPerSpace { + return nil, &ErrLimitExceeded{Resource: "Draft", Limit: MaxDraftsPerUserPerSpace, Reason: ReasonDraftQuotaExceeded} + } + } + var page struct { SpaceID string DeleteAt int64 OriginalId string + EditAt int64 } pageLockQuery := s.getQueryBuilder(). - Select("SpaceId", "DeleteAt", "OriginalId"). + Select("SpaceId", "DeleteAt", "OriginalId", "EditAt"). From("DOCS_Page"). Where(sq.Eq{"Id": draft.PageId}). Suffix("FOR UPDATE") @@ -105,39 +291,98 @@ func (s *Store) UpsertDraft(draft *model.Draft) (_ *model.Draft, err error) { if page.DeleteAt != 0 || page.OriginalId != "" || page.SpaceID != draft.SpaceId { return nil, &ErrInvalidInput{Entity: "Draft", Field: "PageId", Value: draft.PageId} } + // Refuse to resurrect a draft a concurrent publish already consumed. When this autosave's + // edit-session baseline is behind the page's current EditAt, the page advanced under it (a + // publish or another edit). A still-existing draft row may keep saving — the conflict is + // deferred to publish — but if no row exists, this upsert would re-INSERT a phantom draft + // that a just-committed publish deleted, so reject it as a stale edit instead. Holding the + // page row FOR UPDATE serializes this with PublishDraft's own draft delete, so the existence + // check is stable within the transaction. + conflictReason := "" + if base, ok := draft.EditBaseline(); ok && page.EditAt > base { + conflictReason = ReasonConcurrentEdit + } else if !ok { + // New-page autosave: no optimistic-lock baseline was set. The page row now exists, + // which means a concurrent publish claimed this page id. If the draft no longer + // exists (publish deleted it), reject rather than resurrect it — a re-INSERT here + // would leave stale recoverable content and ghost presence behind. Holding the page + // row FOR UPDATE serializes this check with PublishDraft's draft delete. + conflictReason = ReasonConcurrentAutosave + } + if conflictReason != "" { + // Re-read draft existence now that the page row is locked: a concurrent PublishDraft + // may have committed (deleting the draft) between the pre-lock draftExistsTx above + // and this point, making the earlier isExisting result stale. + isExistingNow, reErr := s.draftExistsTx(tx, draft.UserId, draft.PageId) + if reErr != nil { + return nil, reErr + } + if !isExistingNow { + return nil, &ErrConflict{Resource: "Draft page_id=" + draft.PageId, Reason: conflictReason} + } + } case errors.Is(pErr, sql.ErrNoRows): // New-page draft: no page row to lock. default: return nil, errors.Wrap(pErr, "failed to lock page for draft upsert") } - // Re-validate the parent is still live under the same transaction (see lockLiveParent). A - // cross-space ParentId, or one whose page row does not exist yet, finds no row and is rejected. - if draft.ParentId != "" { - if parentErr := s.lockLiveParent(tx, draft.ParentId, draft.SpaceId, "Draft"); parentErr != nil { + // Re-validate the parent under the same transaction. A parent is valid when it is a live + // page in the draft's space (see tryLockLiveParent) or a draft of the same user in the same + // space — a child draft may sit under a not-yet-published draft parent, and publish gates + // on the parent being published. Anything else is rejected. + // A nil parentID means "preserve existing"; it skips validation. An explicit "" clears to root + // and also skips validation. Only a non-empty parentID needs the liveness and cycle checks. + if parentID != nil && *parentID != "" { + ok, parentErr := s.tryLockLiveParent(tx, *parentID, draft.SpaceId) + if parentErr != nil { return nil, parentErr } + if !ok { + ok, parentErr = s.draftParentExistsTx(tx, draft.UserId, draft.SpaceId, *parentID) + if parentErr != nil { + return nil, parentErr + } + } + if !ok { + return nil, &ErrInvalidInput{Entity: "Draft", Field: "ParentId", Value: *parentID, Reason: ReasonParentNotLive} + } + if cycleErr := s.checkNoDraftCycle(tx, draft.UserId, draft.PageId, *parentID); cycleErr != nil { + return nil, cycleErr + } } + // parentIDParam is SQL NULL when parentID is nil (preserve on conflict), and the dereferenced + // string otherwise. The COALESCE in VALUES ensures NOT NULL is satisfied on INSERT; the CASE in + // the ON CONFLICT clause reads the original bound parameter (not EXCLUDED.ParentId) to + // distinguish nil ("omit, preserve") from "" ("explicit clear to root"). builder := s.getQueryBuilder(). Insert("DOCS_Draft"). Columns(draftSelectColumns...). - Values(draft.UserId, draft.SpaceId, draft.PageId, draft.ParentId, draft.Title, draft.Body, draft.FileIds, draft.GetProps(), draft.CreateAt, draft.UpdateAt). - Suffix("ON CONFLICT (UserId, PageId) DO UPDATE SET SpaceId = EXCLUDED.SpaceId, ParentId = EXCLUDED.ParentId, Title = EXCLUDED.Title, Body = EXCLUDED.Body, FileIds = EXCLUDED.FileIds, Props = EXCLUDED.Props, UpdateAt = EXCLUDED.UpdateAt RETURNING CreateAt") + Values(draft.UserId, draft.SpaceId, draft.PageId, sq.Expr("COALESCE(?::varchar(26), '')", parentIDParam), draft.Title, draft.Body, sq.Expr("COALESCE(?::text, '[]')", fileIDsParam), draft.GetProps(), draft.CreateAt, draft.UpdateAt, draft.LastActiveAt). + Suffix(`ON CONFLICT (UserId, PageId) DO UPDATE SET + SpaceId = DOCS_Draft.SpaceId, + ParentId = CASE WHEN ?::varchar(26) IS NULL THEN DOCS_Draft.ParentId ELSE EXCLUDED.ParentId END, + Title = COALESCE(NULLIF(EXCLUDED.Title, ''), DOCS_Draft.Title), + Body = COALESCE(NULLIF(EXCLUDED.Body, ''), DOCS_Draft.Body), + FileIds = CASE WHEN ?::text IS NULL THEN DOCS_Draft.FileIds ELSE EXCLUDED.FileIds END, + Props = DOCS_Draft.Props || EXCLUDED.Props, + UpdateAt = GREATEST(EXCLUDED.UpdateAt, DOCS_Draft.UpdateAt + 1), + LastActiveAt = GREATEST(EXCLUDED.LastActiveAt, DOCS_Draft.LastActiveAt) + RETURNING `+strings.Join(draftSelectColumns, ", "), parentIDParam, fileIDsParam) - // On the update path CreateAt keeps the existing row's value (it is not in the SET list), - // which can differ from the caller-supplied one, so read the stored value back. - var storedCreateAt int64 - if cErr := s.getBuilder(tx, &storedCreateAt, builder); cErr != nil { + // Read the stored row back: the omitted-field preserve and the props merge happen in the + // statement above, so the returned row — not the caller's struct — is the saved draft. + var stored model.Draft + if cErr := s.getBuilder(tx, &stored, builder); cErr != nil { return nil, errors.Wrap(cErr, "unable_to_upsert_draft") } - draft.CreateAt = storedCreateAt if err = tx.Commit(); err != nil { return nil, errors.Wrap(err, "commit_transaction") } - return draft, nil + return &stored, nil } // GetDraft returns the draft keyed by (userID, pageID), or ErrNotFound. It is gated the same @@ -168,6 +413,31 @@ func (s *Store) GetDraft(userID, pageID string) (*model.Draft, error) { return &draft, nil } +// AnyDraftExistsForPageInSpace reports whether any user has a draft with the given pageID in spaceID. +// Scoping to the space prevents a draft in one space from being mistaken for a page reservation +// in another space. +func (s *Store) AnyDraftExistsForPageInSpace(pageID, spaceID string) (bool, error) { + if pageID == "" { + return false, &ErrInvalidInput{Entity: "Draft", Field: "pageId", Value: pageID} + } + if spaceID == "" { + return false, &ErrInvalidInput{Entity: "Draft", Field: "spaceId", Value: spaceID} + } + var one int + builder := s.getQueryBuilder(). + Select("1"). + From("DOCS_Draft"). + Where(sq.Eq{"PageId": pageID, "SpaceId": spaceID}) + switch err := s.getBuilder(s.db, &one, builder); { + case err == nil: + return true, nil + case errors.Is(err, sql.ErrNoRows): + return false, nil + default: + return false, errors.Wrap(err, "unable_to_check_draft_for_page") + } +} + // DeleteDraft removes the draft keyed by (userID, pageID), or returns ErrNotFound. func (s *Store) DeleteDraft(userID, pageID string) error { if userID == "" { @@ -189,18 +459,110 @@ func (s *Store) DeleteDraft(userID, pageID string) error { return checkRowsAffected(result, "Draft", pageID) } +// DeleteDraftVersion deletes the draft keyed by (userID, pageID) only if its UpdateAt still equals +// expectedUpdateAt. It returns true when a row was deleted, and false — without error — when the +// version no longer matches (a newer autosave exists and must be left intact) or no draft exists. +// Use it to discard a draft the caller believes it has finished with, without clobbering a +// concurrent autosave that landed in the meantime. +func (s *Store) DeleteDraftVersion(userID, pageID string, expectedUpdateAt int64) (bool, error) { + if userID == "" { + return false, &ErrInvalidInput{Entity: "Draft", Field: "userId", Value: userID} + } + if pageID == "" { + return false, &ErrInvalidInput{Entity: "Draft", Field: "pageId", Value: pageID} + } + + builder := s.getQueryBuilder(). + Delete("DOCS_Draft"). + Where(sq.Eq{"UserId": userID, "PageId": pageID, "UpdateAt": expectedUpdateAt}) + + result, err := s.execBuilder(s.db, builder) + if err != nil { + return false, errors.Wrap(err, "unable_to_delete_draft_version") + } + rows, err := result.RowsAffected() + if err != nil { + return false, errors.Wrap(err, "unable_to_read_rows_affected_delete_draft_version") + } + return rows > 0, nil +} + +// DeleteDraftReparenting atomically reparents the calling user's child drafts (those with +// ParentId = pageID) to the deleted draft's own parent, then deletes the draft keyed by +// (userID, pageID). This prevents child drafts from holding a dangling parent after a discard. +// Returns ErrNotFound when no draft exists for (userID, pageID). +func (s *Store) DeleteDraftReparenting(userID, pageID string) (err error) { + if userID == "" { + return &ErrInvalidInput{Entity: "Draft", Field: "userId", Value: userID} + } + if pageID == "" { + return &ErrInvalidInput{Entity: "Draft", Field: "pageId", Value: pageID} + } + + tx, err := s.db.Beginx() + if err != nil { + return errors.Wrap(err, "begin_transaction") + } + defer s.finalizeTransaction(tx, &err) + + // Lock the draft row and read its parent for reparenting. + var draft struct{ ParentId string } + lockQ := s.getQueryBuilder(). + Select("ParentId"). + From("DOCS_Draft"). + Where(sq.Eq{"UserId": userID, "PageId": pageID}). + Suffix("FOR UPDATE") + switch lockErr := s.getBuilder(tx, &draft, lockQ); { + case lockErr == nil: + case errors.Is(lockErr, sql.ErrNoRows): + return &ErrNotFound{EntityName: "Draft", ID: pageID} + default: + return errors.Wrap(lockErr, "failed to lock draft for delete") + } + + // Reparent this user's child drafts to the deleted draft's own parent so they remain valid. + now := mmmodel.GetMillis() + reparentQ := s.getQueryBuilder(). + Update("DOCS_Draft"). + Set("ParentId", draft.ParentId). + Set("UpdateAt", monotonicBump("UpdateAt", now)). + Where(sq.Eq{"UserId": userID, "ParentId": pageID}) + if _, rErr := s.execBuilder(tx, reparentQ); rErr != nil { + return errors.Wrap(rErr, "failed to reparent child drafts") + } + + deleteQ := s.getQueryBuilder(). + Delete("DOCS_Draft"). + Where(sq.Eq{"UserId": userID, "PageId": pageID}) + result, dErr := s.execBuilder(tx, deleteQ) + if dErr != nil { + return errors.Wrap(dErr, "unable_to_delete_draft") + } + if err = checkRowsAffected(result, "Draft", pageID); err != nil { + return err + } + + if err = tx.Commit(); err != nil { + return errors.Wrap(err, "commit_transaction") + } + return nil +} + // GetDraftsForSpace returns the user's drafts in the given space, most-recently-updated first, // with Body omitted (metadata only — see draftMetaColumns). Results pass applyDraftLivenessFilter, // so a soft-deleted space lists no drafts (they survive the soft-delete and reappear after -// RestoreSpace) and a draft whose page is soft-deleted is excluded from results. The result is -// capped at MaxRowsPerQuery; ErrLimitExceeded is returned rather than truncating when more rows match. -func (s *Store) GetDraftsForSpace(userID, spaceID string) ([]*model.Draft, error) { +// RestoreSpace) and a draft whose page is soft-deleted is excluded from results. Results are +// paginated via offset/limit (see applyLimitOffset); callers must pass a positive limit. +func (s *Store) GetDraftsForSpace(userID, spaceID string, offset, limit int) ([]*model.DraftSummary, error) { if userID == "" { return nil, &ErrInvalidInput{Entity: "Draft", Field: "userId", Value: userID} } if spaceID == "" { return nil, &ErrInvalidInput{Entity: "Draft", Field: "spaceId", Value: spaceID} } + if err := requirePositiveLimit("Draft", limit); err != nil { + return nil, err + } builder := applyDraftLivenessFilter( s.getQueryBuilder(). @@ -208,16 +570,66 @@ func (s *Store) GetDraftsForSpace(userID, spaceID string) ([]*model.Draft, error From("DOCS_Draft d"), ). Where(sq.Eq{"d.UserId": userID, "d.SpaceId": spaceID}). - OrderBy("d.UpdateAt DESC"). - Limit(uint64(MaxRowsPerQuery + 1)) + OrderBy("d.UpdateAt DESC, d.PageId") + builder = applyLimitOffset(builder, offset, limit) - drafts := []*model.Draft{} + drafts := make([]*model.DraftSummary, 0, limit) if err := s.selectBuilder(s.db, &drafts, builder); err != nil { return nil, errors.Wrap(err, "unable_to_get_drafts_for_space") } - if len(drafts) > MaxRowsPerQuery { - return nil, &ErrLimitExceeded{Resource: "Drafts for user_id=" + userID + " space_id=" + spaceID, Limit: MaxRowsPerQuery} - } return drafts, nil } + +// CountDraftsForUser returns the number of draft rows the user owns in the given space. +// It counts all draft rows regardless of page liveness, so it reflects the true storage usage +// for quota enforcement. +func (s *Store) CountDraftsForUser(userID, spaceID string) (int, error) { + if userID == "" { + return 0, &ErrInvalidInput{Entity: "Draft", Field: "userId", Value: userID} + } + if spaceID == "" { + return 0, &ErrInvalidInput{Entity: "Draft", Field: "spaceId", Value: spaceID} + } + return s.countDraftsForUser(s.db, userID, spaceID) +} + +// GetPageActiveEditors returns the user IDs who last saved a draft on the page in spaceID at or +// after minActiveAt — users recently editing it. Presence is derived from the shared DOCS_Draft +// table so it is consistent across all cluster nodes. +// +// The result is scoped to spaceID: a draft is keyed by (UserId, PageId) without a space, and an +// unpublished new-page draft has no page row to bound it, so two users in different spaces holding +// a draft at the same reserved page id would otherwise appear in each other's presence set. Scoping +// on the draft's own SpaceId keeps a broadcast to one space from disclosing the other space's editor. +// +// The filter is LastActiveAt, not UpdateAt: UpdateAt also moves when a bulk maintenance write +// touches the row (a page delete reparents its pending child drafts; a move-to-space re-homes +// them), which would report the draft's owner as editing a page they never opened. +func (s *Store) GetPageActiveEditors(pageID, spaceID string, minActiveAt int64) ([]string, error) { + if pageID == "" { + return nil, &ErrInvalidInput{Entity: "Draft", Field: "pageId", Value: pageID} + } + if spaceID == "" { + return nil, &ErrInvalidInput{Entity: "Draft", Field: "spaceId", Value: spaceID} + } + + // Initialize non-nil: a page with no active editors is the common case, and the caller marshals + // this straight into the active-editors REST response and the page_presence_updated WS payload, + // which must carry [] rather than null. + userIDs := []string{} + builder := applyDraftLivenessFilter( + s.getQueryBuilder(). + Select("d.UserId"). + From("DOCS_Draft d"), + ).Where(sq.Eq{"d.PageId": pageID, "d.SpaceId": spaceID}). + Where(sq.GtOrEq{"d.LastActiveAt": minActiveAt}). + OrderBy("d.LastActiveAt DESC"). + Limit(maxActiveEditorsPerPage) + + if err := s.selectBuilder(s.db, &userIDs, builder); err != nil { + return nil, errors.Wrap(err, "failed to get active editors for page") + } + + return userIDs, nil +} diff --git a/server/store/migrations/000005_add_draft_lastactiveat.down.sql b/server/store/migrations/000005_add_draft_lastactiveat.down.sql new file mode 100644 index 0000000..85e8743 --- /dev/null +++ b/server/store/migrations/000005_add_draft_lastactiveat.down.sql @@ -0,0 +1 @@ +ALTER TABLE DOCS_Draft DROP COLUMN IF EXISTS LastActiveAt; diff --git a/server/store/migrations/000005_add_draft_lastactiveat.up.sql b/server/store/migrations/000005_add_draft_lastactiveat.up.sql new file mode 100644 index 0000000..878f898 --- /dev/null +++ b/server/store/migrations/000005_add_draft_lastactiveat.up.sql @@ -0,0 +1,4 @@ +-- LastActiveAt records the user's own last autosave of the draft, which is what editor presence is +-- derived from. It is distinct from UpdateAt, which can also be bumped by internal maintenance +-- writes that do not reflect user activity and would otherwise report the user as an active editor. +ALTER TABLE DOCS_Draft ADD COLUMN IF NOT EXISTS LastActiveAt BIGINT NOT NULL DEFAULT 0; diff --git a/server/store/page_move.go b/server/store/page_move.go index 981f5df..0acf6f2 100644 --- a/server/store/page_move.go +++ b/server/store/page_move.go @@ -247,7 +247,7 @@ func (s *Store) reindexSiblingGroup(tx *sqlx.Tx, channelID, parentID, movedPageI // cycle-safety are all re-validated under lock, so the move is safe regardless of concurrent // operations between the caller's pre-checks and this call. Cross-owner resources // (page-comment Posts, FileInfo) are not re-homed here. -func (s *Store) MovePageToSpace(pageID, sourceSpaceID, targetSpaceID string, parentPageID *string, expectedUpdateAt int64, force bool, maxDepth int) (_ *model.Page, priorParentID string, err error) { +func (s *Store) MovePageToSpace(pageID, sourceSpaceID, targetSpaceID, moverUserID string, parentPageID *string, expectedUpdateAt int64, force bool, maxDepth int) (_ *model.Page, priorParentID string, err error) { if pageID == "" { return nil, "", &ErrInvalidInput{Entity: "Page", Field: "Id", Value: pageID} } @@ -272,18 +272,20 @@ func (s *Store) MovePageToSpace(pageID, sourceSpaceID, targetSpaceID string, par if firstSpace > secondSpace { firstSpace, secondSpace = secondSpace, firstSpace } - targetChannelID, lockErr := s.lockLiveSpaceChannel(tx, firstSpace) + channelA, lockErr := s.lockLiveSpaceChannel(tx, firstSpace) if lockErr != nil { return nil, "", lockErr } + channelB := channelA if secondSpace != firstSpace { - secondChannelID, lockErr := s.lockLiveSpaceChannel(tx, secondSpace) + channelB, lockErr = s.lockLiveSpaceChannel(tx, secondSpace) if lockErr != nil { return nil, "", lockErr } - if secondSpace == targetSpaceID { - targetChannelID = secondChannelID - } + } + targetChannelID := channelA + if secondSpace == targetSpaceID { + targetChannelID = channelB } // Lock the moving page, scoped to the caller's source space: a page relocated out of the URL by a @@ -362,7 +364,7 @@ func (s *Store) MovePageToSpace(pageID, sourceSpaceID, targetSpaceID string, par } // Rewrite SpaceId/ChannelId across the subtree (live rows and drafts). - if e := s.rewriteSubtreeSpace(tx, ids, targetSpaceID, targetChannelID, now); e != nil { + if e := s.rewriteSubtreeSpace(tx, ids, sourceSpaceID, targetSpaceID, targetChannelID, moverUserID, now); e != nil { return nil, "", e } @@ -425,7 +427,34 @@ func (s *Store) collectLiveSubtreeIDs(tx *sqlx.Tx, pageID string) ([]string, int // rewriteSubtreeSpace re-homes the given page IDs onto // targetSpaceID/targetChannelID, chunked, within tx. It rewrites SpaceId/ChannelId across // live DOCS_Page rows, their version snapshots (OriginalId IN ids), and DOCS_Draft rows. -func (s *Store) rewriteSubtreeSpace(tx *sqlx.Tx, ids []string, targetSpaceID, targetChannelID string, now int64) error { +// Only the mover's own drafts are re-homed; other users' drafts for the moved pages are +// deleted, since their page is now in a space they may not be able to access. +func (s *Store) rewriteSubtreeSpace(tx *sqlx.Tx, ids []string, sourceSpaceID, targetSpaceID, targetChannelID, moverUserID string, now int64) error { + // Quota guard: count the mover's drafts that will be re-homed into targetSpaceID (those in + // source that cover moved pages or sit under them as new-page children) and ensure adding + // them won't exceed MaxDraftsPerUserPerSpace in the target. This count is a lower bound for + // the total re-homed set (the cascade loop below can pick up transitively nested new-page + // drafts), so a failure here is correct, but a pass does not guarantee the cascade is safe; + // the cascade is bounded by draftCycleCheckMaxDepth and the count remains low in practice. + var movedDraftCount int + movedCountQ := s.getQueryBuilder(). + Select("COUNT(*)"). + From("DOCS_Draft"). + Where(sq.Eq{"UserId": moverUserID, "SpaceId": sourceSpaceID}). + Where(sq.Or{sq.Eq{"PageId": ids}, sq.Eq{"ParentId": ids}}) + if err := s.getBuilder(tx, &movedDraftCount, movedCountQ); err != nil { + return errors.Wrap(err, "failed to count mover drafts to re-home") + } + if movedDraftCount > 0 { + targetDraftCount, err := s.countDraftsForUser(tx, moverUserID, targetSpaceID) + if err != nil { + return errors.Wrap(err, "failed to count mover drafts in target space") + } + if targetDraftCount+movedDraftCount > MaxDraftsPerUserPerSpace { + return &ErrLimitExceeded{Resource: "Draft", Limit: MaxDraftsPerUserPerSpace, Reason: ReasonDraftQuotaExceeded} + } + } + const chunkSize = 1000 for i := 0; i < len(ids); i += chunkSize { chunk := ids[i:min(i+chunkSize, len(ids))] @@ -452,19 +481,61 @@ func (s *Store) rewriteSubtreeSpace(tx *sqlx.Tx, ids []string, targetSpaceID, ta return errors.Wrap(e, "failed to update subtree snapshots SpaceId/ChannelId") } - // Re-home drafts onto the target space: draft reads are scoped to the page's current space, - // so a draft left behind in the source space would become unreadable after the move. PageId - // matches a moved page's in-progress edit; ParentId matches a pending new-page draft parented - // within the subtree. - draftUpd := s.getQueryBuilder(). + // Re-home the mover's own drafts. UpdateAt uses monotonicBump so it stays a valid CAS + // token even when the move and a concurrent autosave share a millisecond boundary. + moverDraftUpd := s.getQueryBuilder(). Update("DOCS_Draft"). Set("SpaceId", targetSpaceID). - Set("UpdateAt", now). + Set("UpdateAt", monotonicBump("UpdateAt", now)). + Where(sq.Eq{"UserId": moverUserID}). Where(sq.Or{sq.Eq{"PageId": chunk}, sq.Eq{"ParentId": chunk}}) - if _, e := s.execBuilder(tx, draftUpd); e != nil { - return errors.Wrap(e, "failed to update subtree drafts") + if _, e := s.execBuilder(tx, moverDraftUpd); e != nil { + return errors.Wrap(e, "failed to re-home mover drafts") + } + + // Delete other users' drafts for the moved pages: their SpaceId would no longer match + // the page's space, so the liveness filter rejects them anyway. Removing them explicitly + // prevents inaccessible rows from accumulating when the draft owner is not a member of + // the target space. SpaceId = sourceSpaceID prevents cross-space ID collisions from + // removing unrelated drafts that happen to share a PageId or ParentId. + otherDraftDel := s.getQueryBuilder(). + Delete("DOCS_Draft"). + Where(sq.NotEq{"UserId": moverUserID}). + Where(sq.Eq{"SpaceId": sourceSpaceID}). + Where(sq.Or{sq.Eq{"PageId": chunk}, sq.Eq{"ParentId": chunk}}) + if _, e := s.execBuilder(tx, otherDraftDel); e != nil { + return errors.Wrap(e, "failed to delete other-user drafts for moved pages") } } + + // Cascade the space re-home to the mover's transitively-nested new-page drafts (draft B + // whose ParentId is draft A's PageId, not a live page). The chunk loop above matched only + // drafts whose ParentId was a live moved page; draft B is caught here. Loop until stable, + // bounded by draftCycleCheckMaxDepth which caps the draft tree depth. + // Squirrel cannot express UPDATE … FROM …, so the statement is built directly. + for range draftCycleCheckMaxDepth { + result, e := s.exec(tx, ` + UPDATE DOCS_Draft d + SET SpaceId = $1, UpdateAt = GREATEST(d.UpdateAt + 1, $2) + FROM DOCS_Draft parent + WHERE d.UserId = $3 + AND d.SpaceId = $4 + AND parent.UserId = $3 + AND parent.SpaceId = $1 + AND parent.PageId = d.ParentId`, + targetSpaceID, now, moverUserID, sourceSpaceID) + if e != nil { + return errors.Wrap(e, "failed to cascade draft space to nested drafts") + } + rows, rowsErr := result.RowsAffected() + if rowsErr != nil { + return errors.Wrap(rowsErr, "failed to read rows affected for nested draft cascade") + } + if rows == 0 { + break + } + } + return nil } diff --git a/server/store/page_move_test.go b/server/store/page_move_test.go index 0436c79..8b54dcb 100644 --- a/server/store/page_move_test.go +++ b/server/store/page_move_test.go @@ -120,7 +120,7 @@ func TestMovePageToSpace_Store(t *testing.T) { spaceB, err := s.CreateSpace(newSpace(chB)) require.NoError(t, err) - movedRoot, _, err := s.MovePageToSpace(root.Id, spaceA.Id, spaceB.Id, nil, root.UpdateAt, false, store.MaxPageHierarchyDepth) + movedRoot, _, err := s.MovePageToSpace(root.Id, spaceA.Id, spaceB.Id, mmmodel.NewId(), nil, root.UpdateAt, false, store.MaxPageHierarchyDepth) require.NoError(t, err) require.Equal(t, spaceB.Id, movedRoot.SpaceId, "returned page reflects the committed move") require.Equal(t, chB, movedRoot.ChannelId) @@ -158,34 +158,37 @@ func TestMovePageToSpace_Store(t *testing.T) { // An in-progress edit draft on the page, and a pending new-page draft parented under it // (its own PageId has no page row yet). - _, err = s.UpsertDraft(newDraft(user, spaceA.Id, page.Id, "")) + dEdit := newDraft(user, spaceA.Id, page.Id, "") + dEdit.Props = mmmodel.StringInterface{model.DraftPropsOriginalPageEditAt: page.EditAt} + _, err = s.UpsertDraft(dEdit, nil, nil) require.NoError(t, err) - _, err = s.UpsertDraft(newDraft(user, spaceA.Id, mmmodel.NewId(), page.Id)) + parentPageID := page.Id + _, err = s.UpsertDraft(newDraft(user, spaceA.Id, mmmodel.NewId(), parentPageID), &parentPageID, nil) require.NoError(t, err) - sourceBefore, err := s.GetDraftsForSpace(user, spaceA.Id) + sourceBefore, err := s.GetDraftsForSpace(user, spaceA.Id, 0, testDraftListLimit) require.NoError(t, err) require.Len(t, sourceBefore, 2, "both drafts are readable in the source space before the move") - _, _, err = s.MovePageToSpace(page.Id, spaceA.Id, spaceB.Id, nil, page.UpdateAt, false, store.MaxPageHierarchyDepth) + _, _, err = s.MovePageToSpace(page.Id, spaceA.Id, spaceB.Id, user, nil, page.UpdateAt, false, store.MaxPageHierarchyDepth) require.NoError(t, err) movedDraft, err := s.GetDraft(user, page.Id) require.NoError(t, err) require.Equal(t, spaceB.Id, movedDraft.SpaceId, "the edit draft follows the page and stays readable") - targetDrafts, err := s.GetDraftsForSpace(user, spaceB.Id) + targetDrafts, err := s.GetDraftsForSpace(user, spaceB.Id, 0, testDraftListLimit) require.NoError(t, err) require.Len(t, targetDrafts, 2, "both drafts now live in the target space") - sourceAfter, err := s.GetDraftsForSpace(user, spaceA.Id) + sourceAfter, err := s.GetDraftsForSpace(user, spaceA.Id, 0, testDraftListLimit) require.NoError(t, err) require.Empty(t, sourceAfter, "no draft remains stranded in the source space") }) t.Run("empty pageID returns invalid-input", func(t *testing.T) { s := openTestDB(t) - _, _, err := s.MovePageToSpace("", mmmodel.NewId(), mmmodel.NewId(), nil, 0, false, store.MaxPageHierarchyDepth) + _, _, err := s.MovePageToSpace("", mmmodel.NewId(), mmmodel.NewId(), mmmodel.NewId(), nil, 0, false, store.MaxPageHierarchyDepth) require.Error(t, err) var inv *store.ErrInvalidInput require.True(t, errors.As(err, &inv), "expected ErrInvalidInput, got %T: %v", err, err) @@ -193,7 +196,7 @@ func TestMovePageToSpace_Store(t *testing.T) { t.Run("empty sourceSpaceID returns invalid-input", func(t *testing.T) { s := openTestDB(t) - _, _, err := s.MovePageToSpace(mmmodel.NewId(), "", mmmodel.NewId(), nil, 0, false, store.MaxPageHierarchyDepth) + _, _, err := s.MovePageToSpace(mmmodel.NewId(), "", mmmodel.NewId(), mmmodel.NewId(), nil, 0, false, store.MaxPageHierarchyDepth) require.Error(t, err) var inv *store.ErrInvalidInput require.True(t, errors.As(err, &inv), "expected ErrInvalidInput, got %T: %v", err, err) @@ -201,7 +204,7 @@ func TestMovePageToSpace_Store(t *testing.T) { t.Run("empty targetSpaceID returns invalid-input", func(t *testing.T) { s := openTestDB(t) - _, _, err := s.MovePageToSpace(mmmodel.NewId(), mmmodel.NewId(), "", nil, 0, false, store.MaxPageHierarchyDepth) + _, _, err := s.MovePageToSpace(mmmodel.NewId(), mmmodel.NewId(), "", mmmodel.NewId(), nil, 0, false, store.MaxPageHierarchyDepth) require.Error(t, err) var inv *store.ErrInvalidInput require.True(t, errors.As(err, &inv), "expected ErrInvalidInput, got %T: %v", err, err) @@ -213,7 +216,7 @@ func TestMovePageToSpace_Store(t *testing.T) { spaceB, err := s.CreateSpace(newSpace(chB)) require.NoError(t, err) - _, _, err = s.MovePageToSpace(mmmodel.NewId(), mmmodel.NewId(), spaceB.Id, nil, 0, false, store.MaxPageHierarchyDepth) + _, _, err = s.MovePageToSpace(mmmodel.NewId(), mmmodel.NewId(), spaceB.Id, mmmodel.NewId(), nil, 0, false, store.MaxPageHierarchyDepth) require.Error(t, err) require.True(t, store.IsErrNotFound(err), "expected ErrNotFound, got %T: %v", err, err) }) @@ -414,7 +417,7 @@ func TestPageMutations_ScopedToSpace(t *testing.T) { }) t.Run("move-to-space with wrong source space is not found", func(t *testing.T) { - _, _, mErr := s.MovePageToSpace(page.Id, spaceB.Id, page.SpaceId, nil, page.UpdateAt, false, store.MaxPageHierarchyDepth) + _, _, mErr := s.MovePageToSpace(page.Id, spaceB.Id, page.SpaceId, user, nil, page.UpdateAt, false, store.MaxPageHierarchyDepth) require.True(t, store.IsErrNotFound(mErr)) }) diff --git a/server/store/page_store.go b/server/store/page_store.go index 8e396b3..db3f419 100644 --- a/server/store/page_store.go +++ b/server/store/page_store.go @@ -423,7 +423,7 @@ func (s *Store) DeletePage(pageID, spaceID, userID string) (_ string, err error) // A draft is unpublished work on the page, so deleting the page ends its life; a new-page // draft parented under this page is a pending child of it, so it is reparented rather than // deleted (see reparentDraftsForPage). Both cascades run inside this transaction. - if draftErr := s.deleteDraftsForPage(tx, pageID); draftErr != nil { + if draftErr := s.deleteDraftsForPage(tx, pageID, spaceID); draftErr != nil { return "", draftErr } if draftErr := s.reparentDraftsForPage(tx, pageID, deleted.ParentID, now); draftErr != nil { @@ -536,13 +536,12 @@ func (s *Store) RestorePage(pageID, spaceID, userID string, maxDepth int) (_ *mo Set("EditAt", page.EditAt). Set("LastModifiedBy", page.LastModifiedBy). Set("ParentId", page.ParentId). - Set("SortOrder", page.SortOrder) - - restoreQuery = restoreQuery.Where(sq.And{ - sq.Eq{"Id": pageID}, - sq.Eq{"OriginalId": ""}, - sq.NotEq{"DeleteAt": 0}, - }) + Set("SortOrder", page.SortOrder). + Where(sq.And{ + sq.Eq{"Id": pageID}, + sq.Eq{"OriginalId": ""}, + sq.NotEq{"DeleteAt": 0}, + }) result, txErr := s.execBuilder(tx, restoreQuery) if txErr != nil { return nil, errors.Wrap(txErr, "failed to restore page") @@ -704,3 +703,180 @@ func (s *Store) GetSpacePages(spaceID string, offset, limit int) ([]*model.PageS return pages, nil } + +// PublishDraft atomically writes a page (create or update) and deletes the user's draft for +// that page in a single transaction. On the new-page path a PK collision returns ErrConflict so +// the caller can adopt the concurrent winner without a half-state. On the edit path an EditAt +// mismatch (stale optimistic-lock baseline) returns ErrConflict. In both conflict cases the whole +// transaction is rolled back. +// +// draftUpdateAt pins the draft delete to the version the caller read; a concurrent autosave rolls +// the publish back as a ReasonConcurrentAutosave conflict rather than committing older content. +func (s *Store) PublishDraft(isNewPage bool, page *model.Page, userID, spaceID string, force bool, maxDepth int, draftUpdateAt int64) (_ *model.Page, err error) { + if page == nil { + return nil, &ErrInvalidInput{Entity: "Page", Field: "page", Value: nil} + } + if userID == "" { + return nil, &ErrInvalidInput{Entity: "Draft", Field: "userID", Value: userID} + } + if spaceID == "" { + return nil, &ErrInvalidInput{Entity: "Draft", Field: "spaceID", Value: spaceID} + } + // spaceID is the caller's authorized space; the page must live in it. A mismatch means the page + // was relocated by a concurrent move-to-space (edit path) or the caller built it for the wrong + // space — reject rather than write under the stale/foreign space. + if page.SpaceId != spaceID { + return nil, &ErrInvalidInput{Entity: "Page", Field: "SpaceId", Value: page.SpaceId} + } + + tx, err := s.db.Beginx() + if err != nil { + return nil, errors.Wrap(err, "begin_transaction") + } + defer s.finalizeTransaction(tx, &err) + + var result *model.Page + + if isNewPage { + // Lock the space row to serialize with DeleteSpace / RestoreSpace. + spaceChannelID, spErr := s.lockLiveSpaceChannel(tx, page.SpaceId) + if spErr != nil { + return nil, spErr + } + page.ChannelId = spaceChannelID + + if page.ParentId != "" { + if pErr := s.lockLiveParent(tx, page.ParentId, page.SpaceId, "Page"); pErr != nil { + return nil, pErr + } + // Enforce the depth cap against the parent's locked, current depth — atomic with the + // insert, the same guard CreatePage applies. + parentDepth, depthErr := s.pageDepth(tx, page.ParentId) + if depthErr != nil { + return nil, depthErr + } + if capErr := depthCapError("Page parent_id="+page.ParentId+" (publish depth)", parentDepth, 0, maxDepth); capErr != nil { + return nil, capErr + } + } + + sortOrder, sortErr := s.nextSortOrder(tx, page.ChannelId, page.ParentId) + if sortErr != nil { + return nil, sortErr + } + page.SortOrder = sortOrder + + page.PreSave() + if validErr := page.IsValid(); validErr != nil { + return nil, &ErrInvalidInput{Entity: "Page", Field: "IsValid", Value: validErr.Error(), Reason: validErr.Id} + } + + insertQ := s.getQueryBuilder(). + Insert("DOCS_Page"). + Columns(pageColumnList...). + Values(pageToSlice(page)...) + + if _, execErr := s.execBuilder(tx, insertQ); execErr != nil { + if isUniqueViolation(execErr) { + return nil, &ErrConflict{Resource: "Page id=" + page.Id} + } + return nil, errors.Wrap(execErr, "failed to insert page on publish") + } + result = page + } else { + // Edit path: lock the live row, apply the draft's content, CAS on EditAt. + selectQ := s.getQueryBuilder(). + Select(pageColumnList...). + From("DOCS_Page"). + Where(sq.Eq{"Id": page.Id, "SpaceId": page.SpaceId}). + Where(liveNonSnapshotFilter("")). + Suffix("FOR UPDATE") + + var current model.Page + if txErr := s.getBuilder(tx, ¤t, selectQ); txErr != nil { + if errors.Is(txErr, sql.ErrNoRows) { + return nil, &ErrNotFound{EntityName: "Page", ID: page.Id} + } + return nil, errors.Wrap(txErr, "failed to lock page for publish") + } + + // Optimistic-lock: page.EditAt carries the baseline the caller last saw. Unless force, + // a mismatch against the locked current row is a concurrent-edit conflict. + if !force && current.EditAt != page.EditAt { + return nil, &ErrConflict{Resource: "Page id=" + page.Id, Reason: ReasonConcurrentEdit} + } + + // Apply only the fields the draft carried against the locked row, preserving current's value + // for any empty (unset) field. An empty Title/Body means "not sent" (a cleared document is + // EmptyTipTapJSON, not ""), so a partial autosave never wipes an untouched field — and a + // force-publish cannot revert a concurrent edit to a field this draft did not change. + if page.Title != "" { + current.Title = page.Title + } + if page.Body != "" { + current.Body = page.Body + current.SearchText = page.SearchText + } + current.LastModifiedBy = page.LastModifiedBy + current.PreUpdate() + if validErr := current.IsValid(); validErr != nil { + return nil, &ErrInvalidInput{Entity: "Page", Field: "IsValid", Value: validErr.Error(), Reason: validErr.Id} + } + + // Keep EditAt and UpdateAt strictly monotonic, matching UpdatePage: EditAt is the CAS token + // for content edits; UpdateAt may already be ahead of it from a prior structural op. + now := nextMonotonic(mmmodel.GetMillis(), max(current.EditAt, current.UpdateAt)) + updateQ := s.getQueryBuilder(). + Update("DOCS_Page"). + Set("Title", current.Title). + Set("Body", current.Body). + Set("SearchText", current.SearchText). + Set("LastModifiedBy", current.LastModifiedBy). + Set("Props", current.GetProps()). + Set("UpdateAt", now). + Set("EditAt", now). + Where(sq.Eq{"Id": page.Id, "SpaceId": page.SpaceId}). + Where(liveNonSnapshotFilter("")) + + res, execErr := s.execBuilder(tx, updateQ) + if execErr != nil { + return nil, errors.Wrap(execErr, "failed to update page on publish") + } + if raErr := checkRowsAffected(res, "Page", page.Id); raErr != nil { + return nil, raErr + } + current.UpdateAt = now + current.EditAt = now + result = ¤t + } + + // Delete the draft atomically with the page write, but only if it still holds the content the + // caller published. A concurrent autosave bumps UpdateAt, so it matches no row and the publish + // is rolled back — the newer draft survives and the client can publish it. + // + // UpdateAt is the only version token, so a bulk maintenance write that moves it without changing + // content (a page delete reparenting a pending child draft, a move-to-space re-homing it) also + // trips this CAS and surfaces a ReasonConcurrentAutosave conflict when no autosave occurred. The + // failure is safe — clean rollback, no data loss, self-heals when the client republishes — so we + // accept it rather than add a separate content-only version column for this narrow race. + deleteDraftQ := s.getQueryBuilder(). + Delete("DOCS_Draft"). + Where(sq.Eq{"UserId": userID, "PageId": page.Id, "SpaceId": spaceID, "UpdateAt": draftUpdateAt}) + dRes, dErr := s.execBuilder(tx, deleteDraftQ) + if dErr != nil { + return nil, errors.Wrap(dErr, "failed to delete draft on publish") + } + dRows, dRowsErr := dRes.RowsAffected() + if dRowsErr != nil { + return nil, errors.Wrap(dRowsErr, "failed to read rows affected deleting draft on publish") + } + if dRows == 0 { + return nil, &ErrConflict{Resource: "Draft page_id=" + page.Id, Reason: ReasonConcurrentAutosave} + } + + if err = tx.Commit(); err != nil { + return nil, errors.Wrap(err, "commit_transaction") + } + + return result, nil +} diff --git a/server/store/store.go b/server/store/store.go index ca19f12..ff87790 100644 --- a/server/store/store.go +++ b/server/store/store.go @@ -311,6 +311,9 @@ const ( ReasonMaxDepthExceeded = "max_depth_exceeded" ReasonSubtreeMaxDepthExceeded = "subtree_max_depth_exceeded" ReasonParentNotLive = "parent_not_live" + ReasonDraftCycle = "draft_cycle" + ReasonDraftTooDeep = "draft_too_deep" + ReasonDraftQuotaExceeded = "draft_quota_exceeded" ) func (e *ErrInvalidInput) Error() string { @@ -323,12 +326,28 @@ func IsErrInvalidInput(err error) bool { return errors.As(err, &e) } -// ErrConflict is returned when a unique constraint is violated or a CAS check fails. +// Conflict reasons let a caller tell one CAS failure from another without parsing Resource. An +// ErrConflict with no Reason is an unqualified conflict (e.g. a primary-key collision). +const ( + // ReasonConcurrentEdit: the page's EditAt no longer matches the baseline the caller published + // against — someone else edited the page. + ReasonConcurrentEdit = "concurrent_edit" + // ReasonConcurrentAutosave: the draft changed after the caller read it — the caller's own + // editor autosaved while the publish was in flight. + ReasonConcurrentAutosave = "concurrent_autosave" +) + +// ErrConflict is returned when a unique constraint is violated or a CAS check fails. Reason, when +// set, names which CAS failed so a caller can map it to a specific response. type ErrConflict struct { Resource string + Reason string } func (e *ErrConflict) Error() string { + if e.Reason != "" { + return fmt.Sprintf("conflict on %s: %s", e.Resource, e.Reason) + } return fmt.Sprintf("conflict: %s", e.Resource) } @@ -338,6 +357,16 @@ func IsErrConflict(err error) bool { return errors.As(err, &e) } +// ConflictReason returns the Reason of the ErrConflict in err's chain, or "" if err is not an +// ErrConflict or carries no reason. +func ConflictReason(err error) string { + var e *ErrConflict + if errors.As(err, &e) { + return e.Reason + } + return "" +} + // ErrLimitExceeded is returned when a result set exceeds a hard size limit. type ErrLimitExceeded struct { Resource string diff --git a/server/store/store_test.go b/server/store/store_test.go index 2211803..dcc8003 100644 --- a/server/store/store_test.go +++ b/server/store/store_test.go @@ -40,6 +40,10 @@ func restorePageErr(s *store.Store, pageID, spaceID, userID string, maxDepth int // depth cap itself (see testutil.UncappedMaxDepth). const testDefaultMaxDepth = testutil.UncappedMaxDepth +// testDraftListLimit is a limit large enough that a draft listing in these tests is never a +// partial page, so a test can assert on the whole set. +const testDraftListLimit = 100 + // openTestDB opens an isolated Postgres schema for this test run, runs migrations into it, and // returns the Store. The schema is dropped in t.Cleanup so parallel package runs never share // tables. @@ -1010,10 +1014,16 @@ func TestDeletePage(t *testing.T) { require.NoError(t, err) // Two users hold drafts for this page; both must be hard-deleted when the page is. + // UpsertDraft requires the EditAt baseline when the page row already exists. otherUserID := mmmodel.NewId() - _, err = s.UpsertDraft(newDraft(userID, space.Id, created.Id, "")) + withBaseline := func(uid string) *model.Draft { + d := newDraft(uid, space.Id, created.Id, "") + d.Props = mmmodel.StringInterface{model.DraftPropsOriginalPageEditAt: created.EditAt} + return d + } + _, err = s.UpsertDraft(withBaseline(userID), nil, nil) require.NoError(t, err) - _, err = s.UpsertDraft(newDraft(otherUserID, space.Id, created.Id, "")) + _, err = s.UpsertDraft(withBaseline(otherUserID), nil, nil) require.NoError(t, err) require.NoError(t, deletePageErr(s, created.Id, created.SpaceId, userID)) @@ -1045,6 +1055,29 @@ func TestDeletePage(t *testing.T) { require.Equal(t, parent.ParentId, gotChild.ParentId, "child must be reparented to the deleted page's parent") }) + t.Run("reparents draft UpdateAt monotonically", func(t *testing.T) { + // reparentDraftsForPage uses GREATEST(now, UpdateAt+1) rather than a plain SET UpdateAt=now. + // Without GREATEST, a reparent whose `now` was captured before a concurrent autosave could + // move UpdateAt backward, letting a stale publish CAS-delete match a row it should not touch. + parent, err := s.CreatePage(newPage(space.Id, channelID, userID, ""), testDefaultMaxDepth) + require.NoError(t, err) + + // New-page draft whose pending parent is the published page. + draftPageID := mmmodel.NewId() + parentID := parent.Id + saved, err := s.UpsertDraft(newDraft(userID, space.Id, draftPageID, ""), &parentID, nil) + require.NoError(t, err) + before := saved.UpdateAt + + // Deleting the parent triggers reparentDraftsForPage on this draft. + require.NoError(t, deletePageErr(s, parent.Id, space.Id, userID)) + + after, err := s.GetDraft(userID, draftPageID) + require.NoError(t, err) + require.Greater(t, after.UpdateAt, before, + "reparent must strictly advance UpdateAt so a stale publish CAS cannot match the reparented token") + }) + t.Run("missing page returns not-found", func(t *testing.T) { require.True(t, store.IsErrNotFound(deletePageErr(s, mmmodel.NewId(), space.Id, userID))) }) @@ -1618,7 +1651,7 @@ func TestDraft(t *testing.T) { require.NoError(t, spaceErr) spaceID := space.Id - saved, err := s.UpsertDraft(newDraft(userID, spaceID, pageID, "")) + saved, err := s.UpsertDraft(newDraft(userID, spaceID, pageID, ""), nil, nil) require.NoError(t, err) require.NotZero(t, saved.CreateAt) @@ -1636,13 +1669,13 @@ func TestDraft(t *testing.T) { require.NoError(t, spaceErr) spaceID := space.Id - first, err := s.UpsertDraft(newDraft(userID, spaceID, pageID, "")) + first, err := s.UpsertDraft(newDraft(userID, spaceID, pageID, ""), nil, nil) require.NoError(t, err) second := newDraft(userID, spaceID, pageID, "") second.CreateAt = first.CreateAt second.Title = "Updated" - _, err = s.UpsertDraft(second) + _, err = s.UpsertDraft(second, nil, nil) require.NoError(t, err) got, err := s.GetDraft(userID, pageID) @@ -1651,6 +1684,42 @@ func TestDraft(t *testing.T) { require.Equal(t, first.CreateAt, got.CreateAt, "CreateAt preserved across upsert") }) + t.Run("an autosave that omits a field keeps the stored value", func(t *testing.T) { + s := openTestDB(t) + userID, pageID := mmmodel.NewId(), mmmodel.NewId() + space, spaceErr := s.CreateSpace(newSpace(mmmodel.NewId())) + require.NoError(t, spaceErr) + + full := newDraft(userID, space.Id, pageID, "") + full.Title = "Original title" + full.Body = `{"type":"doc","content":[{"type":"paragraph"}]}` + full.Props = mmmodel.StringInterface{model.DraftPropsOriginalPageEditAt: float64(1234)} + stored, err := s.UpsertDraft(full, nil, nil) + require.NoError(t, err) + + // A body-only heartbeat: no title, no props. Neither may be wiped. + bodyOnly := newDraft(userID, space.Id, pageID, "") + bodyOnly.Title = "" + bodyOnly.Body = `{"type":"doc","content":[{"type":"paragraph"},{"type":"paragraph"}]}` + bodyOnly.Props = nil + saved, err := s.UpsertDraft(bodyOnly, nil, nil) + require.NoError(t, err) + require.Equal(t, "Original title", saved.Title, "an omitted title must not wipe the stored one") + require.Equal(t, bodyOnly.Body, saved.Body, "the sent body must be written") + require.Equal(t, float64(1234), saved.Props[model.DraftPropsOriginalPageEditAt], + "an omitted prop must not drop the stored optimistic-lock baseline") + require.Equal(t, stored.CreateAt, saved.CreateAt, "CreateAt preserved across upsert") + + // A title-only heartbeat: no body. The body just written must survive. + titleOnly := newDraft(userID, space.Id, pageID, "") + titleOnly.Title = "Renamed" + titleOnly.Body = "" + saved, err = s.UpsertDraft(titleOnly, nil, nil) + require.NoError(t, err) + require.Equal(t, "Renamed", saved.Title) + require.Equal(t, bodyOnly.Body, saved.Body, "an omitted body must not wipe the stored one") + }) + t.Run("two users can draft the same page id", func(t *testing.T) { s := openTestDB(t) pageID := mmmodel.NewId() @@ -1659,9 +1728,9 @@ func TestDraft(t *testing.T) { spaceID := space.Id userA, userB := mmmodel.NewId(), mmmodel.NewId() - _, err := s.UpsertDraft(newDraft(userA, spaceID, pageID, "")) + _, err := s.UpsertDraft(newDraft(userA, spaceID, pageID, ""), nil, nil) require.NoError(t, err) - _, err = s.UpsertDraft(newDraft(userB, spaceID, pageID, "")) + _, err = s.UpsertDraft(newDraft(userB, spaceID, pageID, ""), nil, nil) require.NoError(t, err) gotA, err := s.GetDraft(userA, pageID) @@ -1679,7 +1748,7 @@ func TestDraft(t *testing.T) { require.NoError(t, spaceErr) spaceID := space.Id - _, err := s.UpsertDraft(newDraft(userID, spaceID, pageID, "")) + _, err := s.UpsertDraft(newDraft(userID, spaceID, pageID, ""), nil, nil) require.NoError(t, err) require.NoError(t, s.DeleteDraft(userID, pageID)) @@ -1706,12 +1775,12 @@ func TestDraft(t *testing.T) { space, err := s.CreateSpace(newSpace(mmmodel.NewId())) require.NoError(t, err) - _, err = s.UpsertDraft(newDraft(userID, space.Id, mmmodel.NewId(), "")) + _, err = s.UpsertDraft(newDraft(userID, space.Id, mmmodel.NewId(), ""), nil, nil) require.NoError(t, err) - second, err := s.UpsertDraft(newDraft(userID, space.Id, mmmodel.NewId(), "")) + second, err := s.UpsertDraft(newDraft(userID, space.Id, mmmodel.NewId(), ""), nil, nil) require.NoError(t, err) - drafts, err := s.GetDraftsForSpace(userID, space.Id) + drafts, err := s.GetDraftsForSpace(userID, space.Id, 0, testDraftListLimit) require.NoError(t, err) require.Len(t, drafts, 2) require.Equal(t, second.PageId, drafts[0].PageId, "most-recently-updated first") @@ -1723,13 +1792,13 @@ func TestDraft(t *testing.T) { space, err := s.CreateSpace(newSpace(mmmodel.NewId())) require.NoError(t, err) - draft, err := s.UpsertDraft(newDraft(userID, space.Id, mmmodel.NewId(), "")) + draft, err := s.UpsertDraft(newDraft(userID, space.Id, mmmodel.NewId(), ""), nil, nil) require.NoError(t, err) require.NoError(t, s.DeleteSpace(space.Id)) // While the space is soft-deleted both reads are gated to nothing... - drafts, err := s.GetDraftsForSpace(userID, space.Id) + drafts, err := s.GetDraftsForSpace(userID, space.Id, 0, testDraftListLimit) require.NoError(t, err) require.Empty(t, drafts, "a soft-deleted space lists no drafts") @@ -1738,7 +1807,7 @@ func TestDraft(t *testing.T) { // ...but the draft row is kept (not purged), so it reappears once the space is restored. require.NoError(t, s.RestoreSpace(space.Id)) - drafts, err = s.GetDraftsForSpace(userID, space.Id) + drafts, err = s.GetDraftsForSpace(userID, space.Id, 0, testDraftListLimit) require.NoError(t, err) require.Len(t, drafts, 1, "restoring the space brings its drafts back") @@ -1767,7 +1836,7 @@ func TestDraft(t *testing.T) { userID, spaceA.Id, pageInB.Id, now) require.NoError(t, rawErr) - drafts, err := s.GetDraftsForSpace(userID, spaceA.Id) + drafts, err := s.GetDraftsForSpace(userID, spaceA.Id, 0, testDraftListLimit) require.NoError(t, err) require.Empty(t, drafts, "a draft whose page belongs to another space must not be listed") }) @@ -1784,7 +1853,7 @@ func TestDraft(t *testing.T) { pageInB, err := s.CreatePage(newPage(spaceB.Id, spaceB.ChannelId, userID, ""), testDefaultMaxDepth) require.NoError(t, err) - _, err = s.UpsertDraft(newDraft(userID, spaceA.Id, pageInB.Id, "")) + _, err = s.UpsertDraft(newDraft(userID, spaceA.Id, pageInB.Id, ""), nil, nil) require.True(t, store.IsErrInvalidInput(err), "expected invalid input for a cross-space page, got %v", err) }) @@ -1798,17 +1867,21 @@ func TestDraft(t *testing.T) { // A draft editing a live page is included. live, err := s.CreatePage(newPage(space.Id, channelID, userID, ""), testDefaultMaxDepth) require.NoError(t, err) - _, err = s.UpsertDraft(newDraft(userID, space.Id, live.Id, "")) + dLive := newDraft(userID, space.Id, live.Id, "") + dLive.Props = mmmodel.StringInterface{model.DraftPropsOriginalPageEditAt: live.EditAt} + _, err = s.UpsertDraft(dLive, nil, nil) require.NoError(t, err) // A draft whose page is soft-deleted is excluded. deleted, err := s.CreatePage(newPage(space.Id, channelID, userID, ""), testDefaultMaxDepth) require.NoError(t, err) - _, err = s.UpsertDraft(newDraft(userID, space.Id, deleted.Id, "")) + dDeleted := newDraft(userID, space.Id, deleted.Id, "") + dDeleted.Props = mmmodel.StringInterface{model.DraftPropsOriginalPageEditAt: deleted.EditAt} + _, err = s.UpsertDraft(dDeleted, nil, nil) require.NoError(t, err) require.NoError(t, deletePageErr(s, deleted.Id, deleted.SpaceId, userID)) - drafts, err := s.GetDraftsForSpace(userID, space.Id) + drafts, err := s.GetDraftsForSpace(userID, space.Id, 0, testDraftListLimit) require.NoError(t, err) require.Len(t, drafts, 1) require.Equal(t, live.Id, drafts[0].PageId) @@ -1828,7 +1901,9 @@ func TestDraft(t *testing.T) { // p.Id IS NULL is false. snap, err := s.CreatePage(newPage(space.Id, channelID, userID, ""), testDefaultMaxDepth) require.NoError(t, err) - _, err = s.UpsertDraft(newDraft(userID, space.Id, snap.Id, "")) + dSnap := newDraft(userID, space.Id, snap.Id, "") + dSnap.Props = mmmodel.StringInterface{model.DraftPropsOriginalPageEditAt: snap.EditAt} + _, err = s.UpsertDraft(dSnap, nil, nil) require.NoError(t, err) _, rawErr := s.ExecBuilderForTest(s.QueryBuilderForTest(). Update("DOCS_Page"). @@ -1837,7 +1912,7 @@ func TestDraft(t *testing.T) { Where(sq.Eq{"Id": snap.Id})) require.NoError(t, rawErr) - drafts, err := s.GetDraftsForSpace(userID, space.Id) + drafts, err := s.GetDraftsForSpace(userID, space.Id, 0, testDraftListLimit) require.NoError(t, err) require.Empty(t, drafts, "a draft on a version snapshot must be excluded") }) @@ -1854,7 +1929,7 @@ func TestDraft(t *testing.T) { require.NoError(t, deletePageErr(s, page.Id, page.SpaceId, userID)) // An autosave landing after the page was deleted must not recreate a draft for it. - _, err = s.UpsertDraft(newDraft(userID, space.Id, page.Id, "")) + _, err = s.UpsertDraft(newDraft(userID, space.Id, page.Id, ""), nil, nil) require.True(t, store.IsErrInvalidInput(err), "expected invalid input for a deleted page, got %v", err) }) @@ -1864,7 +1939,7 @@ func TestDraft(t *testing.T) { require.NoError(t, err) require.NoError(t, s.DeleteSpace(space.Id)) - _, err = s.UpsertDraft(newDraft(mmmodel.NewId(), space.Id, mmmodel.NewId(), "")) + _, err = s.UpsertDraft(newDraft(mmmodel.NewId(), space.Id, mmmodel.NewId(), ""), nil, nil) require.True(t, store.IsErrNotFound(err), "expected not-found for a deleted space, got %v", err) }) @@ -1878,7 +1953,8 @@ func TestDraft(t *testing.T) { parent, err := s.CreatePage(newPage(space.Id, channelID, userID, ""), testDefaultMaxDepth) require.NoError(t, err) - saved, err := s.UpsertDraft(newDraft(userID, space.Id, mmmodel.NewId(), parent.Id)) + parentID := parent.Id + saved, err := s.UpsertDraft(newDraft(userID, space.Id, mmmodel.NewId(), parentID), &parentID, nil) require.NoError(t, err) require.Equal(t, parent.Id, saved.ParentId) }) @@ -1888,7 +1964,8 @@ func TestDraft(t *testing.T) { space, err := s.CreateSpace(newSpace(mmmodel.NewId())) require.NoError(t, err) - _, err = s.UpsertDraft(newDraft(mmmodel.NewId(), space.Id, mmmodel.NewId(), mmmodel.NewId())) + missingParentID := mmmodel.NewId() + _, err = s.UpsertDraft(newDraft(mmmodel.NewId(), space.Id, mmmodel.NewId(), missingParentID), &missingParentID, nil) require.True(t, store.IsErrInvalidInput(err), "expected invalid input for a missing parent, got %v", err) }) @@ -1903,7 +1980,8 @@ func TestDraft(t *testing.T) { require.NoError(t, err) require.NoError(t, deletePageErr(s, parent.Id, parent.SpaceId, userID)) - _, err = s.UpsertDraft(newDraft(userID, space.Id, mmmodel.NewId(), parent.Id)) + parentID := parent.Id + _, err = s.UpsertDraft(newDraft(userID, space.Id, mmmodel.NewId(), parentID), &parentID, nil) require.True(t, store.IsErrInvalidInput(err), "expected invalid input for a deleted parent, got %v", err) }) @@ -1919,22 +1997,52 @@ func TestDraft(t *testing.T) { parentInB, err := s.CreatePage(newPage(spaceB.Id, spaceB.ChannelId, userID, ""), testDefaultMaxDepth) require.NoError(t, err) - _, err = s.UpsertDraft(newDraft(userID, spaceA.Id, mmmodel.NewId(), parentInB.Id)) + parentID := parentInB.Id + _, err = s.UpsertDraft(newDraft(userID, spaceA.Id, mmmodel.NewId(), parentID), &parentID, nil) require.True(t, store.IsErrInvalidInput(err), "expected invalid input for a cross-space parent, got %v", err) }) + t.Run("upsert accepts a parent that is the user's own draft in the same space", func(t *testing.T) { + s := openTestDB(t) + space, err := s.CreateSpace(newSpace(mmmodel.NewId())) + require.NoError(t, err) + userID := mmmodel.NewId() + + parentDraft, err := s.UpsertDraft(newDraft(userID, space.Id, mmmodel.NewId(), ""), nil, nil) + require.NoError(t, err) + + parentPageID := parentDraft.PageId + saved, err := s.UpsertDraft(newDraft(userID, space.Id, mmmodel.NewId(), parentPageID), &parentPageID, nil) + require.NoError(t, err) + require.Equal(t, parentDraft.PageId, saved.ParentId) + }) + + t.Run("upsert rejects a parent that is another user's draft", func(t *testing.T) { + s := openTestDB(t) + space, err := s.CreateSpace(newSpace(mmmodel.NewId())) + require.NoError(t, err) + userA, userB := mmmodel.NewId(), mmmodel.NewId() + + otherDraft, err := s.UpsertDraft(newDraft(userA, space.Id, mmmodel.NewId(), ""), nil, nil) + require.NoError(t, err) + + otherPageID := otherDraft.PageId + _, err = s.UpsertDraft(newDraft(userB, space.Id, mmmodel.NewId(), otherPageID), &otherPageID, nil) + require.True(t, store.IsErrInvalidInput(err), "expected invalid input for another user's draft parent, got %v", err) + }) + t.Run("drafts for space is scoped to the user", func(t *testing.T) { s := openTestDB(t) space, err := s.CreateSpace(newSpace(mmmodel.NewId())) require.NoError(t, err) userA, userB := mmmodel.NewId(), mmmodel.NewId() - _, err = s.UpsertDraft(newDraft(userA, space.Id, mmmodel.NewId(), "")) + _, err = s.UpsertDraft(newDraft(userA, space.Id, mmmodel.NewId(), ""), nil, nil) require.NoError(t, err) - _, err = s.UpsertDraft(newDraft(userB, space.Id, mmmodel.NewId(), "")) + _, err = s.UpsertDraft(newDraft(userB, space.Id, mmmodel.NewId(), ""), nil, nil) require.NoError(t, err) - drafts, err := s.GetDraftsForSpace(userA, space.Id) + drafts, err := s.GetDraftsForSpace(userA, space.Id, 0, testDraftListLimit) require.NoError(t, err) require.Len(t, drafts, 1) require.Equal(t, userA, drafts[0].UserId) @@ -1951,7 +2059,7 @@ func TestDraft(t *testing.T) { d.Body = `{"type":"doc","content":[{"type":"paragraph"}]}` d.FileIds = mmmodel.StringArray{mmmodel.NewId(), mmmodel.NewId()} d.Props = mmmodel.StringInterface{"k": float64(1700000000123)} - _, err := s.UpsertDraft(d) + _, err := s.UpsertDraft(d, nil, &d.FileIds) require.NoError(t, err) got, err := s.GetDraft(userID, pageID) @@ -1968,7 +2076,7 @@ func TestDraft(t *testing.T) { require.NoError(t, spaceErr) spaceID := space.Id - _, err := s.UpsertDraft(newDraft(userID, spaceID, pageID, "")) + _, err := s.UpsertDraft(newDraft(userID, spaceID, pageID, ""), nil, nil) require.NoError(t, err) got, err := s.GetDraft(userID, pageID) @@ -1991,13 +2099,13 @@ func TestDraft(t *testing.T) { require.NoError(t, err) firstParent, secondParent := firstPage.Id, secondPage.Id - _, err = s.UpsertDraft(newDraft(userID, spaceID, pageID, firstParent)) + _, err = s.UpsertDraft(newDraft(userID, spaceID, pageID, firstParent), &firstParent, nil) require.NoError(t, err) got, err := s.GetDraft(userID, pageID) require.NoError(t, err) require.Equal(t, firstParent, got.ParentId) - _, err = s.UpsertDraft(newDraft(userID, spaceID, pageID, secondParent)) + _, err = s.UpsertDraft(newDraft(userID, spaceID, pageID, secondParent), &secondParent, nil) require.NoError(t, err) got, err = s.GetDraft(userID, pageID) require.NoError(t, err) @@ -2014,7 +2122,7 @@ func TestDraft(t *testing.T) { d := newDraft(userID, spaceID, pageID, "") d.Title = "Title Only" d.Body = "" - _, err := s.UpsertDraft(d) + _, err := s.UpsertDraft(d, nil, nil) require.NoError(t, err) got, err := s.GetDraft(userID, pageID) @@ -2031,14 +2139,14 @@ func TestDraft(t *testing.T) { spaceB, err := s.CreateSpace(newSpace(mmmodel.NewId())) require.NoError(t, err) - _, err = s.UpsertDraft(newDraft(userID, spaceA.Id, mmmodel.NewId(), "")) + _, err = s.UpsertDraft(newDraft(userID, spaceA.Id, mmmodel.NewId(), ""), nil, nil) require.NoError(t, err) - _, err = s.UpsertDraft(newDraft(userID, spaceA.Id, mmmodel.NewId(), "")) + _, err = s.UpsertDraft(newDraft(userID, spaceA.Id, mmmodel.NewId(), ""), nil, nil) require.NoError(t, err) - _, err = s.UpsertDraft(newDraft(userID, spaceB.Id, mmmodel.NewId(), "")) + _, err = s.UpsertDraft(newDraft(userID, spaceB.Id, mmmodel.NewId(), ""), nil, nil) require.NoError(t, err) - drafts, err := s.GetDraftsForSpace(userID, spaceA.Id) + drafts, err := s.GetDraftsForSpace(userID, spaceA.Id, 0, testDraftListLimit) require.NoError(t, err) require.Len(t, drafts, 2) for _, d := range drafts { @@ -2048,7 +2156,7 @@ func TestDraft(t *testing.T) { t.Run("drafts for space returns empty when user has none", func(t *testing.T) { s := openTestDB(t) - drafts, err := s.GetDraftsForSpace(mmmodel.NewId(), mmmodel.NewId()) + drafts, err := s.GetDraftsForSpace(mmmodel.NewId(), mmmodel.NewId(), 0, testDraftListLimit) require.NoError(t, err) require.Empty(t, drafts) }) @@ -2059,17 +2167,45 @@ func TestDraft(t *testing.T) { // Upsert runs the full model IsValid, so a malformed (non-empty) id is rejected as // invalid input. - _, err := s.UpsertDraft(newDraft("bad", valid, valid, "")) + _, err := s.UpsertDraft(newDraft("bad", valid, valid, ""), nil, nil) require.True(t, store.IsErrInvalidInput(err), "upsert with bad user id, got %v", err) + // Upsert with nil draft must return ErrInvalidInput. + _, err = s.UpsertDraft(nil, nil, nil) + require.True(t, store.IsErrInvalidInput(err), "upsert nil draft, got %v", err) + // Get/Delete guard only against empty ids (matching the page/space store convention); // a non-empty but unknown id falls through to the query and returns not-found. _, err = s.GetDraft("", valid) require.True(t, store.IsErrInvalidInput(err), "get with empty user id, got %v", err) + _, err = s.GetDraft(valid, "") + require.True(t, store.IsErrInvalidInput(err), "get with empty page id, got %v", err) + + err = s.DeleteDraft("", valid) + require.True(t, store.IsErrInvalidInput(err), "delete with empty user id, got %v", err) + err = s.DeleteDraft(valid, "") require.True(t, store.IsErrInvalidInput(err), "delete with empty page id, got %v", err) }) + + t.Run("GetDraftsForSpace rejects empty userID", func(t *testing.T) { + s := openTestDB(t) + _, err := s.GetDraftsForSpace("", mmmodel.NewId(), 0, testDraftListLimit) + require.True(t, store.IsErrInvalidInput(err), "got %v", err) + }) + + t.Run("GetDraftsForSpace rejects empty spaceID", func(t *testing.T) { + s := openTestDB(t) + _, err := s.GetDraftsForSpace(mmmodel.NewId(), "", 0, testDraftListLimit) + require.True(t, store.IsErrInvalidInput(err), "got %v", err) + }) + + t.Run("GetDraftsForSpace rejects non-positive limit", func(t *testing.T) { + s := openTestDB(t) + _, err := s.GetDraftsForSpace(mmmodel.NewId(), mmmodel.NewId(), 0, 0) + require.True(t, store.IsErrInvalidInput(err), "zero limit must be rejected, got %v", err) + }) } // TestDeletePageReparentsPendingDrafts verifies that deleting a page reparents the new-page @@ -2090,7 +2226,8 @@ func TestDeletePageReparentsPendingDrafts(t *testing.T) { // A new-page draft (its own page not yet created) pending as a child of parent. newPageID := mmmodel.NewId() - _, err = s.UpsertDraft(newDraft(userID, space.Id, newPageID, parent.Id)) + parentID := parent.Id + _, err = s.UpsertDraft(newDraft(userID, space.Id, newPageID, parentID), &parentID, nil) require.NoError(t, err) require.NoError(t, deletePageErr(s, parent.Id, parent.SpaceId, userID)) @@ -2310,3 +2447,309 @@ func TestWithSpaceMembershipLockAcquireTimeout(t *testing.T) { })) require.True(t, ran) } + +// TestGetActiveEditorsForPage covers the presence window predicate: a draft updated at/after the +// cutoff counts its user as active; one before the cutoff, or on another page, does not. +func TestGetActiveEditorsForPage(t *testing.T) { + s := openTestDB(t) + space, err := s.CreateSpace(newSpace(mmmodel.NewId())) + require.NoError(t, err) + + pageID := mmmodel.NewId() + userID := mmmodel.NewId() + _, err = s.UpsertDraft(newDraft(userID, space.Id, pageID, ""), nil, nil) + require.NoError(t, err) + now := mmmodel.GetMillis() + + t.Run("within window includes the editor", func(t *testing.T) { + editors, err := s.GetPageActiveEditors(pageID, space.Id, now-5*60*1000) + require.NoError(t, err) + require.Contains(t, editors, userID) + }) + + t.Run("cutoff after the update excludes the editor", func(t *testing.T) { + editors, err := s.GetPageActiveEditors(pageID, space.Id, now+60*1000) + require.NoError(t, err) + require.NotContains(t, editors, userID) + }) + + t.Run("a different page has no editors", func(t *testing.T) { + editors, err := s.GetPageActiveEditors(mmmodel.NewId(), space.Id, 0) + require.NoError(t, err) + require.Empty(t, editors) + }) + + t.Run("a new-page draft at the same reserved id in another space does not leak", func(t *testing.T) { + otherSpace, err := s.CreateSpace(newSpace(mmmodel.NewId())) + require.NoError(t, err) + otherUser := mmmodel.NewId() + // Same (reserved) pageID, different space and user — an unpublished new-page draft. + _, err = s.UpsertDraft(newDraft(otherUser, otherSpace.Id, pageID, ""), nil, nil) + require.NoError(t, err) + + editors, err := s.GetPageActiveEditors(pageID, space.Id, mmmodel.GetMillis()-5*60*1000) + require.NoError(t, err) + require.Contains(t, editors, userID) + require.NotContains(t, editors, otherUser, + "presence for a page must not disclose an editor from another space sharing the reserved id") + }) +} + +func TestGetActiveEditorsForPageInputValidation(t *testing.T) { + s := openTestDB(t) + valid := mmmodel.NewId() + + _, err := s.GetPageActiveEditors("", valid, 0) + require.True(t, store.IsErrInvalidInput(err), "empty pageID, got %v", err) + + _, err = s.GetPageActiveEditors(valid, "", 0) + require.True(t, store.IsErrInvalidInput(err), "empty spaceID, got %v", err) +} + +func TestGetActiveEditorsForPageMultipleEditorsOrderedByLastActiveAt(t *testing.T) { + s := openTestDB(t) + space, err := s.CreateSpace(newSpace(mmmodel.NewId())) + require.NoError(t, err) + + pageID := mmmodel.NewId() + userA, userB := mmmodel.NewId(), mmmodel.NewId() + + _, err = s.UpsertDraft(newDraft(userA, space.Id, pageID, ""), nil, nil) + require.NoError(t, err) + _, err = s.UpsertDraft(newDraft(userB, space.Id, pageID, ""), nil, nil) + require.NoError(t, err) + + // Push userA's LastActiveAt into the past so userB (more recent) should appear first. + past := mmmodel.GetMillis() - 60*1000 + _, err = s.ExecBuilderForTest(s.QueryBuilderForTest(). + Update("DOCS_Draft"). + Set("LastActiveAt", past). + Where(sq.Eq{"UserId": userA, "PageId": pageID})) + require.NoError(t, err) + + editors, err := s.GetPageActiveEditors(pageID, space.Id, 0) + require.NoError(t, err) + require.Len(t, editors, 2) + require.Equal(t, userB, editors[0], "most-recently-active editor must appear first") + require.Equal(t, userA, editors[1]) +} + +// TestGetActiveEditorsForPageIgnoresMaintenanceWrites pins presence to LastActiveAt rather than +// UpdateAt. Deleting a page reparents the drafts pending under it, which stamps their UpdateAt +// without their owner having touched them — that must not report the owner as an active editor. +func TestGetActiveEditorsForPageIgnoresMaintenanceWrites(t *testing.T) { + s := openTestDB(t) + channelID := mmmodel.NewId() + space, err := s.CreateSpace(newSpace(channelID)) + require.NoError(t, err) + + userID := mmmodel.NewId() + parent, err := s.CreatePage(newPage(space.Id, channelID, userID, ""), testDefaultMaxDepth) + require.NoError(t, err) + + // A new-page draft pending under the parent, last actually edited well outside the window. + childPageID := mmmodel.NewId() + parentID := parent.Id + _, err = s.UpsertDraft(newDraft(userID, space.Id, childPageID, parentID), &parentID, nil) + require.NoError(t, err) + + stale := mmmodel.GetMillis() - 60*60*1000 + _, err = s.ExecBuilderForTest(s.QueryBuilderForTest(). + Update("DOCS_Draft"). + Set("UpdateAt", stale). + Set("LastActiveAt", stale). + Where(sq.Eq{"UserId": userID, "PageId": childPageID})) + require.NoError(t, err) + + // Someone else deletes the parent, which reparents the pending draft and bumps its UpdateAt. + _, err = s.DeletePage(parent.Id, space.Id, mmmodel.NewId()) + require.NoError(t, err) + + cutoff := mmmodel.GetMillis() - 5*60*1000 + editors, err := s.GetPageActiveEditors(childPageID, space.Id, cutoff) + require.NoError(t, err) + require.NotContains(t, editors, userID, + "reparenting a draft must not report its owner as an active editor") +} + +// TestUpsertDraftBumpsUpdateAtMonotonically guards the draft's UpdateAt version token: it must +// advance strictly past the stored value even when the saving node's wall clock is behind it, so a +// later autosave can never commit an UpdateAt that collides with the value a publish already +// captured (which would let the publish CAS delete the newer draft and ship older content). +func TestUpsertDraftBumpsUpdateAtMonotonically(t *testing.T) { + s := openTestDB(t) + space, err := s.CreateSpace(newSpace(mmmodel.NewId())) + require.NoError(t, err) + userID := mmmodel.NewId() + pageID := mmmodel.NewId() + + _, err = s.UpsertDraft(newDraft(userID, space.Id, pageID, ""), nil, nil) + require.NoError(t, err) + + // Force the stored UpdateAt ahead of the next save's wall clock. Without the monotonic bump, + // the next upsert would write a smaller UpdateAt (its own GetMillis()). + future := mmmodel.GetMillis() + 60*60*1000 + _, err = s.ExecBuilderForTest(s.QueryBuilderForTest(). + Update("DOCS_Draft"). + Set("UpdateAt", future). + Where(sq.Eq{"UserId": userID, "PageId": pageID})) + require.NoError(t, err) + + saved, err := s.UpsertDraft(newDraft(userID, space.Id, pageID, ""), nil, nil) + require.NoError(t, err) + require.Equal(t, future+1, saved.UpdateAt, + "UpdateAt must advance to stored+1 when the incoming timestamp is not already greater") +} + +func TestDeleteDraftVersion(t *testing.T) { + s := openTestDB(t) + space, err := s.CreateSpace(newSpace(mmmodel.NewId())) + require.NoError(t, err) + userID := mmmodel.NewId() + pageID := mmmodel.NewId() + + saved, err := s.UpsertDraft(newDraft(userID, space.Id, pageID, ""), nil, nil) + require.NoError(t, err) + + t.Run("stale version deletes nothing and leaves the draft intact", func(t *testing.T) { + deleted, delErr := s.DeleteDraftVersion(userID, pageID, saved.UpdateAt-1) + require.NoError(t, delErr) + require.False(t, deleted, "a mismatched version must not delete the row") + got, getErr := s.GetDraft(userID, pageID) + require.NoError(t, getErr, "the draft must survive a stale-version delete") + require.Equal(t, saved.UpdateAt, got.UpdateAt) + }) + + t.Run("matching version deletes the draft", func(t *testing.T) { + deleted, delErr := s.DeleteDraftVersion(userID, pageID, saved.UpdateAt) + require.NoError(t, delErr) + require.True(t, deleted, "the matching version must delete the row") + _, getErr := s.GetDraft(userID, pageID) + require.True(t, store.IsErrNotFound(getErr), "the draft must be gone") + }) + + t.Run("missing draft reports false without error", func(t *testing.T) { + deleted, delErr := s.DeleteDraftVersion(userID, mmmodel.NewId(), 1) + require.NoError(t, delErr) + require.False(t, deleted) + }) +} + +// TestPublishDraft covers the atomic publish transaction at the store boundary: the new-page +// insert-and-delete-draft path, and the edit path's optimistic-lock CAS. +func TestPublishDraft(t *testing.T) { + t.Run("new page inserts the page and deletes the draft", func(t *testing.T) { + s := openTestDB(t) + channelID := mmmodel.NewId() + space, err := s.CreateSpace(newSpace(channelID)) + require.NoError(t, err) + userID := mmmodel.NewId() + pageID := mmmodel.NewId() + + draft, err := s.UpsertDraft(newDraft(userID, space.Id, pageID, ""), nil, nil) + require.NoError(t, err) + + page := &model.Page{Id: pageID, SpaceId: space.Id, Title: "Published", Body: `{"type":"doc","content":[]}`, UserId: userID} + published, err := s.PublishDraft(true, page, userID, space.Id, false, testDefaultMaxDepth, draft.UpdateAt) + require.NoError(t, err) + require.Equal(t, pageID, published.Id) + + _, getErr := s.GetDraft(userID, pageID) + require.True(t, store.IsErrNotFound(getErr), "draft must be deleted by publish") + + live, err := s.GetPage(pageID, false) + require.NoError(t, err) + require.Equal(t, "Published", live.Title) + }) + + t.Run("edit path conflicts on a stale baseline", func(t *testing.T) { + s := openTestDB(t) + channelID := mmmodel.NewId() + space, err := s.CreateSpace(newSpace(channelID)) + require.NoError(t, err) + userID := mmmodel.NewId() + + created, err := s.CreatePage(newPage(space.Id, channelID, userID, ""), testDefaultMaxDepth) + require.NoError(t, err) + d := newDraft(userID, space.Id, created.Id, "") + d.Props = mmmodel.StringInterface{model.DraftPropsOriginalPageEditAt: created.EditAt} + draft, err := s.UpsertDraft(d, nil, nil) + require.NoError(t, err) + + edit := *created + edit.Title = "Edited" + edit.Body = `{"type":"doc","content":[]}` + edit.EditAt = created.EditAt - 1 // stale baseline + + _, err = s.PublishDraft(false, &edit, userID, space.Id, false, testDefaultMaxDepth, draft.UpdateAt) + require.True(t, store.IsErrConflict(err), "a stale baseline must conflict, got %v", err) + }) + + t.Run("edit path succeeds with a matching baseline", func(t *testing.T) { + s := openTestDB(t) + channelID := mmmodel.NewId() + space, err := s.CreateSpace(newSpace(channelID)) + require.NoError(t, err) + userID := mmmodel.NewId() + + created, err := s.CreatePage(newPage(space.Id, channelID, userID, ""), testDefaultMaxDepth) + require.NoError(t, err) + d2 := newDraft(userID, space.Id, created.Id, "") + d2.Props = mmmodel.StringInterface{model.DraftPropsOriginalPageEditAt: created.EditAt} + draft, err := s.UpsertDraft(d2, nil, nil) + require.NoError(t, err) + + edit := *created + edit.Title = "Edited" + edit.Body = `{"type":"doc","content":[]}` + edit.EditAt = created.EditAt // matching baseline + + published, err := s.PublishDraft(false, &edit, userID, space.Id, false, testDefaultMaxDepth, draft.UpdateAt) + require.NoError(t, err) + require.Equal(t, "Edited", published.Title) + require.Greater(t, published.EditAt, created.EditAt, "publish advances EditAt") + + _, getErr := s.GetDraft(userID, created.Id) + require.True(t, store.IsErrNotFound(getErr), "draft must be deleted by publish") + }) + + t.Run("an autosave landing after the draft was read rolls the publish back", func(t *testing.T) { + s := openTestDB(t) + channelID := mmmodel.NewId() + space, err := s.CreateSpace(newSpace(channelID)) + require.NoError(t, err) + userID := mmmodel.NewId() + + created, err := s.CreatePage(newPage(space.Id, channelID, userID, ""), testDefaultMaxDepth) + require.NoError(t, err) + d3 := newDraft(userID, space.Id, created.Id, "") + d3.Props = mmmodel.StringInterface{model.DraftPropsOriginalPageEditAt: created.EditAt} + stale, err := s.UpsertDraft(d3, nil, nil) + require.NoError(t, err) + + // The user's editor autosaves again after the publish path read the draft. + newer := newDraft(userID, space.Id, created.Id, "") + newer.Body = `{"type":"doc","content":[{"type":"paragraph"}]}` + newer.Props = mmmodel.StringInterface{model.DraftPropsOriginalPageEditAt: created.EditAt} + newer, err = s.UpsertDraft(newer, nil, nil) + require.NoError(t, err) + require.Greater(t, newer.UpdateAt, stale.UpdateAt, "the autosave must advance UpdateAt") + + edit := *created + edit.Title = "Published from stale content" + edit.Body = `{"type":"doc","content":[]}` + edit.EditAt = created.EditAt + + _, err = s.PublishDraft(false, &edit, userID, space.Id, false, testDefaultMaxDepth, stale.UpdateAt) + require.True(t, store.IsErrConflict(err), "publishing stale draft content must conflict, got %v", err) + + // The page must be untouched and the newer draft must survive for the client to republish. + live, err := s.GetPage(created.Id, false) + require.NoError(t, err) + require.NotEqual(t, "Published from stale content", live.Title, "the rolled-back publish must not have written the page") + + survived, err := s.GetDraft(userID, created.Id) + require.NoError(t, err, "the newer draft must survive the rolled-back publish") + require.Equal(t, newer.Body, survived.Body) + }) +} From 904d4967f8a062c8d2f57643d3c23844f33b8a5e Mon Sep 17 00:00:00 2001 From: "Catalin I. Tomai" Date: Wed, 15 Jul 2026 14:37:12 +0200 Subject: [PATCH 17/36] remove empty app/export_test.go placeholder --- server/app/export_test.go | 7 ------- 1 file changed, 7 deletions(-) delete mode 100644 server/app/export_test.go diff --git a/server/app/export_test.go b/server/app/export_test.go deleted file mode 100644 index 4d28349..0000000 --- a/server/app/export_test.go +++ /dev/null @@ -1,7 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -package app - -// This file exposes private Service internals to package-level tests. Add *ForTest accessors here -// as needed. From 14813835df9b18f43da8c2e6758e505240db0cd1 Mon Sep 17 00:00:00 2001 From: "Catalin I. Tomai" Date: Wed, 15 Jul 2026 14:38:45 +0200 Subject: [PATCH 18/36] fix lint --- server/api_page_drafts.go | 10 +++++----- server/api_page_drafts_test.go | 4 ++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/server/api_page_drafts.go b/server/api_page_drafts.go index bd10eaa..29ca6a6 100644 --- a/server/api_page_drafts.go +++ b/server/api_page_drafts.go @@ -43,11 +43,11 @@ func (p *Plugin) handleUpdatePageDraft(w http.ResponseWriter, r *http.Request) { } var req struct { - ParentId *string `json:"parent_id"` - Title string `json:"title"` - Body string `json:"body"` - FileIds *mmmodel.StringArray `json:"file_ids"` - Props mmmodel.StringInterface `json:"props"` + ParentId *string `json:"parent_id"` + Title string `json:"title"` + Body string `json:"body"` + FileIds *mmmodel.StringArray `json:"file_ids"` + Props mmmodel.StringInterface `json:"props"` } if !p.decodeJSONBody(w, r, maxDraftBodyBytes, &req, "handleUpdatePageDraft", false) { return diff --git a/server/api_page_drafts_test.go b/server/api_page_drafts_test.go index 39a35c4..0df6c30 100644 --- a/server/api_page_drafts_test.go +++ b/server/api_page_drafts_test.go @@ -289,9 +289,9 @@ func TestHandler_DeletePageDraft(t *testing.T) { require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &draft)) pageID := draft.PageId - // DELETE the draft — must return 204. + // DELETE the draft — must return 200. rec = h.do(t, http.MethodDelete, base+"/pages/"+pageID+"/draft", userID, nil) - require.Equal(t, http.StatusNoContent, rec.Code) + require.Equal(t, http.StatusOK, rec.Code) // GET after delete must return 404. rec = h.do(t, http.MethodGet, base+"/pages/"+pageID+"/draft", userID, nil) From eab6ef870a15cf054c3f73b2d24f817495e269a0 Mon Sep 17 00:00:00 2001 From: "Catalin I. Tomai" Date: Wed, 15 Jul 2026 14:47:44 +0200 Subject: [PATCH 19/36] fix lint(1) --- assets/i18n/en.json | 52 ++++++++++++++----------------------- server/store/draft_store.go | 4 +-- 2 files changed, 22 insertions(+), 34 deletions(-) diff --git a/assets/i18n/en.json b/assets/i18n/en.json index 1ccdda9..423885a 100644 --- a/assets/i18n/en.json +++ b/assets/i18n/en.json @@ -167,10 +167,6 @@ "id": "app.page.presence.invalid_space_id.app_error", "translation": "Invalid space ID." }, - { - "id": "app.page.presence.store_error.app_error", - "translation": "The list of active editors could not be loaded." - }, { "id": "app.page.restore.invalid_id.app_error", "translation": "Invalid page ID." @@ -255,10 +251,6 @@ "id": "app.page_draft.delete.not_found.app_error", "translation": "Draft not found." }, - { - "id": "app.page_draft.delete.store_error.app_error", - "translation": "Failed to delete draft." - }, { "id": "app.page_draft.get.invalid_page_id.app_error", "translation": "Invalid page ID." @@ -275,10 +267,6 @@ "id": "app.page_draft.get.not_found.app_error", "translation": "Draft not found." }, - { - "id": "app.page_draft.get.store_error.app_error", - "translation": "Failed to retrieve draft." - }, { "id": "app.page_draft.list.invalid_space_id.app_error", "translation": "Invalid space ID." @@ -299,14 +287,14 @@ "id": "app.page_draft.publish.draft_changed.app_error", "translation": "This draft was saved again while it was being published. Nothing was published; try publishing again to include your latest changes." }, - { - "id": "app.page_draft.publish.edit_conflict.app_error", - "translation": "Someone else edited this page while you were writing. Reopen the page to see their changes, then publish again." - }, { "id": "app.page_draft.publish.draft_not_found.app_error", "translation": "The draft could not be found; it may have already been published or discarded." }, + { + "id": "app.page_draft.publish.edit_conflict.app_error", + "translation": "Someone else edited this page while you were writing. Reopen the page to see their changes, then publish again." + }, { "id": "app.page_draft.publish.page_deleted.app_error", "translation": "The page was deleted and can no longer be published." @@ -316,17 +304,25 @@ "translation": "The parent page must be published before this page can be published." }, { - "id": "app.page_draft.update.invalid_page_id.app_error", - "translation": "Invalid page ID." + "id": "app.page_draft.update.draft_changed.app_error", + "translation": "A concurrent autosave has updated the draft; please republish." }, { - "id": "app.page_draft.update.invalid_parent_id.app_error", - "translation": "Invalid parent ID." + "id": "app.page_draft.update.edit_conflict.app_error", + "translation": "A concurrent edit has been published; please reload the page to continue editing." }, { "id": "app.page_draft.update.invalid_file_id.app_error", "translation": "One or more file IDs are invalid." }, + { + "id": "app.page_draft.update.invalid_page_id.app_error", + "translation": "Invalid page ID." + }, + { + "id": "app.page_draft.update.invalid_parent_id.app_error", + "translation": "Invalid parent ID." + }, { "id": "app.page_draft.update.invalid_space_id.app_error", "translation": "Invalid space ID." @@ -339,22 +335,14 @@ "id": "app.page_draft.update.nil_draft.app_error", "translation": "Draft must not be nil." }, - { - "id": "app.page_draft.update.parent_cycle.app_error", - "translation": "Setting this parent would create a cycle in the draft hierarchy." - }, - { - "id": "app.page_draft.update.draft_changed.app_error", - "translation": "A concurrent autosave has updated the draft; please republish." - }, - { - "id": "app.page_draft.update.edit_conflict.app_error", - "translation": "A concurrent edit has been published; please reload the page to continue editing." - }, { "id": "app.page_draft.update.page_not_found.app_error", "translation": "The page does not exist or is not accessible in this space." }, + { + "id": "app.page_draft.update.parent_cycle.app_error", + "translation": "Setting this parent would create a cycle in the draft hierarchy." + }, { "id": "app.page_draft.update.parent_too_deep.app_error", "translation": "The draft hierarchy is too deep to add another level." diff --git a/server/store/draft_store.go b/server/store/draft_store.go index f330e58..0dee5db 100644 --- a/server/store/draft_store.go +++ b/server/store/draft_store.go @@ -228,13 +228,13 @@ func (s *Store) UpsertDraft(draft *model.Draft, parentID *string, fileIDs *mmmod // parentIDParam is the SQL-level parameter: nil → SQL NULL (preserve on conflict), non-nil // → the explicit value (stored via COALESCE on INSERT, used directly on UPDATE). - var parentIDParam interface{} + var parentIDParam any if parentID != nil { parentIDParam = *parentID } // fileIDsParam follows the same nil/non-nil semantics as parentIDParam. - var fileIDsParam interface{} + var fileIDsParam any if fileIDs != nil { fileIDsParam = mmmodel.ArrayToJSON([]string(*fileIDs)) } From 504e8bd09af3d2567cbc8d59ebcd023d700a0c22 Mon Sep 17 00:00:00 2001 From: "Catalin I. Tomai" Date: Wed, 15 Jul 2026 16:00:59 +0200 Subject: [PATCH 20/36] address coderabbitai comments --- server/app/page_draft.go | 45 ++++++++---------- server/app/page_presence.go | 21 +++++---- server/store/draft_store.go | 91 ++++++++++++++++--------------------- 3 files changed, 71 insertions(+), 86 deletions(-) diff --git a/server/app/page_draft.go b/server/app/page_draft.go index 177f3fa..911b53d 100644 --- a/server/app/page_draft.go +++ b/server/app/page_draft.go @@ -13,6 +13,10 @@ import ( "github.com/mattermost/mattermost-plugin-docs/server/store" ) +func presenceBroadcastKey(pageID, userID string) string { + return pageID + ":" + userID +} + // UpdatePageDraft upserts the calling user's autosave draft for a page in a space. channelID is // the space's backing channel, used to scope the presence broadcast. // @@ -99,13 +103,7 @@ func (s *Service) UpdatePageDraft(draft *model.Draft, parentID *string, fileIDs } pageIsLiveResolved = true if !pageIsLive { - anyDraft, anyErr := s.store.AnyDraftExistsForPageInSpace(draft.PageId, draft.SpaceId) - if anyErr != nil { - return nil, storeAppError("UpdatePageDraft", anyErr) - } - if !anyDraft { - return nil, mmmodel.NewAppError("UpdatePageDraft", "app.page_draft.update.page_not_found.app_error", nil, "", http.StatusNotFound) - } + return nil, mmmodel.NewAppError("UpdatePageDraft", "app.page_draft.update.page_not_found.app_error", nil, "", http.StatusNotFound) } } @@ -140,15 +138,17 @@ func (s *Service) UpdatePageDraft(draft *model.Draft, parentID *string, fileIDs } // Existing published page: rate-limited channel-wide broadcast so other viewers see this user - // in the active-editors indicator. + // in the active-editors indicator. Key by page+user so concurrent editors don't suppress each + // other's first broadcast. + presenceKey := presenceBroadcastKey(saved.PageId, saved.UserId) now := mmmodel.GetMillis() - existing, loaded := s.presenceBroadcastLast.LoadOrStore(saved.PageId, now) + existing, loaded := s.presenceBroadcastLast.LoadOrStore(presenceKey, now) if loaded { lastTime, ok := existing.(int64) if !ok || now-lastTime < presenceBroadcastMinIntervalMs { return saved, nil } - if !s.presenceBroadcastLast.CompareAndSwap(saved.PageId, existing, now) { + if !s.presenceBroadcastLast.CompareAndSwap(presenceKey, existing, now) { return saved, nil } } @@ -290,29 +290,24 @@ func (s *Service) DeletePageDraft(userID, spaceID, pageID, channelID string) *mm // a discarded draft can briefly reappear. It is per-user and cleared by discarding again; fully // preventing it would need a soft-delete tombstone, which is not warranted for this window. // - if err := s.store.DeleteDraftReparenting(userID, pageID); err != nil { + pageWasLive, delErr := s.store.DeleteDraftReparenting(userID, pageID) + if delErr != nil { // A concurrent publish/delete may have removed the draft between the check above and here; // treat that benign race as a 404, matching the not-found path of the initial check, rather // than a 500 that would also emit a spurious server-side error log. - if store.IsErrNotFound(err) { - return mmmodel.NewAppError("DeletePageDraft", "app.page_draft.delete.not_found.app_error", nil, "", http.StatusNotFound).Wrap(err) + if store.IsErrNotFound(delErr) { + return mmmodel.NewAppError("DeletePageDraft", "app.page_draft.delete.not_found.app_error", nil, "", http.StatusNotFound).Wrap(delErr) } - return storeAppError("DeletePageDraft", err) + return storeAppError("DeletePageDraft", delErr) } // Presence cleanup: only broadcast channel-wide if the page is published. A new-page draft // discard was never visible to the channel (no channel broadcast on create), so no cleanup // broadcast is needed. - pageExists, pageExistsErr := s.store.PageExistsInSpace(pageID, spaceID) - if pageExistsErr != nil { - s.log.Warn("DeletePageDraft: failed to check page existence; skipping broadcast", - "page_id", pageID, "err", pageExistsErr) - return nil - } - if !pageExists { + if !pageWasLive { return nil } - s.presenceBroadcastLast.Delete(pageID) + s.presenceBroadcastLast.Delete(presenceBroadcastKey(pageID, userID)) s.broadcastPagePresence(pageID, spaceID, channelID) return nil @@ -466,7 +461,7 @@ func (s *Service) PublishPageDraft(userID, spaceID, pageID string, force bool) ( return nil, false, mmmodel.NewAppError("PublishPageDraft", "app.page_draft.publish.draft_changed.app_error", nil, "", http.StatusConflict) } - s.presenceBroadcastLast.Delete(pageID) + s.presenceBroadcastLast.Delete(presenceBroadcastKey(pageID, userID)) s.broadcastPagePresence(pageID, spaceID, existing.ChannelId) return existing, false, nil } @@ -511,7 +506,7 @@ func (s *Service) PublishPageDraft(userID, spaceID, pageID string, force bool) ( } // The draft is consumed; clear the rate-limit entry and broadcast presence so // the active-editors indicator drops this user, matching the non-conflict path. - s.presenceBroadcastLast.Delete(pageID) + s.presenceBroadcastLast.Delete(presenceBroadcastKey(pageID, userID)) s.broadcastPagePresence(pageID, spaceID, raced.ChannelId) return raced, false, nil } @@ -551,7 +546,7 @@ func (s *Service) PublishPageDraft(userID, spaceID, pageID string, force bool) ( // The publish deleted the draft inside PublishDraft (bypassing the app-level DeletePageDraft // that normally broadcasts presence), so broadcast presence now so the active-editors indicator // clears on other clients. Delete the rate-limit entry first so the broadcast is not suppressed. - s.presenceBroadcastLast.Delete(pageID) + s.presenceBroadcastLast.Delete(presenceBroadcastKey(pageID, userID)) s.broadcastPagePresence(pageID, spaceID, page.ChannelId) return page, isNewPage, nil diff --git a/server/app/page_presence.go b/server/app/page_presence.go index ef5de66..61b4bd1 100644 --- a/server/app/page_presence.go +++ b/server/app/page_presence.go @@ -26,18 +26,18 @@ func activeEditorSince() int64 { } // getActiveEditors returns the user IDs currently editing pageID in spaceID — those with a draft -// updated within the active-editor window. The returned slice is never nil. A store failure is -// logged and yields an empty list (presence is best-effort and must never fail the originating -// request); use this only where a best-effort answer is acceptable, not to back a REST read that -// must surface a store failure. -func (s *Service) getActiveEditors(pageID, spaceID string) []string { +// updated within the active-editor window. The bool is true on success and false on a store +// failure; callers must skip the broadcast on failure to avoid publishing a spurious empty snapshot +// that would wrongly clear a valid presence indicator. Use this only where a best-effort answer is +// acceptable, not to back a REST read that must surface a store failure. +func (s *Service) getActiveEditors(pageID, spaceID string) ([]string, bool) { editors, err := s.store.GetPageActiveEditors(pageID, spaceID, activeEditorSince()) if err != nil { - s.log.Warn("getActiveEditors: failed to query active editors; returning empty", + s.log.Warn("getActiveEditors: failed to query active editors; skipping broadcast", "page_id", pageID, "err", err) - return []string{} + return nil, false } - return editors + return editors, true } // publishSelfPresence sends a presence snapshot to the draft's author only. Used when the page is @@ -62,7 +62,10 @@ func (s *Service) broadcastPagePresence(pageID, spaceID, channelID string) { // Stamp as_of before the editors query so it marks when the snapshot was taken, not when the // broadcast finished assembling — clients use it to discard out-of-order snapshots. asOf := mmmodel.GetMillis() - editors := s.getActiveEditors(pageID, spaceID) + editors, ok := s.getActiveEditors(pageID, spaceID) + if !ok { + return + } s.publishToChannels(wsEventPagePresenceUpdated, map[string]any{ "page_id": pageID, "space_id": spaceID, diff --git a/server/store/draft_store.go b/server/store/draft_store.go index 0dee5db..993ea8a 100644 --- a/server/store/draft_store.go +++ b/server/store/draft_store.go @@ -186,14 +186,14 @@ FROM chain`, draftCycleCheckMaxDepth, draftCycleCheckMaxDepth) return &ErrInvalidInput{Entity: "Draft", Field: "ParentId", Value: startParentID, Reason: ReasonDraftTooDeep} } // Include the live-page ancestor's depth: a draft chain valid on its own can still exceed - // MaxPageHierarchyDepth when the live ancestor is already deeply nested. + // the publishing limit when the live ancestor is already deeply nested. if result.LiveAncestor != "" { liveDepth, err := s.pageDepth(tx, result.LiveAncestor) if err != nil { return errors.Wrap(err, "cycle check: failed to read live ancestor depth") } // liveDepth counts the ancestor itself; +1 for the new leaf being validated. - if liveDepth+result.ChainDepth+1 > MaxPageHierarchyDepth { + if liveDepth+result.ChainDepth+1 > draftCycleCheckMaxDepth { return &ErrInvalidInput{Entity: "Draft", Field: "ParentId", Value: startParentID, Reason: ReasonDraftTooDeep} } } @@ -413,31 +413,6 @@ func (s *Store) GetDraft(userID, pageID string) (*model.Draft, error) { return &draft, nil } -// AnyDraftExistsForPageInSpace reports whether any user has a draft with the given pageID in spaceID. -// Scoping to the space prevents a draft in one space from being mistaken for a page reservation -// in another space. -func (s *Store) AnyDraftExistsForPageInSpace(pageID, spaceID string) (bool, error) { - if pageID == "" { - return false, &ErrInvalidInput{Entity: "Draft", Field: "pageId", Value: pageID} - } - if spaceID == "" { - return false, &ErrInvalidInput{Entity: "Draft", Field: "spaceId", Value: spaceID} - } - var one int - builder := s.getQueryBuilder(). - Select("1"). - From("DOCS_Draft"). - Where(sq.Eq{"PageId": pageID, "SpaceId": spaceID}) - switch err := s.getBuilder(s.db, &one, builder); { - case err == nil: - return true, nil - case errors.Is(err, sql.ErrNoRows): - return false, nil - default: - return false, errors.Wrap(err, "unable_to_check_draft_for_page") - } -} - // DeleteDraft removes the draft keyed by (userID, pageID), or returns ErrNotFound. func (s *Store) DeleteDraft(userID, pageID string) error { if userID == "" { @@ -490,45 +465,57 @@ func (s *Store) DeleteDraftVersion(userID, pageID string, expectedUpdateAt int64 // DeleteDraftReparenting atomically reparents the calling user's child drafts (those with // ParentId = pageID) to the deleted draft's own parent, then deletes the draft keyed by // (userID, pageID). This prevents child drafts from holding a dangling parent after a discard. -// Returns ErrNotFound when no draft exists for (userID, pageID). -func (s *Store) DeleteDraftReparenting(userID, pageID string) (err error) { +// Returns ErrNotFound when no draft exists for (userID, pageID). pageWasLive is true when the +// deleted draft was an edit draft (the page exists as a live page), false for new-page drafts; +// callers use this to decide whether a presence broadcast is needed. +func (s *Store) DeleteDraftReparenting(userID, pageID string) (pageWasLive bool, err error) { if userID == "" { - return &ErrInvalidInput{Entity: "Draft", Field: "userId", Value: userID} + return false, &ErrInvalidInput{Entity: "Draft", Field: "userId", Value: userID} } if pageID == "" { - return &ErrInvalidInput{Entity: "Draft", Field: "pageId", Value: pageID} + return false, &ErrInvalidInput{Entity: "Draft", Field: "pageId", Value: pageID} } tx, err := s.db.Beginx() if err != nil { - return errors.Wrap(err, "begin_transaction") + return false, errors.Wrap(err, "begin_transaction") } defer s.finalizeTransaction(tx, &err) - // Lock the draft row and read its parent for reparenting. - var draft struct{ ParentId string } + // Lock the draft row and read its parent and space for reparenting. + var draft struct { + ParentId string + SpaceId string + } lockQ := s.getQueryBuilder(). - Select("ParentId"). + Select("ParentId", "SpaceId"). From("DOCS_Draft"). Where(sq.Eq{"UserId": userID, "PageId": pageID}). Suffix("FOR UPDATE") switch lockErr := s.getBuilder(tx, &draft, lockQ); { case lockErr == nil: case errors.Is(lockErr, sql.ErrNoRows): - return &ErrNotFound{EntityName: "Draft", ID: pageID} + return false, &ErrNotFound{EntityName: "Draft", ID: pageID} default: - return errors.Wrap(lockErr, "failed to lock draft for delete") - } - - // Reparent this user's child drafts to the deleted draft's own parent so they remain valid. - now := mmmodel.GetMillis() - reparentQ := s.getQueryBuilder(). - Update("DOCS_Draft"). - Set("ParentId", draft.ParentId). - Set("UpdateAt", monotonicBump("UpdateAt", now)). - Where(sq.Eq{"UserId": userID, "ParentId": pageID}) - if _, rErr := s.execBuilder(tx, reparentQ); rErr != nil { - return errors.Wrap(rErr, "failed to reparent child drafts") + return false, errors.Wrap(lockErr, "failed to lock draft for delete") + } + + // Reparent child drafts only when discarding a new-page draft. For edit drafts (page is live), + // children's ParentId still points at a valid live page and must not be changed. + pageIsLive, liveErr := s.PageExistsInSpace(pageID, draft.SpaceId) + if liveErr != nil { + return false, errors.Wrap(liveErr, "failed to check page liveness for reparenting") + } + if !pageIsLive { + now := mmmodel.GetMillis() + reparentQ := s.getQueryBuilder(). + Update("DOCS_Draft"). + Set("ParentId", draft.ParentId). + Set("UpdateAt", monotonicBump("UpdateAt", now)). + Where(sq.Eq{"UserId": userID, "ParentId": pageID}) + if _, rErr := s.execBuilder(tx, reparentQ); rErr != nil { + return false, errors.Wrap(rErr, "failed to reparent child drafts") + } } deleteQ := s.getQueryBuilder(). @@ -536,16 +523,16 @@ func (s *Store) DeleteDraftReparenting(userID, pageID string) (err error) { Where(sq.Eq{"UserId": userID, "PageId": pageID}) result, dErr := s.execBuilder(tx, deleteQ) if dErr != nil { - return errors.Wrap(dErr, "unable_to_delete_draft") + return false, errors.Wrap(dErr, "unable_to_delete_draft") } if err = checkRowsAffected(result, "Draft", pageID); err != nil { - return err + return false, err } if err = tx.Commit(); err != nil { - return errors.Wrap(err, "commit_transaction") + return false, errors.Wrap(err, "commit_transaction") } - return nil + return pageIsLive, nil } // GetDraftsForSpace returns the user's drafts in the given space, most-recently-updated first, From 39b913c52bf4d8f11145c9b99ed68446ac919033 Mon Sep 17 00:00:00 2001 From: "Catalin I. Tomai" Date: Wed, 15 Jul 2026 17:49:39 +0200 Subject: [PATCH 21/36] address coderabbitai comments --- assets/i18n/en.json | 8 ++++++++ server/app/page_draft.go | 13 +++++++++++++ 2 files changed, 21 insertions(+) diff --git a/assets/i18n/en.json b/assets/i18n/en.json index 423885a..074ad13 100644 --- a/assets/i18n/en.json +++ b/assets/i18n/en.json @@ -231,6 +231,14 @@ "id": "app.page_draft.create.invalid_user_id.app_error", "translation": "Invalid user ID." }, + { + "id": "app.page_draft.create.parent_cycle.app_error", + "translation": "Setting this parent would create a cycle in the draft hierarchy." + }, + { + "id": "app.page_draft.create.parent_too_deep.app_error", + "translation": "The draft hierarchy is too deep to add another level." + }, { "id": "app.page_draft.create.quota_exceeded.app_error", "translation": "Draft limit reached. Publish or discard an existing draft before creating a new one." diff --git a/server/app/page_draft.go b/server/app/page_draft.go index 911b53d..e9a35f9 100644 --- a/server/app/page_draft.go +++ b/server/app/page_draft.go @@ -4,6 +4,7 @@ package app import ( + "errors" "net/http" "unicode/utf8" @@ -197,6 +198,18 @@ func (s *Service) CreateSpaceDraft(userID, spaceID, title, pageParentID string) // path — would incorrectly block this call. saved, err := s.store.UpsertDraft(draft, parentPtr, nil) if err != nil { + // Translate hierarchy errors with create-specific keys so the client receives an + // appropriate message. invalidInputAppError maps these to update.* keys, which don't + // apply to a create operation. + var invErr *store.ErrInvalidInput + if errors.As(err, &invErr) { + switch invErr.Reason { + case store.ReasonDraftCycle: + return nil, mmmodel.NewAppError("CreateSpaceDraft", "app.page_draft.create.parent_cycle.app_error", nil, "", http.StatusBadRequest).Wrap(err) + case store.ReasonDraftTooDeep: + return nil, mmmodel.NewAppError("CreateSpaceDraft", "app.page_draft.create.parent_too_deep.app_error", nil, "", http.StatusBadRequest).Wrap(err) + } + } return nil, storeAppError("CreateSpaceDraft", err) } From 2941ed3d23981b4c2dcc4789ad15e53f98192ff1 Mon Sep 17 00:00:00 2001 From: "Catalin I. Tomai" Date: Fri, 17 Jul 2026 09:17:57 +0200 Subject: [PATCH 22/36] post-merge fixes --- server/app/page.go | 47 ---------------------------------------------- server/plugin.go | 1 - 2 files changed, 48 deletions(-) diff --git a/server/app/page.go b/server/app/page.go index 523630c..2d50c5d 100644 --- a/server/app/page.go +++ b/server/app/page.go @@ -334,50 +334,3 @@ func copyTitle(original string) string { title, _ := mmmodel.LimitRunes("Copy of "+original, model.PageTitleMaxRunes) return title } - -// GetPageWithDeleted fetches a page including soft-deleted rows, for restore flows. -func (s *Service) GetPageWithDeleted(pageID string) (*model.Page, *mmmodel.AppError) { - if !mmmodel.IsValidId(pageID) { - return nil, mmmodel.NewAppError("GetPageWithDeleted", "app.page.get.invalid_id.app_error", nil, "", http.StatusBadRequest) - } - page, err := s.store.GetPage(pageID, true) - if err != nil { - return nil, storeAppError("GetPageWithDeleted", "app.page.get", err) - } - // Version snapshots (OriginalId != "") are soft-deleted but not restorable; treat as not found. - if page.OriginalId != "" { - return nil, mmmodel.NewAppError("GetPageWithDeleted", "app.page.get.not_found.app_error", nil, "", http.StatusNotFound) - } - return page, nil -} - -// DeletePage soft-deletes a page; the store promotes its live children to the page's parent -// (not undone on restore, matching Confluence). -func (s *Service) DeletePage(pageID string) *mmmodel.AppError { - if !mmmodel.IsValidId(pageID) { - return mmmodel.NewAppError("DeletePage", "app.page.delete.invalid_id.app_error", nil, "", http.StatusBadRequest) - } - if delErr := s.store.DeletePage(pageID); delErr != nil { - return storeAppError("DeletePage", "app.page.delete", delErr) - } - return nil -} - -// RestorePage un-deletes a soft-deleted page; promoted children stay put (matching -// Confluence), and the page returns under its original parent or the space root if it's gone. -func (s *Service) RestorePage(pageID string) *mmmodel.AppError { - if !mmmodel.IsValidId(pageID) { - return mmmodel.NewAppError("RestorePage", "app.page.restore.invalid_id.app_error", nil, "", http.StatusBadRequest) - } - page, err := s.store.GetPage(pageID, true) - if err != nil { - return storeAppError("RestorePage", "app.page.restore", err) - } - if page.DeleteAt == 0 { - return mmmodel.NewAppError("RestorePage", "app.page.restore.not_deleted.app_error", nil, "", http.StatusBadRequest) - } - if restoreErr := s.store.RestorePage(page.Id); restoreErr != nil { - return storeAppError("RestorePage", "app.page.restore", restoreErr) - } - return nil -} diff --git a/server/plugin.go b/server/plugin.go index ccc3e4d..1628c41 100644 --- a/server/plugin.go +++ b/server/plugin.go @@ -90,7 +90,6 @@ func (p *Plugin) OnActivate() error { p.store = s p.service = app.New(p.store, &p.client.Log, p.client) - p.router = p.initRouter() return nil From 762eb46b53df1d83c7c5cf2fc877ec0ef817d008 Mon Sep 17 00:00:00 2001 From: "Catalin I. Tomai" Date: Fri, 17 Jul 2026 13:10:22 +0200 Subject: [PATCH 23/36] address coderabbitai comments --- assets/i18n/en.json | 6 +----- server/api_page_drafts.go | 10 ++++++---- server/app/page_draft.go | 13 ++++++++++++- server/app/page_draft_test.go | 2 +- server/store/page_move.go | 5 +++++ 5 files changed, 25 insertions(+), 11 deletions(-) diff --git a/assets/i18n/en.json b/assets/i18n/en.json index 074ad13..4d19b6b 100644 --- a/assets/i18n/en.json +++ b/assets/i18n/en.json @@ -83,10 +83,6 @@ "id": "app.page.duplicate.invalid_user_id.app_error", "translation": "Invalid user ID." }, - { - "id": "app.page.duplicate.max_depth_exceeded.app_error", - "translation": "Pages cannot be nested more than {{.MaxDepth}} levels deep." - }, { "id": "app.page.duplicate.not_found.app_error", "translation": "The page could not be found." @@ -313,7 +309,7 @@ }, { "id": "app.page_draft.update.draft_changed.app_error", - "translation": "A concurrent autosave has updated the draft; please republish." + "translation": "A concurrent autosave updated the draft. Reload it and try saving again." }, { "id": "app.page_draft.update.edit_conflict.app_error", diff --git a/server/api_page_drafts.go b/server/api_page_drafts.go index 29ca6a6..c896620 100644 --- a/server/api_page_drafts.go +++ b/server/api_page_drafts.go @@ -13,10 +13,12 @@ import ( ) const ( - // maxDraftBodyBytes caps the autosave PUT, which carries the full document body. It tracks the - // model's enforced body limit plus headroom for the title/props/file-ids and JSON envelope, so - // an over-limit body is rejected at the transport layer rather than after decoding. - maxDraftBodyBytes = model.PageBodyMaxBytes + (64 << 10) // 64 KiB headroom + // maxDraftBodyBytes caps the autosave PUT, which carries the full document body. Body is JSON + // nested inside the request JSON, so its transport form can grow far beyond its decoded size once + // quotes, backslashes, and control characters are escaped (worst case ~6x for all-control-char + // input). Size the transport cap for that worst case plus headroom for the title/props/file-ids + // and JSON envelope; the decoded body stays capped at model.PageBodyMaxBytes during normalization. + maxDraftBodyBytes = 6*model.PageBodyMaxBytes + (64 << 10) // 64 KiB headroom ) // handleUpdatePageDraft handles PUT /api/v1/spaces/{space_id}/pages/{page_id}/draft diff --git a/server/app/page_draft.go b/server/app/page_draft.go index e9a35f9..34290bf 100644 --- a/server/app/page_draft.go +++ b/server/app/page_draft.go @@ -75,7 +75,9 @@ func (s *Service) UpdatePageDraft(draft *model.Draft, parentID *string, fileIDs return nil, mmmodel.NewAppError("UpdatePageDraft", "model.draft.is_valid.file_ids.app_error", nil, "", http.StatusBadRequest) } for _, fileID := range *fileIDs { - if fileID != "" && !mmmodel.IsValidId(fileID) { + // Reject "" too: an empty slice clears the list, but an empty entry is malformed and + // would otherwise be merged verbatim into FileIds. + if !mmmodel.IsValidId(fileID) { return nil, mmmodel.NewAppError("UpdatePageDraft", "app.page_draft.update.invalid_file_id.app_error", nil, "", http.StatusBadRequest) } } @@ -204,6 +206,10 @@ func (s *Service) CreateSpaceDraft(userID, spaceID, title, pageParentID string) var invErr *store.ErrInvalidInput if errors.As(err, &invErr) { switch invErr.Reason { + case store.ReasonParentNotLive: + // The parent validated in validateDraftParent can disappear before UpsertDraft's + // locked check; surface the create-specific key rather than storeAppError's page.* key. + return nil, mmmodel.NewAppError("CreateSpaceDraft", "app.page_draft.create.invalid_parent.app_error", nil, "", http.StatusBadRequest).Wrap(err) case store.ReasonDraftCycle: return nil, mmmodel.NewAppError("CreateSpaceDraft", "app.page_draft.create.parent_cycle.app_error", nil, "", http.StatusBadRequest).Wrap(err) case store.ReasonDraftTooDeep: @@ -294,6 +300,11 @@ func (s *Service) DeletePageDraft(userID, spaceID, pageID, channelID string) *mm // named in the request before deleting — otherwise a member of another space could delete a // draft here by passing this space's id with a foreign page id. if _, appErr := s.GetPageDraft(userID, spaceID, pageID); appErr != nil { + // GetPageDraft returns its own get.* not-found key; translate it to the delete operation's + // key so a discard reports a delete-appropriate message. + if appErr.StatusCode == http.StatusNotFound { + return mmmodel.NewAppError("DeletePageDraft", "app.page_draft.delete.not_found.app_error", nil, "", http.StatusNotFound).Wrap(appErr) + } return appErr } diff --git a/server/app/page_draft_test.go b/server/app/page_draft_test.go index b6b3af5..10b4f1c 100644 --- a/server/app/page_draft_test.go +++ b/server/app/page_draft_test.go @@ -361,7 +361,7 @@ func TestPublishRejectsForeignSpacePage(t *testing.T) { for _, force := range []bool{false, true} { _, _, appErr = h.svc.PublishPageDraft(userB, spaceB.Id, pageID, force) require.NotNil(t, appErr, "cross-space publish (force=%v) must fail", force) - require.Contains(t, []int{http.StatusNotFound, http.StatusConflict}, appErr.StatusCode, + require.Equal(t, http.StatusNotFound, appErr.StatusCode, "cross-space publish (force=%v) must be rejected", force) } diff --git a/server/store/page_move.go b/server/store/page_move.go index 0acf6f2..512c504 100644 --- a/server/store/page_move.go +++ b/server/store/page_move.go @@ -257,6 +257,11 @@ func (s *Store) MovePageToSpace(pageID, sourceSpaceID, targetSpaceID, moverUserI if targetSpaceID == "" { return nil, "", &ErrInvalidInput{Entity: "Page", Field: "TargetSpaceId", Value: targetSpaceID} } + // moverUserID keys the draft re-home vs. delete classification in rewriteSubtreeSpace; an empty + // or malformed value would match no owner and delete every affected draft as "another user's". + if !mmmodel.IsValidId(moverUserID) { + return nil, "", &ErrInvalidInput{Entity: "Page", Field: "MoverUserId", Value: moverUserID} + } tx, err := s.db.Beginx() if err != nil { From a6ef25b06ef35c04cd428779850ad5f6db25ba98 Mon Sep 17 00:00:00 2001 From: "Catalin I. Tomai" Date: Mon, 20 Jul 2026 16:16:49 +0200 Subject: [PATCH 24/36] Harden page-draft presence, content, and move handling; add tests --- assets/i18n/en.json | 12 +- go.mod | 10 +- go.sum | 4 +- server/api.go | 2 +- server/api_handler_test.go | 2 +- server/api_page_drafts.go | 15 +-- server/api_page_drafts_test.go | 28 +++-- server/api_page_presence.go | 6 +- server/app/page.go | 8 ++ server/app/page_content_test.go | 13 ++ server/app/page_draft.go | 63 +++++----- server/app/page_draft_test.go | 49 ++++++-- server/app/page_presence.go | 84 ++++++++++--- server/app/page_presence_test.go | 42 +++++++ server/app/service.go | 12 +- server/app/ws_events_test.go | 6 +- server/model/draft.go | 3 +- server/model/draft_test.go | 111 +++++++++++++++++ server/model/page_content.go | 9 +- server/model/page_content_test.go | 114 +++++++++++++++++ server/store/draft_store.go | 107 ++++++++-------- server/store/page_move.go | 73 +++++------ server/store/page_move_test.go | 195 +++++++++++++++++++++++++++++- server/store/page_store.go | 9 +- server/store/store_test.go | 181 ++++++++++++++++++--------- 25 files changed, 912 insertions(+), 246 deletions(-) create mode 100644 server/app/page_presence_test.go diff --git a/assets/i18n/en.json b/assets/i18n/en.json index 4d19b6b..a224308 100644 --- a/assets/i18n/en.json +++ b/assets/i18n/en.json @@ -43,10 +43,6 @@ "id": "app.page.create.invalid_user_id.app_error", "translation": "Invalid user ID." }, - { - "id": "app.page.create.parent_different_channel.app_error", - "translation": "The parent page belongs to a different space." - }, { "id": "app.page.create.space_not_found.app_error", "translation": "The space could not be found." @@ -235,10 +231,6 @@ "id": "app.page_draft.create.parent_too_deep.app_error", "translation": "The draft hierarchy is too deep to add another level." }, - { - "id": "app.page_draft.create.quota_exceeded.app_error", - "translation": "Draft limit reached. Publish or discard an existing draft before creating a new one." - }, { "id": "app.page_draft.delete.invalid_page_id.app_error", "translation": "Invalid page ID." @@ -307,6 +299,10 @@ "id": "app.page_draft.publish.parent_unpublished.app_error", "translation": "The parent page must be published before this page can be published." }, + { + "id": "app.page_draft.quota_exceeded.app_error", + "translation": "Draft limit reached. Publish or discard an existing draft to free up space." + }, { "id": "app.page_draft.update.draft_changed.app_error", "translation": "A concurrent autosave updated the draft. Reload it and try saving again." diff --git a/go.mod b/go.mod index 4dca5b5..0b5ee62 100644 --- a/go.mod +++ b/go.mod @@ -2,19 +2,19 @@ module github.com/mattermost/mattermost-plugin-docs go 1.26.4 -// Dev-only pins: server/public is pinned to the head of the paired core branch that adds the -// Space backing-channel type — ChannelTypeSpace ("S"), pluginapi Channel.GetChannelOfType, and -// pluginapi Channel.Restore are not yet in a released server/public. +// Dev-only pins: server/public is pinned to the master commit that merged the Space +// backing-channel type — ChannelTypeSpace ("S"), pluginapi Channel.GetChannelOfType, and +// pluginapi Channel.Restore — which is not yet in a released server/public. // server/v8 is the test harness only (storetest helpers); it does not contribute any runtime // symbols and is pinned independently to an older commit. The two modules live in the same // monorepo but are versioned independently, so their pseudo-version timestamps will always // differ; what matters is that server/public has the APIs this plugin calls. -// Bump both to a release tag once the core space-channel changes merge and ship. +// Bump both to a release tag once the core space-channel changes ship. require ( github.com/gorilla/mux v1.8.1 github.com/jmoiron/sqlx v1.4.0 github.com/lib/pq v1.12.3 - github.com/mattermost/mattermost/server/public v0.4.4-0.20260713131524-80d5b7966dc5 + github.com/mattermost/mattermost/server/public v0.4.4-0.20260716203457-5f7f967a7dbf github.com/mattermost/mattermost/server/v8 v8.0.0-20260623200446-ba033eae4704 github.com/mattermost/morph v1.1.0 github.com/mattermost/squirrel v0.5.0 diff --git a/go.sum b/go.sum index 3a0cdde..5b7f020 100644 --- a/go.sum +++ b/go.sum @@ -140,8 +140,8 @@ github.com/mattermost/ldap v0.0.0-20231116144001-0f480c025956 h1:Y1Tu/swM31pVwwb github.com/mattermost/ldap v0.0.0-20231116144001-0f480c025956/go.mod h1:SRl30Lb7/QoYyohYeVBuqYvvmXSZJxZgiV3Zf6VbxjI= github.com/mattermost/logr/v2 v2.0.22 h1:npFkXlkAWR9J8payh8ftPcCZvLbHSI125mAM5/r/lP4= github.com/mattermost/logr/v2 v2.0.22/go.mod h1:0sUKpO+XNMZApeumaid7PYaUZPBIydfuWZ0dqixXo+s= -github.com/mattermost/mattermost/server/public v0.4.4-0.20260713131524-80d5b7966dc5 h1:L/o7nmoq4fv/wfcaen7tr85GxZ/V05hpzfpI3/TuOto= -github.com/mattermost/mattermost/server/public v0.4.4-0.20260713131524-80d5b7966dc5/go.mod h1:rHFKFSnyNmyk1qieL00Fv+YuLb093Q7y8VTwBu/43ic= +github.com/mattermost/mattermost/server/public v0.4.4-0.20260716203457-5f7f967a7dbf h1:Elv/2xLhHNauIlgA3EajAQYRzpF2OfCF77UTCmQdhX4= +github.com/mattermost/mattermost/server/public v0.4.4-0.20260716203457-5f7f967a7dbf/go.mod h1:rHFKFSnyNmyk1qieL00Fv+YuLb093Q7y8VTwBu/43ic= github.com/mattermost/mattermost/server/v8 v8.0.0-20260623200446-ba033eae4704 h1:vEw+u4m6mUrjHgpvDLXfCkuYcpUk8Q5CorgPRsqjW74= github.com/mattermost/mattermost/server/v8 v8.0.0-20260623200446-ba033eae4704/go.mod h1:RBaqawSPsPB76XA4hfrIRFvLS0QWF1qJWHr2+w34+6s= github.com/mattermost/morph v1.1.0 h1:Q9vrJbeM3s2jfweGheq12EFIzdNp9a/6IovcbvOQ6Cw= diff --git a/server/api.go b/server/api.go index bbd3f28..17b84a1 100644 --- a/server/api.go +++ b/server/api.go @@ -63,7 +63,7 @@ func (p *Plugin) initRouter() *mux.Router { // Draft CRUD + publish. api.HandleFunc("/spaces/{space_id}/drafts", p.handleCreateSpaceDraft).Methods(http.MethodPost) api.HandleFunc("/spaces/{space_id}/drafts", p.handleGetPageDraftsForSpace).Methods(http.MethodGet) - api.HandleFunc("/spaces/{space_id}/pages/{page_id}/draft", p.handleUpdatePageDraft).Methods(http.MethodPut) + api.HandleFunc("/spaces/{space_id}/pages/{page_id}/draft", p.handleUpdatePageDraft).Methods(http.MethodPatch) api.HandleFunc("/spaces/{space_id}/pages/{page_id}/draft", p.handleGetPageDraft).Methods(http.MethodGet) api.HandleFunc("/spaces/{space_id}/pages/{page_id}/draft", p.handleDeletePageDraft).Methods(http.MethodDelete) api.HandleFunc("/spaces/{space_id}/pages/{page_id}/draft/publish", p.handlePublishPageDraft).Methods(http.MethodPost) diff --git a/server/api_handler_test.go b/server/api_handler_test.go index a0a8b6f..2aa7add 100644 --- a/server/api_handler_test.go +++ b/server/api_handler_test.go @@ -1098,7 +1098,7 @@ func TestHandler_SpaceMembershipRequired(t *testing.T) { // Draft + presence handlers. {http.MethodPost, "/api/v1/spaces/" + space.Id + "/drafts", map[string]any{"title": "D"}}, {http.MethodGet, "/api/v1/spaces/" + space.Id + "/drafts", nil}, - {http.MethodPut, "/api/v1/spaces/" + space.Id + "/pages/" + page.Id + "/draft", map[string]any{"title": "D"}}, + {http.MethodPatch, "/api/v1/spaces/" + space.Id + "/pages/" + page.Id + "/draft", map[string]any{"title": "D"}}, {http.MethodGet, "/api/v1/spaces/" + space.Id + "/pages/" + page.Id + "/draft", nil}, {http.MethodDelete, "/api/v1/spaces/" + space.Id + "/pages/" + page.Id + "/draft", nil}, {http.MethodPost, "/api/v1/spaces/" + space.Id + "/pages/" + page.Id + "/draft/publish", nil}, diff --git a/server/api_page_drafts.go b/server/api_page_drafts.go index c896620..c7b927d 100644 --- a/server/api_page_drafts.go +++ b/server/api_page_drafts.go @@ -13,7 +13,7 @@ import ( ) const ( - // maxDraftBodyBytes caps the autosave PUT, which carries the full document body. Body is JSON + // maxDraftBodyBytes caps the autosave request, which carries the full document body. Body is JSON // nested inside the request JSON, so its transport form can grow far beyond its decoded size once // quotes, backslashes, and control characters are escaped (worst case ~6x for all-control-char // input). Size the transport cap for that worst case plus headroom for the title/props/file-ids @@ -21,12 +21,13 @@ const ( maxDraftBodyBytes = 6*model.PageBodyMaxBytes + (64 << 10) // 64 KiB headroom ) -// handleUpdatePageDraft handles PUT /api/v1/spaces/{space_id}/pages/{page_id}/draft -// It upserts the calling user's draft for the page. This PUT merges rather than replaces: an -// omitted field means "unchanged", not "cleared" (autosave heartbeats carry only the fields the -// editor touched). parent_id: null omits the field; parent_id: "" explicitly clears it. +// handleUpdatePageDraft handles PATCH /api/v1/spaces/{space_id}/pages/{page_id}/draft +// It upserts the calling user's draft for the page. PATCH is deliberate: the request merges rather +// than replaces, so an omitted field means "unchanged", not "cleared" (autosave heartbeats carry +// only the fields the editor touched). parent_id: null omits the field; parent_id: "" explicitly +// clears it. // -// For existing published pages, the first PUT creates the draft (open an edit session). The client +// For existing published pages, the first request creates the draft (open an edit session). The client // must include original_page_edit_at in props — the page's EditAt at the moment the user opened // it — so a subsequent publish can detect a concurrent edit. // @@ -150,7 +151,7 @@ func (p *Plugin) handleCreateSpaceDraft(w http.ResponseWriter, r *http.Request) // // The optimistic-lock baseline for an edit-publish is not a field on this request: it travels with // the draft, captured once (as the original_page_edit_at prop) when editing began and carried by the -// autosave PUTs. This differs from the per-request base_edit_at on handleUpdatePage (and +// autosave requests. This differs from the per-request base_edit_at on handleUpdatePage (and // expected_update_at on handleMovePage) because a publish ships whatever the draft already holds // rather than re-supplying a freshly-read baseline. func (p *Plugin) handlePublishPageDraft(w http.ResponseWriter, r *http.Request) { diff --git a/server/api_page_drafts_test.go b/server/api_page_drafts_test.go index 0df6c30..a08aef8 100644 --- a/server/api_page_drafts_test.go +++ b/server/api_page_drafts_test.go @@ -36,7 +36,7 @@ func TestHandler_DraftLifecycle(t *testing.T) { pageID := draft.PageId require.True(t, mmmodel.IsValidId(pageID)) - rec = h.do(t, http.MethodPut, base+"/pages/"+pageID+"/draft", userID, map[string]any{ + rec = h.do(t, http.MethodPatch, base+"/pages/"+pageID+"/draft", userID, map[string]any{ "title": "New Doc", "body": `{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"hello"}]}]}`, }) @@ -124,7 +124,7 @@ func TestHandler_UpdatePageDraftRequiresExistingDraft(t *testing.T) { userID := mmmodel.NewId() // No draft has been created for this page id — the PUT must be rejected. - rec := h.do(t, http.MethodPut, "/api/v1/spaces/"+space.Id+"/pages/"+mmmodel.NewId()+"/draft", userID, map[string]any{ + rec := h.do(t, http.MethodPatch, "/api/v1/spaces/"+space.Id+"/pages/"+mmmodel.NewId()+"/draft", userID, map[string]any{ "title": "ghost", }) require.Equal(t, http.StatusNotFound, rec.Code) @@ -156,7 +156,7 @@ func TestHandler_UpdatePageDraftClearsParentWhenParentIdIsEmptyString(t *testing require.Equal(t, parentDraft.PageId, childDraft.ParentId) // PUT with parent_id omitted must preserve the existing parent. - rec = h.do(t, http.MethodPut, base+"/pages/"+childDraft.PageId+"/draft", userID, map[string]any{ + rec = h.do(t, http.MethodPatch, base+"/pages/"+childDraft.PageId+"/draft", userID, map[string]any{ "title": "Child updated", }) require.Equal(t, http.StatusOK, rec.Code) @@ -165,7 +165,7 @@ func TestHandler_UpdatePageDraftClearsParentWhenParentIdIsEmptyString(t *testing require.Equal(t, parentDraft.PageId, saved.ParentId, "omitting parent_id must preserve the existing parent") // PUT with parent_id: "" must clear the parent to root. - rec = h.do(t, http.MethodPut, base+"/pages/"+childDraft.PageId+"/draft", userID, map[string]any{ + rec = h.do(t, http.MethodPatch, base+"/pages/"+childDraft.PageId+"/draft", userID, map[string]any{ "parent_id": "", }) require.Equal(t, http.StatusOK, rec.Code) @@ -198,7 +198,7 @@ func TestHandler_UpdatePageDraftCreatesForExistingPage(t *testing.T) { require.Equal(t, pageID, page.Id) // Step 2: open an edit session — first PUT creates the draft for an existing page. - rec = h.do(t, http.MethodPut, base+"/pages/"+pageID+"/draft", userID, map[string]any{ + rec = h.do(t, http.MethodPatch, base+"/pages/"+pageID+"/draft", userID, map[string]any{ "title": "Original", "props": mmmodel.StringInterface{model.DraftPropsOriginalPageEditAt: page.EditAt}, }) @@ -210,7 +210,7 @@ func TestHandler_UpdatePageDraftCreatesForExistingPage(t *testing.T) { require.True(t, hasBaseline, "draft must carry the original_page_edit_at baseline so publish can detect conflicts") // Step 3: autosave new content. - rec = h.do(t, http.MethodPut, base+"/pages/"+pageID+"/draft", userID, map[string]any{ + rec = h.do(t, http.MethodPatch, base+"/pages/"+pageID+"/draft", userID, map[string]any{ "title": "Edited", "body": `{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"updated"}]}]}`, }) @@ -253,13 +253,13 @@ func TestHandler_PublishConflict409(t *testing.T) { editAt := page.EditAt // User A and user B both open edit sessions against the same baseline. - rec = h.do(t, http.MethodPut, base+"/pages/"+pageID+"/draft", userA, map[string]any{ + rec = h.do(t, http.MethodPatch, base+"/pages/"+pageID+"/draft", userA, map[string]any{ "title": "Edit by A", "props": mmmodel.StringInterface{model.DraftPropsOriginalPageEditAt: editAt}, }) require.Equal(t, http.StatusOK, rec.Code) - rec = h.do(t, http.MethodPut, base+"/pages/"+pageID+"/draft", userB, map[string]any{ + rec = h.do(t, http.MethodPatch, base+"/pages/"+pageID+"/draft", userB, map[string]any{ "title": "Edit by B", "props": mmmodel.StringInterface{model.DraftPropsOriginalPageEditAt: editAt}, }) @@ -338,18 +338,24 @@ func TestHandler_ActiveEditorsResponseBody(t *testing.T) { var page model.Page require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &page)) - // No edit draft open yet — active_editors must be an empty list, not null. + // No edit draft open yet — active_editors must be an empty list, not null. The response also + // carries as_of and active_timeout_ms, mirroring the page_presence_updated WS payload so a client + // resyncing over REST can reason about snapshot staleness the same way. rec = h.do(t, http.MethodGet, base+"/pages/"+pageID+"/active-editors", userID, nil) require.Equal(t, http.StatusOK, rec.Code) var resp struct { - ActiveEditors []string `json:"active_editors"` + ActiveEditors []string `json:"active_editors"` + AsOf int64 `json:"as_of"` + ActiveTimeoutMs int64 `json:"active_timeout_ms"` } require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) require.NotNil(t, resp.ActiveEditors) require.Empty(t, resp.ActiveEditors) + require.Positive(t, resp.AsOf, "response must carry the snapshot timestamp") + require.Equal(t, int64(5*60*1000), resp.ActiveTimeoutMs, "response must carry the active-editor window") // Open an edit draft — the user must now appear as an active editor. - rec = h.do(t, http.MethodPut, base+"/pages/"+pageID+"/draft", userID, map[string]any{ + rec = h.do(t, http.MethodPatch, base+"/pages/"+pageID+"/draft", userID, map[string]any{ "title": "Presence Test", "props": mmmodel.StringInterface{model.DraftPropsOriginalPageEditAt: page.EditAt}, }) diff --git a/server/api_page_presence.go b/server/api_page_presence.go index b88b734..89823d1 100644 --- a/server/api_page_presence.go +++ b/server/api_page_presence.go @@ -22,13 +22,11 @@ func (p *Plugin) handleGetPageActiveEditors(w http.ResponseWriter, r *http.Reque return } - editors, appErr := p.service.GetPageActiveEditors(pageID, spaceID) + snapshot, appErr := p.service.GetPageActiveEditors(pageID, spaceID) if appErr != nil { p.writeAppError(w, appErr) return } - writeJSON(w, http.StatusOK, struct { - ActiveEditors []string `json:"active_editors"` - }{ActiveEditors: editors}) + writeJSON(w, http.StatusOK, snapshot) } diff --git a/server/app/page.go b/server/app/page.go index 2d50c5d..ec2fb5b 100644 --- a/server/app/page.go +++ b/server/app/page.go @@ -18,6 +18,14 @@ import ( // store.MaxPageHierarchyDepth (50) is a separate, larger bound used by descendant/ancestor reads. const MaxPageDepth = 10 +// A draft chain publishes into a page chain of the same depth, so the store's draft-cycle bound +// must equal MaxPageDepth. Either subtraction below is a negative constant if they drift, which +// overflows uint and fails to compile — catching drift in both directions. +const ( + _ = uint(MaxPageDepth - store.DraftCycleCheckMaxDepth) + _ = uint(store.DraftCycleCheckMaxDepth - MaxPageDepth) +) + // CreatePage creates a new page in spaceID. ChannelId is derived from the space, not supplied by the caller. // The page ID is always server-generated; callers must not supply one. func (s *Service) CreatePage(spaceID, parentID, title, body, userID string) (*model.Page, *mmmodel.AppError) { diff --git a/server/app/page_content_test.go b/server/app/page_content_test.go index f8b8eb2..7544604 100644 --- a/server/app/page_content_test.go +++ b/server/app/page_content_test.go @@ -4,6 +4,7 @@ package app import ( + "net/http" "strings" "testing" @@ -68,6 +69,18 @@ func TestValidateAndNormalizeContent(t *testing.T) { }) } +// TestNormalizePageContentRejectsTooManyParagraphs verifies that a plain-text body exceeding +// maxPlainTextParagraphs newlines is rejected with 400, rather than producing an oversized TipTap +// document. +func TestNormalizePageContentRejectsTooManyParagraphs(t *testing.T) { + body := strings.Repeat("x\n", maxPlainTextParagraphs+1) + + _, _, appErr := normalizePageContent("test", body) + require.NotNil(t, appErr) + require.Equal(t, http.StatusBadRequest, appErr.StatusCode) + require.Contains(t, appErr.Error(), "too many paragraphs") +} + func TestNormalizePatchContent(t *testing.T) { t.Run("nil patch is a no-op", func(t *testing.T) { require.Nil(t, normalizePatchContent("test", nil)) diff --git a/server/app/page_draft.go b/server/app/page_draft.go index 34290bf..f06f9cb 100644 --- a/server/app/page_draft.go +++ b/server/app/page_draft.go @@ -45,6 +45,7 @@ func (s *Service) UpdatePageDraft(draft *model.Draft, parentID *string, fileIDs if parentID != nil && *parentID != "" && !mmmodel.IsValidId(*parentID) { return nil, mmmodel.NewAppError("UpdatePageDraft", "app.page_draft.update.invalid_parent_id.app_error", nil, "", http.StatusBadRequest) } + s.log.Debug("Updating page draft", "space_id", draft.SpaceId, "page_id", draft.PageId, "user_id", draft.UserId) if draft.Title != "" { title, titleErr := validateTitle("UpdatePageDraft", draft.Title, model.PageTitleMaxRunes) if titleErr != nil { @@ -98,7 +99,7 @@ func (s *Service) UpdatePageDraft(draft *model.Draft, parentID *string, fileIDs case store.IsErrNotFound(existingDraftErr): // No draft for this user+page. Allow only if the page ID is "known" — either another // user already reserved it via CreateSpaceDraft, or it is a published page in this space. - // This prevents PUT /spaces/X/pages//draft from ghost-drafting a non-existent page. + // This prevents PATCH /spaces/X/pages//draft from ghost-drafting a non-existent page. var existsErr error pageIsLive, existsErr = s.store.PageExistsInSpace(draft.PageId, draft.SpaceId) if existsErr != nil { @@ -110,7 +111,7 @@ func (s *Service) UpdatePageDraft(draft *model.Draft, parentID *string, fileIDs } } - saved, err := s.store.UpsertDraft(draft, parentID, fileIDs) + saved, savedPageWasLive, err := s.store.UpsertDraft(draft, parentID, fileIDs) if err != nil { switch store.ConflictReason(err) { case store.ReasonConcurrentEdit: @@ -126,14 +127,10 @@ func (s *Service) UpdatePageDraft(draft *model.Draft, parentID *string, fileIDs // New-page drafts (no published page row yet) must not broadcast presence to the space channel: // that would expose the reserved page ID and the author's identity to all space members before // the page exists. Send the event only to the author so their own UI can track the session. + // UpsertDraft already determined liveness under its page-row lock, so reuse its result here; + // only the no-existing-draft branch above resolved it independently. if !pageIsLiveResolved { - var pageExistsErr error - pageIsLive, pageExistsErr = s.store.PageExistsInSpace(saved.PageId, saved.SpaceId) - if pageExistsErr != nil { - s.log.Warn("UpdatePageDraft: failed to check page existence; skipping broadcast", - "page_id", saved.PageId, "err", pageExistsErr) - return saved, nil - } + pageIsLive = savedPageWasLive } if !pageIsLive { s.publishSelfPresence(saved) @@ -145,6 +142,7 @@ func (s *Service) UpdatePageDraft(draft *model.Draft, parentID *string, fileIDs // other's first broadcast. presenceKey := presenceBroadcastKey(saved.PageId, saved.UserId) now := mmmodel.GetMillis() + s.sweepPresenceBroadcastLast(now) existing, loaded := s.presenceBroadcastLast.LoadOrStore(presenceKey, now) if loaded { lastTime, ok := existing.(int64) @@ -171,6 +169,7 @@ func (s *Service) CreateSpaceDraft(userID, spaceID, title, pageParentID string) if !mmmodel.IsValidId(spaceID) { return nil, mmmodel.NewAppError("CreateSpaceDraft", "app.page_draft.create.invalid_space_id.app_error", nil, "", http.StatusBadRequest) } + s.log.Debug("Creating space draft", "space_id", spaceID, "parent_id", pageParentID, "user_id", userID) title, titleErr := validateTitle("CreateSpaceDraft", title, model.PageTitleMaxRunes) if titleErr != nil { @@ -197,8 +196,9 @@ func (s *Service) CreateSpaceDraft(userID, spaceID, title, pageParentID string) // Use UpsertDraft directly: the page row does not exist yet (new-page draft), so // UpdatePageDraft's guard — which rejects drafts for non-existent pages on the autosave - // path — would incorrectly block this call. - saved, err := s.store.UpsertDraft(draft, parentPtr, nil) + // path — would incorrectly block this call. The page is never live here, so the liveness + // flag is discarded. + saved, _, err := s.store.UpsertDraft(draft, parentPtr, nil) if err != nil { // Translate hierarchy errors with create-specific keys so the client receives an // appropriate message. invalidInputAppError maps these to update.* keys, which don't @@ -295,6 +295,7 @@ func (s *Service) DeletePageDraft(userID, spaceID, pageID, channelID string) *mm if !mmmodel.IsValidId(pageID) { return mmmodel.NewAppError("DeletePageDraft", "app.page_draft.delete.invalid_page_id.app_error", nil, "", http.StatusBadRequest) } + s.log.Debug("Deleting page draft", "space_id", spaceID, "page_id", pageID, "user_id", userID) // A draft is keyed by (UserId, PageId) without SpaceId, so confirm it belongs to the space // named in the request before deleting — otherwise a member of another space could delete a @@ -314,7 +315,7 @@ func (s *Service) DeletePageDraft(userID, spaceID, pageID, channelID string) *mm // a discarded draft can briefly reappear. It is per-user and cleared by discarding again; fully // preventing it would need a soft-delete tombstone, which is not warranted for this window. // - pageWasLive, delErr := s.store.DeleteDraftReparenting(userID, pageID) + pageWasLive, delErr := s.store.DeleteDraftReparenting(userID, spaceID, pageID) if delErr != nil { // A concurrent publish/delete may have removed the draft between the check above and here; // treat that benign race as a 404, matching the not-found path of the initial check, rather @@ -331,8 +332,7 @@ func (s *Service) DeletePageDraft(userID, spaceID, pageID, channelID string) *mm if !pageWasLive { return nil } - s.presenceBroadcastLast.Delete(presenceBroadcastKey(pageID, userID)) - s.broadcastPagePresence(pageID, spaceID, channelID) + s.clearThrottleAndBroadcastPagePresence(pageID, userID, spaceID, channelID) return nil } @@ -364,6 +364,7 @@ func (s *Service) GetPageDraftsForSpace(userID, spaceID string, page, perPage in // - wasCreated=true → a new page was inserted by this call (handler should return 201) // - wasCreated=false → an existing page was updated, or a concurrent create was adopted (return 200) func (s *Service) PublishPageDraft(userID, spaceID, pageID string, force bool) (*model.Page, bool, *mmmodel.AppError) { + s.log.Debug("Publishing page draft", "space_id", spaceID, "page_id", pageID, "user_id", userID, "force", force) // 1. Fetch draft (idempotency guard: 404 = draft already published or discarded). draft, appErr := s.GetPageDraft(userID, spaceID, pageID) if appErr != nil { @@ -397,17 +398,16 @@ func (s *Service) PublishPageDraft(userID, spaceID, pageID string, force bool) ( // draft-only or non-live parent returns 409. The edit path never reparents, so a stale ParentId // carried on the draft must not block a content-only edit-publish. if isNewPage && draft.ParentId != "" { - parentPage, parentErr := s.GetPage(draft.ParentId) + // Existence-only probe, space-scoped: collapse "not a live page" and "lives in another space" + // into one error so the response can't be used to probe page ids in spaces the caller cannot + // read (matches validateDraftParent / validateParentExists). + parentExists, parentErr := s.store.PageExistsInSpace(draft.ParentId, spaceID) if parentErr != nil { - if parentErr.StatusCode == http.StatusNotFound { - return nil, false, mmmodel.NewAppError("PublishPageDraft", "app.page_draft.publish.parent_unpublished.app_error", - nil, "", http.StatusConflict).Wrap(parentErr) - } - return nil, false, parentErr + return nil, false, storeAppError("PublishPageDraft", parentErr) } - if parentPage.SpaceId != spaceID { - return nil, false, mmmodel.NewAppError("PublishPageDraft", "app.page.create.parent_different_channel.app_error", - nil, "", http.StatusBadRequest) + if !parentExists { + return nil, false, mmmodel.NewAppError("PublishPageDraft", "app.page_draft.publish.parent_unpublished.app_error", + nil, "", http.StatusConflict) } } @@ -444,10 +444,10 @@ func (s *Service) PublishPageDraft(userID, spaceID, pageID string, force bool) ( return nil, false, mmmodel.NewAppError("PublishPageDraft", "app.page_draft.publish.baseline_required.app_error", nil, "", http.StatusBadRequest) } - // Carry only the fields the draft actually set; leave the rest empty. The store applies these - // against the row it locks FOR UPDATE and preserves its current value for any empty field, so - // an omitted field is never sourced from the pre-lock `existing` snapshot — otherwise a - // force-publish could revert a concurrent edit to a field this draft never touched. + // Carry only the fields the draft actually set; leave the rest empty. The store preserves the + // live page's current value for any empty field, so an omitted field is never sourced from the + // pre-lock `existing` snapshot — otherwise a force-publish could revert a concurrent edit to a + // field this draft never touched. // An empty draft body means "unset" (a cleared document is EmptyTipTapJSON, not ""), so an // empty body leaves the live page's content intact rather than wiping it. pageForWrite = &model.Page{ @@ -485,8 +485,7 @@ func (s *Service) PublishPageDraft(userID, spaceID, pageID string, force bool) ( return nil, false, mmmodel.NewAppError("PublishPageDraft", "app.page_draft.publish.draft_changed.app_error", nil, "", http.StatusConflict) } - s.presenceBroadcastLast.Delete(presenceBroadcastKey(pageID, userID)) - s.broadcastPagePresence(pageID, spaceID, existing.ChannelId) + s.clearThrottleAndBroadcastPagePresence(pageID, userID, spaceID, existing.ChannelId) return existing, false, nil } } @@ -530,8 +529,7 @@ func (s *Service) PublishPageDraft(userID, spaceID, pageID string, force bool) ( } // The draft is consumed; clear the rate-limit entry and broadcast presence so // the active-editors indicator drops this user, matching the non-conflict path. - s.presenceBroadcastLast.Delete(presenceBroadcastKey(pageID, userID)) - s.broadcastPagePresence(pageID, spaceID, raced.ChannelId) + s.clearThrottleAndBroadcastPagePresence(pageID, userID, spaceID, raced.ChannelId) return raced, false, nil } if rErr != nil { @@ -570,8 +568,7 @@ func (s *Service) PublishPageDraft(userID, spaceID, pageID string, force bool) ( // The publish deleted the draft inside PublishDraft (bypassing the app-level DeletePageDraft // that normally broadcasts presence), so broadcast presence now so the active-editors indicator // clears on other clients. Delete the rate-limit entry first so the broadcast is not suppressed. - s.presenceBroadcastLast.Delete(presenceBroadcastKey(pageID, userID)) - s.broadcastPagePresence(pageID, spaceID, page.ChannelId) + s.clearThrottleAndBroadcastPagePresence(pageID, userID, spaceID, page.ChannelId) return page, isNewPage, nil } diff --git a/server/app/page_draft_test.go b/server/app/page_draft_test.go index 10b4f1c..47ae913 100644 --- a/server/app/page_draft_test.go +++ b/server/app/page_draft_test.go @@ -23,7 +23,7 @@ func docWith(text string) string { } // publishNewPage creates a new-page draft, autosaves the given body, and publishes it, returning -// the live page. It asserts the reserved draft id is preserved through publish (plan §5). +// the live page. It asserts the reserved draft id is preserved through publish. func publishNewPage(t *testing.T, h *testHarness, spaceID, userID, title, bodyText string) *model.Page { t.Helper() draft, appErr := h.svc.CreateSpaceDraft(userID, spaceID, title, "") @@ -80,6 +80,36 @@ func TestPublishEmptyDraftBodyDoesNotWipePage(t *testing.T) { require.Contains(t, republished.Body, "ORIGINAL", "publishing a title-only edit must preserve the page body") } +// TestPublishNoOpDraftDiscardsAndReturnsExistingPage verifies that publishing a draft that carries +// no content change — only the optimistic-lock baseline prop, no Title, no Body — is treated as a +// discard rather than a no-op page write: the draft is deleted, the existing page comes back +// unchanged, and wasCreated is false. +func TestPublishNoOpDraftDiscardsAndReturnsExistingPage(t *testing.T) { + h := openTestService(t) + space := mustCreateSpace(t, h.store, mmmodel.NewId()) + userID := mmmodel.NewId() + + page := publishNewPage(t, h, space.Id, userID, "Doc", "original") + + // Start an edit session whose only autosave carries the baseline prop — no Title, no Body. + _, appErr := h.svc.UpdatePageDraft(&model.Draft{ + UserId: userID, SpaceId: space.Id, PageId: page.Id, + Props: mmmodel.StringInterface{model.DraftPropsOriginalPageEditAt: float64(page.EditAt)}, + }, nil, nil, "") + require.Nil(t, appErr) + + result, wasCreated, appErr := h.svc.PublishPageDraft(userID, space.Id, page.Id, false) + require.Nil(t, appErr) + require.False(t, wasCreated, "a no-content publish must not report a creation") + require.Equal(t, page.Title, result.Title, "the page's title must be unchanged") + require.Contains(t, result.Body, "original", "the page's body must be unchanged") + + // The draft was consumed: it was converted into a discard rather than left in place. + _, appErr = h.svc.GetPageDraft(userID, space.Id, page.Id) + require.NotNil(t, appErr) + require.Equal(t, http.StatusNotFound, appErr.StatusCode) +} + func TestPublishRejectsMissingBaselineOnEdit(t *testing.T) { h := openTestService(t) space := mustCreateSpace(t, h.store, mmmodel.NewId()) @@ -188,9 +218,9 @@ func TestGetPageActiveEditorsRejectsWrongSpace(t *testing.T) { require.Equal(t, http.StatusNotFound, appErr.StatusCode) // Through its own space it resolves (empty set — no active drafts). - editors, appErr := h.svc.GetPageActiveEditors(page.Id, spaceA.Id) + snapshot, appErr := h.svc.GetPageActiveEditors(page.Id, spaceA.Id) require.Nil(t, appErr) - require.Empty(t, editors) + require.Empty(t, snapshot.ActiveEditors) } func TestActiveEditorsSurfacesHeartbeat(t *testing.T) { @@ -207,9 +237,11 @@ func TestActiveEditorsSurfacesHeartbeat(t *testing.T) { }, nil, nil, "") require.Nil(t, appErr) - editors, appErr := h.svc.GetPageActiveEditors(page.Id, space.Id) + snapshot, appErr := h.svc.GetPageActiveEditors(page.Id, space.Id) require.Nil(t, appErr) - require.Contains(t, editors, userID) + require.Contains(t, snapshot.ActiveEditors, userID) + require.Positive(t, snapshot.AsOf) + require.Equal(t, int64(5*60*1000), snapshot.ActiveTimeoutMs) } func TestPublishSetsLastModifiedBy(t *testing.T) { @@ -718,10 +750,13 @@ func TestPublishNewPageRejectsCrossSpaceParent(t *testing.T) { ) require.NoError(t, err) - // Publishing must reject: the parent lives in a different space than the draft's space. + // Publishing must reject: the parent lives in a different space than the draft's space. The guard + // collapses "cross-space" and "not a live page" into a single parent_unpublished 409 so the + // response can't be used to probe page ids in spaces the caller cannot read. _, _, appErr := h.svc.PublishPageDraft(userID, spaceB.Id, pageID, false) require.NotNil(t, appErr) - require.Equal(t, http.StatusBadRequest, appErr.StatusCode, "cross-space parent must be rejected: %v", appErr) + require.Equal(t, http.StatusConflict, appErr.StatusCode, "cross-space parent must be rejected: %v", appErr) + require.Equal(t, "app.page_draft.publish.parent_unpublished.app_error", appErr.Id) } func TestGetPageActiveEditorsRejectsInvalidPageID(t *testing.T) { diff --git a/server/app/page_presence.go b/server/app/page_presence.go index 61b4bd1..cb4d542 100644 --- a/server/app/page_presence.go +++ b/server/app/page_presence.go @@ -21,10 +21,36 @@ const activeEditorTimeoutMs int64 = 5 * 60 * 1000 // for the same page. Delete and publish paths always broadcast regardless of this interval. const presenceBroadcastMinIntervalMs int64 = 30 * 1000 +// presenceBroadcastSweepIntervalMs is the minimum time between sweeps of the broadcast rate-limit +// map. Sweeping is opportunistic — it runs on an autosave that finds the interval elapsed — so this +// bounds how often an autosave pays for a full scan of the map. +const presenceBroadcastSweepIntervalMs int64 = 5 * 60 * 1000 + func activeEditorSince() int64 { return mmmodel.GetMillis() - activeEditorTimeoutMs } +// sweepPresenceBroadcastLast drops rate-limit entries older than the active-editor window. An entry +// that old suppresses nothing — the next autosave is past presenceBroadcastMinIntervalMs and +// broadcasts either way — so dropping it preserves behavior while bounding the map for sessions +// abandoned without a discard or publish. CompareAndDelete leaves concurrently refreshed entries be. +func (s *Service) sweepPresenceBroadcastLast(now int64) { + last := s.presenceSweepLast.Load() + if now-last < presenceBroadcastSweepIntervalMs { + return + } + if !s.presenceSweepLast.CompareAndSwap(last, now) { + return + } + + s.presenceBroadcastLast.Range(func(key, value any) bool { + if ts, ok := value.(int64); ok && now-ts >= activeEditorTimeoutMs { + s.presenceBroadcastLast.CompareAndDelete(key, value) + } + return true + }) +} + // getActiveEditors returns the user IDs currently editing pageID in spaceID — those with a draft // updated within the active-editor window. The bool is true on success and false on a store // failure; callers must skip the broadcast on failure to avoid publishing a spurious empty snapshot @@ -44,17 +70,21 @@ func (s *Service) getActiveEditors(pageID, spaceID string) ([]string, bool) { // not yet published (no channel to broadcast to), so only the author's own UI learns of the session. func (s *Service) publishSelfPresence(draft *model.Draft) { s.publishToUser(wsEventPagePresenceUpdated, map[string]any{ - "page_id": draft.PageId, - "space_id": draft.SpaceId, - "active_editors": []string{draft.UserId}, - "as_of": mmmodel.GetMillis(), + "page_id": draft.PageId, + "space_id": draft.SpaceId, + "active_editors": []string{draft.UserId}, + "as_of": mmmodel.GetMillis(), + "active_timeout_ms": activeEditorTimeoutMs, }, draft.UserId) } // broadcastPagePresence fans a page_presence_updated event out to the space audience, carrying the -// current active-editor set and the time it was taken so a client can discard an out-of-order -// snapshot delivered from another cluster node. channelID is the space's backing channel. Best-effort: -// failures are swallowed. +// current active-editor set, the time it was taken (so a client can discard an out-of-order snapshot +// delivered from another cluster node), and active_timeout_ms. Broadcasts are only triggered by user +// actions (autosave, discard, publish), so a client that receives no newer snapshot cannot tell a +// still-active editor from one whose session ended abnormally; active_timeout_ms lets it expire the +// snapshot's editors on its own once as_of + active_timeout_ms has passed. channelID is the space's +// backing channel. Best-effort: failures are swallowed. func (s *Service) broadcastPagePresence(pageID, spaceID, channelID string) { if s.client == nil { return @@ -67,18 +97,36 @@ func (s *Service) broadcastPagePresence(pageID, spaceID, channelID string) { return } s.publishToChannels(wsEventPagePresenceUpdated, map[string]any{ - "page_id": pageID, - "space_id": spaceID, - "active_editors": editors, - "as_of": asOf, + "page_id": pageID, + "space_id": spaceID, + "active_editors": editors, + "as_of": asOf, + "active_timeout_ms": activeEditorTimeoutMs, }, channelID) } -// GetPageActiveEditors returns the user IDs currently active on the given page in the given space, +// clearThrottleAndBroadcastPagePresence clears the rate-limit entry for (pageID, userID) so the +// following broadcast is not suppressed, then broadcasts channel-wide. Used whenever a draft session +// ends (discard, publish, race-loss cleanup) and the active-editors indicator must drop this user. +func (s *Service) clearThrottleAndBroadcastPagePresence(pageID, userID, spaceID, channelID string) { + s.presenceBroadcastLast.Delete(presenceBroadcastKey(pageID, userID)) + s.broadcastPagePresence(pageID, spaceID, channelID) +} + +// PageActiveEditors is the editor-presence snapshot returned by the REST active-editors endpoint. Its +// fields mirror the page_presence_updated WebSocket payload (active_editors, as_of, active_timeout_ms) +// so a client sees the same presence contract whether it resyncs over REST or receives a live event. +type PageActiveEditors struct { + ActiveEditors []string `json:"active_editors"` + AsOf int64 `json:"as_of"` + ActiveTimeoutMs int64 `json:"active_timeout_ms"` +} + +// GetPageActiveEditors returns the editor-presence snapshot for the given page in the given space, // after confirming the page exists in that space. Returns 404 if the page is unknown or belongs to // another space, and 500 on a store failure (unlike the best-effort getActiveEditors, this backs a // REST read that must not report "nobody editing" when the query actually failed). -func (s *Service) GetPageActiveEditors(pageID, spaceID string) ([]string, *mmmodel.AppError) { +func (s *Service) GetPageActiveEditors(pageID, spaceID string) (*PageActiveEditors, *mmmodel.AppError) { if !mmmodel.IsValidId(pageID) { return nil, mmmodel.NewAppError("GetPageActiveEditors", "app.page.presence.invalid_page_id.app_error", nil, "", http.StatusBadRequest) } @@ -92,9 +140,15 @@ func (s *Service) GetPageActiveEditors(pageID, spaceID string) ([]string, *mmmod if !exists { return nil, mmmodel.NewAppError("GetPageActiveEditors", "app.page.not_found.app_error", nil, "", http.StatusNotFound) } - editors, storeErr := s.store.GetPageActiveEditors(pageID, spaceID, activeEditorSince()) + // Stamp as_of before the query so it marks when the snapshot was taken, matching the WS event. + asOf := mmmodel.GetMillis() + editors, storeErr := s.store.GetPageActiveEditors(pageID, spaceID, asOf-activeEditorTimeoutMs) if storeErr != nil { return nil, storeAppError("GetPageActiveEditors", storeErr) } - return editors, nil + return &PageActiveEditors{ + ActiveEditors: editors, + AsOf: asOf, + ActiveTimeoutMs: activeEditorTimeoutMs, + }, nil } diff --git a/server/app/page_presence_test.go b/server/app/page_presence_test.go new file mode 100644 index 0000000..83ea64c --- /dev/null +++ b/server/app/page_presence_test.go @@ -0,0 +1,42 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package app + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +// TestSweepPresenceBroadcastLastEvictsStaleEntriesOncePerWindow verifies sweepPresenceBroadcastLast's +// two behaviors: it evicts entries older than activeEditorTimeoutMs while leaving fresh entries in +// place, and it runs at most once per presenceBroadcastSweepIntervalMs — a second call with the same +// `now` must be a no-op even if a new stale entry was added in between. +func TestSweepPresenceBroadcastLastEvictsStaleEntriesOncePerWindow(t *testing.T) { + svc := &Service{} + + now := int64(1_000_000_000) + staleKey := "stale-page:stale-user" + freshKey := "fresh-page:fresh-user" + svc.presenceBroadcastLast.Store(staleKey, now-2*activeEditorTimeoutMs) + svc.presenceBroadcastLast.Store(freshKey, now) + + // Open the gate: make the sweep consider itself overdue. + svc.presenceSweepLast.Store(now - presenceBroadcastSweepIntervalMs) + + svc.sweepPresenceBroadcastLast(now) + + _, staleStillPresent := svc.presenceBroadcastLast.Load(staleKey) + require.False(t, staleStillPresent, "a stale entry must be evicted by the sweep") + _, freshStillPresent := svc.presenceBroadcastLast.Load(freshKey) + require.True(t, freshStillPresent, "a fresh entry must survive the sweep") + + // Re-seed a stale entry and call again with the same `now`: the gate must be closed (at most one + // sweep per activeEditorTimeoutMs), so this entry must NOT be evicted. + svc.presenceBroadcastLast.Store(staleKey, now-2*activeEditorTimeoutMs) + svc.sweepPresenceBroadcastLast(now) + + _, stillThere := svc.presenceBroadcastLast.Load(staleKey) + require.True(t, stillThere, "a second call within the sweep interval must be a no-op") +} diff --git a/server/app/service.go b/server/app/service.go index 4e767ed..6ce0cae 100644 --- a/server/app/service.go +++ b/server/app/service.go @@ -11,6 +11,7 @@ import ( "net/http" "strings" "sync" + "sync/atomic" "unicode/utf8" mmmodel "github.com/mattermost/mattermost/server/public/model" @@ -42,7 +43,16 @@ type Service struct { // presenceBroadcastLast records the last autosave-triggered presence broadcast time (ms) per // pageID, used to rate-limit high-frequency autosave broadcasts. Delete and publish paths bypass // this and always broadcast. + // + // The map is per-process, so each node throttles independently: a user whose autosaves are + // spread across nodes can broadcast more often than the interval implies. That is acceptable — + // the payload is queried fresh from the shared DB on every broadcast, so the throttle only + // trades broadcast volume, never correctness. Entries are dropped on discard and publish, and + // swept by age via sweepPresenceBroadcastLast for sessions abandoned without either. presenceBroadcastLast sync.Map + + // presenceSweepLast is the last time presenceBroadcastLast was swept (ms). + presenceSweepLast atomic.Int64 } // New creates a Service wired to the given store, logger, and optional pluginapi client. @@ -136,7 +146,7 @@ func storeAppError(where string, err error) *mmmodel.AppError { case store.ReasonSubtreeMaxDepthExceeded: return mmmodel.NewAppError(where, "app.page.subtree_max_depth_exceeded.app_error", map[string]any{"MaxDepth": limitErr.Limit}, "", http.StatusBadRequest).Wrap(err) case store.ReasonDraftQuotaExceeded: - return mmmodel.NewAppError(where, "app.page_draft.create.quota_exceeded.app_error", nil, "", http.StatusTooManyRequests).Wrap(err) + return mmmodel.NewAppError(where, "app.page_draft.quota_exceeded.app_error", nil, "", http.StatusTooManyRequests).Wrap(err) } return mmmodel.NewAppError(where, "app.store.too_large.app_error", map[string]any{"Limit": limitErr.Limit}, "", http.StatusUnprocessableEntity).Wrap(err) default: diff --git a/server/app/ws_events_test.go b/server/app/ws_events_test.go index c27c057..66d8024 100644 --- a/server/app/ws_events_test.go +++ b/server/app/ws_events_test.go @@ -202,6 +202,7 @@ func TestServiceUpdatePageDraft_PublishesPresenceEvent(t *testing.T) { payload["page_id"] == page.Id && payload["space_id"] == space.Id && payload["as_of"] != nil && + payload["active_timeout_ms"] == int64(5*60*1000) && slices.Contains(editors, userID) }), &mmmodel.WebsocketBroadcast{ChannelId: channelID}) @@ -272,6 +273,7 @@ func TestServicePublishPageDraft_PublishesUpdatedEvent(t *testing.T) { payload["page_id"] == republished.Id && payload["space_id"] == space.Id && payload["as_of"] != nil && + payload["active_timeout_ms"] == int64(5*60*1000) && len(editors) == 0 }), &mmmodel.WebsocketBroadcast{ChannelId: channelID}) @@ -306,6 +308,7 @@ func TestServiceDeletePageDraft_PublishesPresenceEvent(t *testing.T) { payload["page_id"] == page.Id && payload["space_id"] == space.Id && payload["as_of"] != nil && + payload["active_timeout_ms"] == int64(5*60*1000) && len(editors) == 0 }), &mmmodel.WebsocketBroadcast{ChannelId: channelID}) @@ -337,7 +340,8 @@ func TestServiceUpdatePageDraft_NewPageDraftPublishesToUserOnly(t *testing.T) { // The broadcast must be user-scoped: only the author learns about their own unreleased page. mockAPI.AssertCalled(t, "PublishWebSocketEvent", "page_presence_updated", mock.MatchedBy(func(payload map[string]any) bool { - return payload["page_id"] == draft.PageId && payload["space_id"] == space.Id + return payload["page_id"] == draft.PageId && payload["space_id"] == space.Id && + payload["active_timeout_ms"] == int64(5*60*1000) }), &mmmodel.WebsocketBroadcast{UserId: userID}) diff --git a/server/model/draft.go b/server/model/draft.go index 2f6ecf4..8a77634 100644 --- a/server/model/draft.go +++ b/server/model/draft.go @@ -154,8 +154,7 @@ func (d *Draft) IsValid() *mmmodel.AppError { // GetProps returns Props, or an empty map if Props is nil. func (d *Draft) GetProps() mmmodel.StringInterface { - d.Props = ensureProps(d.Props) - return d.Props + return ensureProps(d.Props) } // SanitizeProps strips any props key not on the recognized allowlist. Call on every write path to diff --git a/server/model/draft_test.go b/server/model/draft_test.go index 7997fb3..647b119 100644 --- a/server/model/draft_test.go +++ b/server/model/draft_test.go @@ -164,3 +164,114 @@ func TestDraftGetPropsNilReturnsEmpty(t *testing.T) { require.NotNil(t, d.GetProps(), "GetProps must return an empty map, not nil") require.Empty(t, d.GetProps()) } + +func TestDraftSanitizeProps(t *testing.T) { + t.Run("keeps only the baseline key, unknown keys dropped", func(t *testing.T) { + d := &model.Draft{Props: mmmodel.StringInterface{ + model.DraftPropsOriginalPageEditAt: int64(12345), + "unknown_key": "value", + "another_unknown": 42, + }} + d.SanitizeProps() + require.Equal(t, mmmodel.StringInterface{model.DraftPropsOriginalPageEditAt: int64(12345)}, d.Props) + }) + + t.Run("nil Props becomes an empty map", func(t *testing.T) { + d := &model.Draft{Props: nil} + d.SanitizeProps() + require.NotNil(t, d.Props) + require.Empty(t, d.Props) + }) + + t.Run("only unknown keys becomes an empty map", func(t *testing.T) { + d := &model.Draft{Props: mmmodel.StringInterface{"unknown": "x"}} + d.SanitizeProps() + require.NotNil(t, d.Props) + require.Empty(t, d.Props) + }) + + t.Run("baseline key absent yields an empty map", func(t *testing.T) { + d := &model.Draft{Props: mmmodel.StringInterface{"foo": "bar"}} + d.SanitizeProps() + require.NotNil(t, d.Props) + require.Empty(t, d.Props) + }) +} + +func TestDraftEditBaseline(t *testing.T) { + t.Run("float64 non-zero converts to int64", func(t *testing.T) { + d := &model.Draft{Props: mmmodel.StringInterface{model.DraftPropsOriginalPageEditAt: float64(1610000000000)}} + v, ok := d.EditBaseline() + require.True(t, ok) + require.Equal(t, int64(1610000000000), v) + }) + + t.Run("float64 zero", func(t *testing.T) { + d := &model.Draft{Props: mmmodel.StringInterface{model.DraftPropsOriginalPageEditAt: float64(0)}} + v, ok := d.EditBaseline() + require.False(t, ok) + require.Equal(t, int64(0), v) + }) + + t.Run("int64 non-zero passes through", func(t *testing.T) { + d := &model.Draft{Props: mmmodel.StringInterface{model.DraftPropsOriginalPageEditAt: int64(42)}} + v, ok := d.EditBaseline() + require.True(t, ok) + require.Equal(t, int64(42), v) + }) + + t.Run("int64 zero", func(t *testing.T) { + d := &model.Draft{Props: mmmodel.StringInterface{model.DraftPropsOriginalPageEditAt: int64(0)}} + v, ok := d.EditBaseline() + require.False(t, ok) + require.Equal(t, int64(0), v) + }) + + t.Run("int non-zero converts to int64", func(t *testing.T) { + d := &model.Draft{Props: mmmodel.StringInterface{model.DraftPropsOriginalPageEditAt: int(7)}} + v, ok := d.EditBaseline() + require.True(t, ok) + require.Equal(t, int64(7), v) + }) + + t.Run("int zero", func(t *testing.T) { + d := &model.Draft{Props: mmmodel.StringInterface{model.DraftPropsOriginalPageEditAt: int(0)}} + v, ok := d.EditBaseline() + require.False(t, ok) + require.Equal(t, int64(0), v) + }) + + t.Run("missing key", func(t *testing.T) { + d := &model.Draft{Props: mmmodel.StringInterface{}} + v, ok := d.EditBaseline() + require.False(t, ok) + require.Equal(t, int64(0), v) + }) + + t.Run("wrong type string does not panic", func(t *testing.T) { + d := &model.Draft{Props: mmmodel.StringInterface{model.DraftPropsOriginalPageEditAt: "not-a-number"}} + require.NotPanics(t, func() { + v, ok := d.EditBaseline() + require.False(t, ok) + require.Equal(t, int64(0), v) + }) + }) + + t.Run("wrong type slice does not panic", func(t *testing.T) { + d := &model.Draft{Props: mmmodel.StringInterface{model.DraftPropsOriginalPageEditAt: []any{1, 2, 3}}} + require.NotPanics(t, func() { + v, ok := d.EditBaseline() + require.False(t, ok) + require.Equal(t, int64(0), v) + }) + }) + + t.Run("wrong type bool does not panic", func(t *testing.T) { + d := &model.Draft{Props: mmmodel.StringInterface{model.DraftPropsOriginalPageEditAt: true}} + require.NotPanics(t, func() { + v, ok := d.EditBaseline() + require.False(t, ok) + require.Equal(t, int64(0), v) + }) + }) +} diff --git a/server/model/page_content.go b/server/model/page_content.go index cbf8545..638fc3d 100644 --- a/server/model/page_content.go +++ b/server/model/page_content.go @@ -19,6 +19,10 @@ const ( EmptyTipTapJSON = `{"type":"doc","content":[]}` ) +// TipTapDocument is the parsed form of a TipTap editor document. Content is left as an untyped node +// tree because the TipTap schema is open (editor extensions add node/mark types). Instances must be +// produced by ParseTipTapDocument for the sanitization invariant to hold — a value built any other +// way has not passed sanitizeTipTapDocument and must not be stored or rendered. type TipTapDocument struct { Type string `json:"type"` Content []map[string]any `json:"content"` @@ -378,7 +382,10 @@ func sanitizeTipTapNode(node map[string]any, depth int) error { // Sanitize both the mark's nested attrs and any dangerous/URL keys placed directly on // the mark object (a non-standard shape a lenient renderer may read). stripDangerousKeys(markNode) - markType, _ := markNode["type"].(string) + markType, ok := markNode["type"].(string) + if !ok || markType == "" { + return errors.New("content mark must have a non-empty type field") + } if _, forbidden := forbiddenMarkTypes[strings.ToLower(markType)]; forbidden { return errors.Errorf("content mark type %q is not allowed", markType) } diff --git a/server/model/page_content_test.go b/server/model/page_content_test.go index dbc6259..a2917dc 100644 --- a/server/model/page_content_test.go +++ b/server/model/page_content_test.go @@ -128,6 +128,88 @@ func TestParseTipTapDocumentSanitizesURLs(t *testing.T) { } } +// TestParseTipTapDocumentRejectsForbiddenTypes pins the node/mark denylist — the strongest +// defense in the sanitizer — by asserting that a document carrying a forbidden type is rejected +// outright rather than stripped and accepted. +func TestParseTipTapDocumentRejectsForbiddenTypes(t *testing.T) { + // Every forbidden node type, plus a couple of allowed ones as a control. + nodeCases := []struct { + nodeType string + rejected bool + }{ + {"script", true}, + {"iframe", true}, + {"embed", true}, + {"object", true}, + {"noscript", true}, + {"template", true}, + {"style", true}, + {"link", true}, + {"svg", true}, + {"math", true}, + {"animate", true}, + {"animatetransform", true}, + {"foreignobject", true}, + {"maction", true}, + {"SCRIPT", true}, // denylist is case-insensitive + {"IFrame", true}, + {"paragraph", false}, // control: allowed + } + for _, tc := range nodeCases { + t.Run("node type "+tc.nodeType, func(t *testing.T) { + raw := map[string]any{ + "type": "doc", + "content": []any{map[string]any{"type": tc.nodeType}}, + } + b, err := json.Marshal(raw) + require.NoError(t, err) + _, err = model.ParseTipTapDocument(string(b)) + if tc.rejected { + require.Error(t, err, "forbidden node type %q must be rejected", tc.nodeType) + } else { + require.NoError(t, err, "allowed node type %q must parse", tc.nodeType) + } + }) + } + + // Forbidden mark types on an otherwise-valid text node. "link" is intentionally absent — it is a + // valid mark whose href is sanitized rather than blocked (see TestParseTipTapDocumentSanitizesURLs). + markCases := []struct { + markType string + rejected bool + }{ + {"script", true}, + {"iframe", true}, + {"style", true}, + {"svg", true}, + {"foreignobject", true}, + {"MAction", true}, // case-insensitive + {"link", false}, // control: allowed as a mark + {"bold", false}, // control: ordinary formatting mark + {"", true}, // a mark with an empty type is rejected, matching node-type strictness + } + for _, tc := range markCases { + t.Run("mark type "+tc.markType, func(t *testing.T) { + raw := map[string]any{ + "type": "doc", + "content": []any{map[string]any{ + "type": "text", + "text": "x", + "marks": []any{map[string]any{"type": tc.markType}}, + }}, + } + b, err := json.Marshal(raw) + require.NoError(t, err) + _, err = model.ParseTipTapDocument(string(b)) + if tc.rejected { + require.Error(t, err, "forbidden mark type %q must be rejected", tc.markType) + } else { + require.NoError(t, err, "allowed mark type %q must parse", tc.markType) + } + }) + } +} + func TestParseTipTapDocumentRejectsNullNodes(t *testing.T) { t.Run("null top-level content node", func(t *testing.T) { _, err := model.ParseTipTapDocument(`{"type":"doc","content":[null]}`) @@ -400,6 +482,38 @@ func TestParseTipTapDocumentStripsAdditionalDangerousAttrs(t *testing.T) { require.Equal(t, "safe", attrs["title"]) } +// buildTipTapContentJSON returns a TipTap document JSON string whose top-level content array holds +// n flat sibling paragraph nodes, built without any nesting so the node count equals n exactly. +func buildTipTapContentJSON(n int) string { + var b strings.Builder + b.WriteString(`{"type":"doc","content":[`) + for i := range n { + if i > 0 { + b.WriteByte(',') + } + b.WriteString(`{"type":"paragraph"}`) + } + b.WriteString(`]}`) + return b.String() +} + +func TestParseTipTapDocumentRejectsTooManyNodes(t *testing.T) { + // Mirrors the maxTipTapNodes guard in sanitizeTipTapDocument (server/model/page_content.go). + const nodeLimit = 50_000 + + t.Run("node count at the limit is accepted", func(t *testing.T) { + doc, err := model.ParseTipTapDocument(buildTipTapContentJSON(nodeLimit)) + require.NoError(t, err) + require.Len(t, doc.Content, nodeLimit) + }) + + t.Run("node count exceeding the limit is rejected", func(t *testing.T) { + _, err := model.ParseTipTapDocument(buildTipTapContentJSON(nodeLimit + 1)) + require.Error(t, err) + require.Contains(t, err.Error(), "exceeds the maximum of 50000 nodes") + }) +} + func TestBuildSearchText(t *testing.T) { t.Run("extracts and joins text", func(t *testing.T) { doc, err := model.ParseTipTapDocument(`{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"hello"},{"type":"text","text":"world"}]}]}`) diff --git a/server/store/draft_store.go b/server/store/draft_store.go index 993ea8a..f41905c 100644 --- a/server/store/draft_store.go +++ b/server/store/draft_store.go @@ -16,9 +16,11 @@ import ( "github.com/mattermost/mattermost-plugin-docs/server/model" ) -// draftCycleCheckMaxDepth bounds the parent-chain walk in checkNoDraftCycle. Must be at -// least as large as the page hierarchy depth cap enforced by the app layer. -const draftCycleCheckMaxDepth = 10 +// DraftCycleCheckMaxDepth bounds the parent-chain walk in checkNoDraftCycle and the nested-draft +// cascade in rewriteSubtreeSpace. It must equal the app layer's page-depth cap (app.MaxPageDepth), +// since a draft chain publishes into a page chain of the same depth; app asserts that equality at +// compile time so the two constants cannot drift. +const DraftCycleCheckMaxDepth = 10 // MaxDraftsPerUserPerSpace is the maximum number of draft rows a single user may hold in one // space. Enforced atomically inside UpsertDraft after the space lock, so it holds under @@ -168,7 +170,7 @@ SELECT AND NOT EXISTS (SELECT 1 FROM DOCS_Draft d2 WHERE d2.UserId = $2 AND d2.PageId = c.node) ORDER BY c.depth DESC LIMIT 1 ), '') AS live_ancestor -FROM chain`, draftCycleCheckMaxDepth, draftCycleCheckMaxDepth) +FROM chain`, DraftCycleCheckMaxDepth, DraftCycleCheckMaxDepth) var result struct { IsCycle bool `db:"is_cycle"` @@ -193,7 +195,7 @@ FROM chain`, draftCycleCheckMaxDepth, draftCycleCheckMaxDepth) return errors.Wrap(err, "cycle check: failed to read live ancestor depth") } // liveDepth counts the ancestor itself; +1 for the new leaf being validated. - if liveDepth+result.ChainDepth+1 > draftCycleCheckMaxDepth { + if liveDepth+result.ChainDepth+1 > DraftCycleCheckMaxDepth { return &ErrInvalidInput{Entity: "Draft", Field: "ParentId", Value: startParentID, Reason: ReasonDraftTooDeep} } } @@ -203,9 +205,11 @@ FROM chain`, draftCycleCheckMaxDepth, draftCycleCheckMaxDepth) // UpsertDraft creates or replaces the draft keyed by (UserId, PageId). It fills in defaults and // rejects an invalid draft itself, so the caller need not prepare or validate it beforehand. // -// An autosave may carry only the fields the editor changed, so on the update path an empty -// ParentId, Title, Body, or FileIds means "not sent", not "cleared", and the stored value is kept (a -// cleared document is EmptyTipTapJSON, not ""). Props are merged key-wise over the stored map. +// An autosave may carry only the fields the editor changed, so on the update path an empty Title or +// Body means "not sent", not "cleared", and the stored value is kept (a cleared document is +// EmptyTipTapJSON, not ""). ParentId and FileIds do not follow this empty-means-not-sent rule: they +// use explicit pointer intent (see the parentID/fileIDs paragraphs below), where a nil pointer — not +// an empty value — means "not sent". Props are merged key-wise over the stored map. // CreateAt keeps the existing row's original value. UpdateAt is bumped strictly monotonically // (GREATEST(incoming, stored+1)), so it is a collision-free version token: publish CAS-deletes the // draft on this value, and two saves within the same millisecond can no longer share it. All of this @@ -221,9 +225,9 @@ FROM chain`, draftCycleCheckMaxDepth, draftCycleCheckMaxDepth) // existing stored value", a pointer to an empty slice means "clear to no attachments", and a // pointer to a non-empty slice means "replace with these IDs". This mirrors parentID's // preserve/clear/set semantics. -func (s *Store) UpsertDraft(draft *model.Draft, parentID *string, fileIDs *mmmodel.StringArray) (_ *model.Draft, err error) { +func (s *Store) UpsertDraft(draft *model.Draft, parentID *string, fileIDs *mmmodel.StringArray) (_ *model.Draft, pageWasLive bool, err error) { if draft == nil { - return nil, &ErrInvalidInput{Entity: "Draft", Field: "draft", Value: nil} + return nil, false, &ErrInvalidInput{Entity: "Draft", Field: "draft", Value: nil} } // parentIDParam is the SQL-level parameter: nil → SQL NULL (preserve on conflict), non-nil @@ -241,17 +245,17 @@ func (s *Store) UpsertDraft(draft *model.Draft, parentID *string, fileIDs *mmmod draft.PreSave() if validErr := draft.IsValid(); validErr != nil { - return nil, &ErrInvalidInput{Entity: "Draft", Field: "IsValid", Value: validErr.Error(), Reason: validErr.Id} + return nil, false, &ErrInvalidInput{Entity: "Draft", Field: "IsValid", Value: validErr.Error(), Reason: validErr.Id} } tx, err := s.db.Beginx() if err != nil { - return nil, errors.Wrap(err, "begin_transaction") + return nil, false, errors.Wrap(err, "begin_transaction") } defer s.finalizeTransaction(tx, &err) if lockErr := s.lockLiveSpace(tx, draft.SpaceId); lockErr != nil { - return nil, lockErr + return nil, false, lockErr } // Quota check: enforce MaxDraftsPerUserPerSpace atomically inside the space lock so @@ -261,15 +265,15 @@ func (s *Store) UpsertDraft(draft *model.Draft, parentID *string, fileIDs *mmmod // on every autosave. isExisting, existErr := s.draftExistsTx(tx, draft.UserId, draft.PageId) if existErr != nil { - return nil, existErr + return nil, false, existErr } if !isExisting { count, countErr := s.countDraftsForUser(tx, draft.UserId, draft.SpaceId) if countErr != nil { - return nil, countErr + return nil, false, countErr } if count >= MaxDraftsPerUserPerSpace { - return nil, &ErrLimitExceeded{Resource: "Draft", Limit: MaxDraftsPerUserPerSpace, Reason: ReasonDraftQuotaExceeded} + return nil, false, &ErrLimitExceeded{Resource: "Draft", Limit: MaxDraftsPerUserPerSpace, Reason: ReasonDraftQuotaExceeded} } } @@ -287,9 +291,11 @@ func (s *Store) UpsertDraft(draft *model.Draft, parentID *string, fileIDs *mmmod switch pErr := s.getBuilder(tx, &page, pageLockQuery); { case pErr == nil: // A page row exists: the draft edits it, so it must be a live page in the - // draft's own space. + // draft's own space. Report that liveness so the caller can decide presence + // scoping. + pageWasLive = true if page.DeleteAt != 0 || page.OriginalId != "" || page.SpaceID != draft.SpaceId { - return nil, &ErrInvalidInput{Entity: "Draft", Field: "PageId", Value: draft.PageId} + return nil, false, &ErrInvalidInput{Entity: "Draft", Field: "PageId", Value: draft.PageId} } // Refuse to resurrect a draft a concurrent publish already consumed. When this autosave's // edit-session baseline is behind the page's current EditAt, the page advanced under it (a @@ -315,16 +321,16 @@ func (s *Store) UpsertDraft(draft *model.Draft, parentID *string, fileIDs *mmmod // and this point, making the earlier isExisting result stale. isExistingNow, reErr := s.draftExistsTx(tx, draft.UserId, draft.PageId) if reErr != nil { - return nil, reErr + return nil, false, reErr } if !isExistingNow { - return nil, &ErrConflict{Resource: "Draft page_id=" + draft.PageId, Reason: conflictReason} + return nil, false, &ErrConflict{Resource: "Draft page_id=" + draft.PageId, Reason: conflictReason} } } case errors.Is(pErr, sql.ErrNoRows): // New-page draft: no page row to lock. default: - return nil, errors.Wrap(pErr, "failed to lock page for draft upsert") + return nil, false, errors.Wrap(pErr, "failed to lock page for draft upsert") } // Re-validate the parent under the same transaction. A parent is valid when it is a live @@ -336,19 +342,19 @@ func (s *Store) UpsertDraft(draft *model.Draft, parentID *string, fileIDs *mmmod if parentID != nil && *parentID != "" { ok, parentErr := s.tryLockLiveParent(tx, *parentID, draft.SpaceId) if parentErr != nil { - return nil, parentErr + return nil, false, parentErr } if !ok { ok, parentErr = s.draftParentExistsTx(tx, draft.UserId, draft.SpaceId, *parentID) if parentErr != nil { - return nil, parentErr + return nil, false, parentErr } } if !ok { - return nil, &ErrInvalidInput{Entity: "Draft", Field: "ParentId", Value: *parentID, Reason: ReasonParentNotLive} + return nil, false, &ErrInvalidInput{Entity: "Draft", Field: "ParentId", Value: *parentID, Reason: ReasonParentNotLive} } if cycleErr := s.checkNoDraftCycle(tx, draft.UserId, draft.PageId, *parentID); cycleErr != nil { - return nil, cycleErr + return nil, false, cycleErr } } @@ -375,14 +381,14 @@ func (s *Store) UpsertDraft(draft *model.Draft, parentID *string, fileIDs *mmmod // statement above, so the returned row — not the caller's struct — is the saved draft. var stored model.Draft if cErr := s.getBuilder(tx, &stored, builder); cErr != nil { - return nil, errors.Wrap(cErr, "unable_to_upsert_draft") + return nil, false, errors.Wrap(cErr, "unable_to_upsert_draft") } if err = tx.Commit(); err != nil { - return nil, errors.Wrap(err, "commit_transaction") + return nil, false, errors.Wrap(err, "commit_transaction") } - return &stored, nil + return &stored, pageWasLive, nil } // GetDraft returns the draft keyed by (userID, pageID), or ErrNotFound. It is gated the same @@ -464,14 +470,21 @@ func (s *Store) DeleteDraftVersion(userID, pageID string, expectedUpdateAt int64 // DeleteDraftReparenting atomically reparents the calling user's child drafts (those with // ParentId = pageID) to the deleted draft's own parent, then deletes the draft keyed by -// (userID, pageID). This prevents child drafts from holding a dangling parent after a discard. -// Returns ErrNotFound when no draft exists for (userID, pageID). pageWasLive is true when the -// deleted draft was an edit draft (the page exists as a live page), false for new-page drafts; +// (userID, spaceID, pageID). This prevents child drafts from holding a dangling parent after a +// discard. The space row is locked first, before any draft row, matching the space→row lock order +// of every other structural draft mutation (UpsertDraft, PublishDraft): this serializes concurrent +// discards in the same chain on a single lock — so two of them cannot interleave into a dangling +// parent — and keeps the lock order consistent, avoiding an AB-BA deadlock against UpsertDraft. +// Returns ErrNotFound when no draft exists for (userID, spaceID, pageID). pageWasLive is true when +// the deleted draft was an edit draft (the page exists as a live page), false for new-page drafts; // callers use this to decide whether a presence broadcast is needed. -func (s *Store) DeleteDraftReparenting(userID, pageID string) (pageWasLive bool, err error) { +func (s *Store) DeleteDraftReparenting(userID, spaceID, pageID string) (pageWasLive bool, err error) { if userID == "" { return false, &ErrInvalidInput{Entity: "Draft", Field: "userId", Value: userID} } + if spaceID == "" { + return false, &ErrInvalidInput{Entity: "Draft", Field: "spaceId", Value: spaceID} + } if pageID == "" { return false, &ErrInvalidInput{Entity: "Draft", Field: "pageId", Value: pageID} } @@ -482,15 +495,18 @@ func (s *Store) DeleteDraftReparenting(userID, pageID string) (pageWasLive bool, } defer s.finalizeTransaction(tx, &err) - // Lock the draft row and read its parent and space for reparenting. + if lockErr := s.lockLiveSpace(tx, spaceID); lockErr != nil { + return false, lockErr + } + + // Lock the draft row and read its parent for reparenting. var draft struct { ParentId string - SpaceId string } lockQ := s.getQueryBuilder(). - Select("ParentId", "SpaceId"). + Select("ParentId"). From("DOCS_Draft"). - Where(sq.Eq{"UserId": userID, "PageId": pageID}). + Where(sq.Eq{"UserId": userID, "SpaceId": spaceID, "PageId": pageID}). Suffix("FOR UPDATE") switch lockErr := s.getBuilder(tx, &draft, lockQ); { case lockErr == nil: @@ -502,7 +518,7 @@ func (s *Store) DeleteDraftReparenting(userID, pageID string) (pageWasLive bool, // Reparent child drafts only when discarding a new-page draft. For edit drafts (page is live), // children's ParentId still points at a valid live page and must not be changed. - pageIsLive, liveErr := s.PageExistsInSpace(pageID, draft.SpaceId) + pageIsLive, liveErr := s.pageExistsInSpace(tx, pageID, spaceID) if liveErr != nil { return false, errors.Wrap(liveErr, "failed to check page liveness for reparenting") } @@ -512,7 +528,7 @@ func (s *Store) DeleteDraftReparenting(userID, pageID string) (pageWasLive bool, Update("DOCS_Draft"). Set("ParentId", draft.ParentId). Set("UpdateAt", monotonicBump("UpdateAt", now)). - Where(sq.Eq{"UserId": userID, "ParentId": pageID}) + Where(sq.Eq{"UserId": userID, "SpaceId": spaceID, "ParentId": pageID}) if _, rErr := s.execBuilder(tx, reparentQ); rErr != nil { return false, errors.Wrap(rErr, "failed to reparent child drafts") } @@ -520,7 +536,7 @@ func (s *Store) DeleteDraftReparenting(userID, pageID string) (pageWasLive bool, deleteQ := s.getQueryBuilder(). Delete("DOCS_Draft"). - Where(sq.Eq{"UserId": userID, "PageId": pageID}) + Where(sq.Eq{"UserId": userID, "SpaceId": spaceID, "PageId": pageID}) result, dErr := s.execBuilder(tx, deleteQ) if dErr != nil { return false, errors.Wrap(dErr, "unable_to_delete_draft") @@ -568,19 +584,6 @@ func (s *Store) GetDraftsForSpace(userID, spaceID string, offset, limit int) ([] return drafts, nil } -// CountDraftsForUser returns the number of draft rows the user owns in the given space. -// It counts all draft rows regardless of page liveness, so it reflects the true storage usage -// for quota enforcement. -func (s *Store) CountDraftsForUser(userID, spaceID string) (int, error) { - if userID == "" { - return 0, &ErrInvalidInput{Entity: "Draft", Field: "userId", Value: userID} - } - if spaceID == "" { - return 0, &ErrInvalidInput{Entity: "Draft", Field: "spaceId", Value: spaceID} - } - return s.countDraftsForUser(s.db, userID, spaceID) -} - // GetPageActiveEditors returns the user IDs who last saved a draft on the page in spaceID at or // after minActiveAt — users recently editing it. Presence is derived from the shared DOCS_Draft // table so it is consistent across all cluster nodes. diff --git a/server/store/page_move.go b/server/store/page_move.go index 512c504..1822240 100644 --- a/server/store/page_move.go +++ b/server/store/page_move.go @@ -257,8 +257,8 @@ func (s *Store) MovePageToSpace(pageID, sourceSpaceID, targetSpaceID, moverUserI if targetSpaceID == "" { return nil, "", &ErrInvalidInput{Entity: "Page", Field: "TargetSpaceId", Value: targetSpaceID} } - // moverUserID keys the draft re-home vs. delete classification in rewriteSubtreeSpace; an empty - // or malformed value would match no owner and delete every affected draft as "another user's". + // moverUserID scopes the draft-quota guard in rewriteSubtreeSpace; an empty or malformed value + // would match no owner and silently skip the target-space quota check. if !mmmodel.IsValidId(moverUserID) { return nil, "", &ErrInvalidInput{Entity: "Page", Field: "MoverUserId", Value: moverUserID} } @@ -432,15 +432,27 @@ func (s *Store) collectLiveSubtreeIDs(tx *sqlx.Tx, pageID string) ([]string, int // rewriteSubtreeSpace re-homes the given page IDs onto // targetSpaceID/targetChannelID, chunked, within tx. It rewrites SpaceId/ChannelId across // live DOCS_Page rows, their version snapshots (OriginalId IN ids), and DOCS_Draft rows. -// Only the mover's own drafts are re-homed; other users' drafts for the moved pages are -// deleted, since their page is now in a space they may not be able to access. +// Every user's drafts follow their page, not just the mover's: a draft is unpublished work its +// owner has not consented to lose, so a move must not destroy it as a side effect. An owner who +// is not a member of targetSpaceID simply cannot reach the draft — the space-membership check on +// every read gates it — but the content survives and returns if they gain access. func (s *Store) rewriteSubtreeSpace(tx *sqlx.Tx, ids []string, sourceSpaceID, targetSpaceID, targetChannelID, moverUserID string, now int64) error { // Quota guard: count the mover's drafts that will be re-homed into targetSpaceID (those in // source that cover moved pages or sit under them as new-page children) and ensure adding // them won't exceed MaxDraftsPerUserPerSpace in the target. This count is a lower bound for // the total re-homed set (the cascade loop below can pick up transitively nested new-page // drafts), so a failure here is correct, but a pass does not guarantee the cascade is safe; - // the cascade is bounded by draftCycleCheckMaxDepth and the count remains low in practice. + // the cascade is bounded by DraftCycleCheckMaxDepth and the count remains low in practice. + // + // Only the mover is quota-checked. Other users' re-homed drafts can push them past the cap in + // the target space, which is accepted: the cap is a soft storage bound, and re-homing moves + // existing rows rather than creating new ones. Failing a mover's move because an unrelated + // user sits at quota would be worse than briefly exceeding a soft cap. + // + // Unlike the re-home writes below, this count runs against the full id set in one query rather + // than in chunks: the predicate is an OR of two INs, so a draft whose PageId falls in one chunk + // and ParentId in another would be counted once per chunk. ids is bounded by + // MaxPageDescendantsLimit, well within Postgres's parameter limit, and this is a rare move op. var movedDraftCount int movedCountQ := s.getQueryBuilder(). Select("COUNT(*)"). @@ -486,49 +498,42 @@ func (s *Store) rewriteSubtreeSpace(tx *sqlx.Tx, ids []string, sourceSpaceID, ta return errors.Wrap(e, "failed to update subtree snapshots SpaceId/ChannelId") } - // Re-home the mover's own drafts. UpdateAt uses monotonicBump so it stays a valid CAS - // token even when the move and a concurrent autosave share a millisecond boundary. - moverDraftUpd := s.getQueryBuilder(). + // Re-home every owner's drafts for the moved pages, so a draft keeps matching its page's + // space and stays readable to an owner who is a member of the target. UpdateAt uses + // monotonicBump so it stays a valid CAS token even when the move and a concurrent autosave + // share a millisecond boundary. SpaceId = sourceSpaceID prevents cross-space ID collisions + // from re-homing unrelated drafts that happen to share a PageId or ParentId. LastActiveAt is + // reset so a re-homed draft is not reported as an active editor in the target space until its + // owner edits it there — otherwise a source-only owner's recent edit would surface to target + // members through GetPageActiveEditors for the remainder of the active-editor window. + draftUpd := s.getQueryBuilder(). Update("DOCS_Draft"). Set("SpaceId", targetSpaceID). Set("UpdateAt", monotonicBump("UpdateAt", now)). - Where(sq.Eq{"UserId": moverUserID}). - Where(sq.Or{sq.Eq{"PageId": chunk}, sq.Eq{"ParentId": chunk}}) - if _, e := s.execBuilder(tx, moverDraftUpd); e != nil { - return errors.Wrap(e, "failed to re-home mover drafts") - } - - // Delete other users' drafts for the moved pages: their SpaceId would no longer match - // the page's space, so the liveness filter rejects them anyway. Removing them explicitly - // prevents inaccessible rows from accumulating when the draft owner is not a member of - // the target space. SpaceId = sourceSpaceID prevents cross-space ID collisions from - // removing unrelated drafts that happen to share a PageId or ParentId. - otherDraftDel := s.getQueryBuilder(). - Delete("DOCS_Draft"). - Where(sq.NotEq{"UserId": moverUserID}). + Set("LastActiveAt", 0). Where(sq.Eq{"SpaceId": sourceSpaceID}). Where(sq.Or{sq.Eq{"PageId": chunk}, sq.Eq{"ParentId": chunk}}) - if _, e := s.execBuilder(tx, otherDraftDel); e != nil { - return errors.Wrap(e, "failed to delete other-user drafts for moved pages") + if _, e := s.execBuilder(tx, draftUpd); e != nil { + return errors.Wrap(e, "failed to re-home drafts for moved pages") } } - // Cascade the space re-home to the mover's transitively-nested new-page drafts (draft B - // whose ParentId is draft A's PageId, not a live page). The chunk loop above matched only - // drafts whose ParentId was a live moved page; draft B is caught here. Loop until stable, - // bounded by draftCycleCheckMaxDepth which caps the draft tree depth. + // Cascade the space re-home to transitively-nested new-page drafts (draft B whose ParentId is + // draft A's PageId, not a live page). The chunk loop above matched only drafts whose ParentId + // was a live moved page; draft B is caught here. Draft nesting is same-owner only, so the join + // pairs each draft with its parent on UserId rather than singling out the mover. Loop until + // stable, bounded by DraftCycleCheckMaxDepth which caps the draft tree depth. // Squirrel cannot express UPDATE … FROM …, so the statement is built directly. - for range draftCycleCheckMaxDepth { + for range DraftCycleCheckMaxDepth { result, e := s.exec(tx, ` UPDATE DOCS_Draft d - SET SpaceId = $1, UpdateAt = GREATEST(d.UpdateAt + 1, $2) + SET SpaceId = $1, UpdateAt = GREATEST(d.UpdateAt + 1, $2), LastActiveAt = 0 FROM DOCS_Draft parent - WHERE d.UserId = $3 - AND d.SpaceId = $4 - AND parent.UserId = $3 + WHERE d.SpaceId = $3 + AND parent.UserId = d.UserId AND parent.SpaceId = $1 AND parent.PageId = d.ParentId`, - targetSpaceID, now, moverUserID, sourceSpaceID) + targetSpaceID, now, sourceSpaceID) if e != nil { return errors.Wrap(e, "failed to cascade draft space to nested drafts") } diff --git a/server/store/page_move_test.go b/server/store/page_move_test.go index 8b54dcb..febd0c1 100644 --- a/server/store/page_move_test.go +++ b/server/store/page_move_test.go @@ -5,6 +5,7 @@ package store_test import ( "errors" + "sync" "testing" "github.com/stretchr/testify/require" @@ -160,10 +161,10 @@ func TestMovePageToSpace_Store(t *testing.T) { // (its own PageId has no page row yet). dEdit := newDraft(user, spaceA.Id, page.Id, "") dEdit.Props = mmmodel.StringInterface{model.DraftPropsOriginalPageEditAt: page.EditAt} - _, err = s.UpsertDraft(dEdit, nil, nil) + _, _, err = s.UpsertDraft(dEdit, nil, nil) require.NoError(t, err) parentPageID := page.Id - _, err = s.UpsertDraft(newDraft(user, spaceA.Id, mmmodel.NewId(), parentPageID), &parentPageID, nil) + _, _, err = s.UpsertDraft(newDraft(user, spaceA.Id, mmmodel.NewId(), parentPageID), &parentPageID, nil) require.NoError(t, err) sourceBefore, err := s.GetDraftsForSpace(user, spaceA.Id, 0, testDraftListLimit) @@ -186,6 +187,117 @@ func TestMovePageToSpace_Store(t *testing.T) { require.Empty(t, sourceAfter, "no draft remains stranded in the source space") }) + // A cross-space move re-homes every owner's draft for the moved pages, not just the mover's: + // a draft is unpublished work its owner never consented to lose, so the move preserves it and + // lets the space-membership read gate hide it from an owner who cannot reach the target space. + t.Run("re-homes another user's draft instead of deleting it", func(t *testing.T) { + s := openTestDB(t) + chA := mmmodel.NewId() + spaceA, err := s.CreateSpace(newSpace(chA)) + require.NoError(t, err) + mover := mmmodel.NewId() + other := mmmodel.NewId() + page, err := s.CreatePage(newPage(spaceA.Id, chA, mover, ""), testDefaultMaxDepth) + require.NoError(t, err) + + chB := mmmodel.NewId() + spaceB, err := s.CreateSpace(newSpace(chB)) + require.NoError(t, err) + + // A second user holds an in-progress edit draft on the same page. + otherDraft := newDraft(other, spaceA.Id, page.Id, "") + otherDraft.Props = mmmodel.StringInterface{model.DraftPropsOriginalPageEditAt: page.EditAt} + _, _, err = s.UpsertDraft(otherDraft, nil, nil) + require.NoError(t, err) + + _, _, err = s.MovePageToSpace(page.Id, spaceA.Id, spaceB.Id, mover, nil, page.UpdateAt, false, store.MaxPageHierarchyDepth) + require.NoError(t, err) + + moved, err := s.GetDraft(other, page.Id) + require.NoError(t, err, "the other user's draft survives the move") + require.Equal(t, spaceB.Id, moved.SpaceId, "and is re-homed to the target space, not deleted") + + targetDrafts, err := s.GetDraftsForSpace(other, spaceB.Id, 0, testDraftListLimit) + require.NoError(t, err) + require.Len(t, targetDrafts, 1, "the draft is readable in the target space") + + sourceDrafts, err := s.GetDraftsForSpace(other, spaceA.Id, 0, testDraftListLimit) + require.NoError(t, err) + require.Empty(t, sourceDrafts, "nothing is stranded in the source space") + }) + + // The mover's re-homed drafts are quota-checked against the target space; other users' are not. + t.Run("rejects the move when re-homing exceeds the mover's target-space quota", func(t *testing.T) { + s := openTestDB(t) + chA := mmmodel.NewId() + spaceA, err := s.CreateSpace(newSpace(chA)) + require.NoError(t, err) + mover := mmmodel.NewId() + page, err := s.CreatePage(newPage(spaceA.Id, chA, mover, ""), testDefaultMaxDepth) + require.NoError(t, err) + // One mover draft on the page to be moved. + editDraft := newDraft(mover, spaceA.Id, page.Id, "") + editDraft.Props = mmmodel.StringInterface{model.DraftPropsOriginalPageEditAt: page.EditAt} + _, _, err = s.UpsertDraft(editDraft, nil, nil) + require.NoError(t, err) + + chB := mmmodel.NewId() + spaceB, err := s.CreateSpace(newSpace(chB)) + require.NoError(t, err) + // Fill the mover's quota in the target space, so re-homing even one more trips the cap. + for range store.MaxDraftsPerUserPerSpace { + _, _, err = s.UpsertDraft(newDraft(mover, spaceB.Id, mmmodel.NewId(), ""), nil, nil) + require.NoError(t, err) + } + + _, _, err = s.MovePageToSpace(page.Id, spaceA.Id, spaceB.Id, mover, nil, page.UpdateAt, false, store.MaxPageHierarchyDepth) + require.Error(t, err) + require.True(t, store.IsErrLimitExceeded(err), "expected ErrLimitExceeded, got %T: %v", err, err) + + // The move rolled back: the page stays in the source space. + stillA, err := s.GetPage(page.Id, false) + require.NoError(t, err) + require.Equal(t, spaceA.Id, stillA.SpaceId, "a rejected move leaves the page in the source space") + }) + + // A cross-space move must not leak presence: rewriteSubtreeSpace resets a re-homed draft's + // LastActiveAt to 0, so an owner who was an active editor in the source space is not reported + // as one in the target space until they edit the draft there again. + t.Run("re-homed draft's presence does not leak into the target space", func(t *testing.T) { + s := openTestDB(t) + chA := mmmodel.NewId() + spaceA, err := s.CreateSpace(newSpace(chA)) + require.NoError(t, err) + user := mmmodel.NewId() + page, err := s.CreatePage(newPage(spaceA.Id, chA, user, ""), testDefaultMaxDepth) + require.NoError(t, err) + + chB := mmmodel.NewId() + spaceB, err := s.CreateSpace(newSpace(chB)) + require.NoError(t, err) + + editDraft := newDraft(user, spaceA.Id, page.Id, "") + editDraft.Props = mmmodel.StringInterface{model.DraftPropsOriginalPageEditAt: page.EditAt} + _, _, err = s.UpsertDraft(editDraft, nil, nil) + require.NoError(t, err) + + windowStart := mmmodel.GetMillis() - 60*1000 + editorsBefore, err := s.GetPageActiveEditors(page.Id, spaceA.Id, windowStart) + require.NoError(t, err) + require.Equal(t, []string{user}, editorsBefore, "the user is an active editor in the source space before the move") + + _, _, err = s.MovePageToSpace(page.Id, spaceA.Id, spaceB.Id, user, nil, page.UpdateAt, false, store.MaxPageHierarchyDepth) + require.NoError(t, err) + + movedDraft, err := s.GetDraft(user, page.Id) + require.NoError(t, err) + require.Equal(t, spaceB.Id, movedDraft.SpaceId, "the draft is re-homed to the target space") + + editorsAfter, err := s.GetPageActiveEditors(page.Id, spaceB.Id, windowStart) + require.NoError(t, err) + require.Empty(t, editorsAfter, "presence must not leak across the move: LastActiveAt was reset") + }) + t.Run("empty pageID returns invalid-input", func(t *testing.T) { s := openTestDB(t) _, _, err := s.MovePageToSpace("", mmmodel.NewId(), mmmodel.NewId(), mmmodel.NewId(), nil, 0, false, store.MaxPageHierarchyDepth) @@ -453,3 +565,82 @@ func TestPageMutations_ScopedToSpace(t *testing.T) { require.Equal(t, child.Id, descendants[0].Id) }) } + +// TestMovePageToSpace_ConcurrentAutosaveInvariants exercises the space-row FOR UPDATE +// serialization that guards a cross-space move against a simultaneous autosave on a draft of the +// moving page. MovePageToSpace and UpsertDraft both take lockLiveSpace on the source space +// (page_move.go:52, draft_store.go:257), so the two transactions serialize on the same row. The +// test does not assert which one wins — it asserts that whichever ordering the lock produces, the +// committed state is one of the legal outcomes: the page reaches the target space, and its draft is +// re-homed there exactly once, never duplicated across spaces, orphaned in the source, or left +// pointing at the wrong space. Both orderings satisfy these invariants, so the test never flakes; a +// broken lock (a lost move, a torn or duplicated draft) would fail an invariant on some interleaving. +func TestMovePageToSpace_ConcurrentAutosaveInvariants(t *testing.T) { + s := openTestDB(t) + + // Repeat so scheduling exercises both commit orderings (move-first and autosave-first) across runs. + const iterations = 12 + for i := range iterations { + chA := mmmodel.NewId() + spaceA, err := s.CreateSpace(newSpace(chA)) + require.NoError(t, err) + chB := mmmodel.NewId() + spaceB, err := s.CreateSpace(newSpace(chB)) + require.NoError(t, err) + + user := mmmodel.NewId() + page, err := s.CreatePage(newPage(spaceA.Id, chA, user, ""), testDefaultMaxDepth) + require.NoError(t, err) + + // An in-progress edit draft on the page, baselined at the page's current EditAt. + seed := newDraft(user, spaceA.Id, page.Id, "") + seed.Props = mmmodel.StringInterface{model.DraftPropsOriginalPageEditAt: page.EditAt} + _, _, err = s.UpsertDraft(seed, nil, nil) + require.NoError(t, err) + + // Race the move against an autosave on the same draft, released together. Both calls open + // their own transactions and contend on the source-space row. Errors are ignored here: an + // autosave that loses to the move is rejected (its page no longer lives in the source space), + // which is a legal outcome — the invariants below hold either way. Assertions run only on the + // main goroutine, after both finish. + start := make(chan struct{}) + var wg sync.WaitGroup + wg.Add(2) + go func() { + defer wg.Done() + <-start + _, _, _ = s.MovePageToSpace(page.Id, spaceA.Id, spaceB.Id, user, nil, page.UpdateAt, false, store.MaxPageHierarchyDepth) + }() + go func() { + defer wg.Done() + <-start + autosave := newDraft(user, spaceA.Id, page.Id, "") + autosave.Props = mmmodel.StringInterface{model.DraftPropsOriginalPageEditAt: page.EditAt} + _, _, _ = s.UpsertDraft(autosave, nil, nil) + }() + close(start) + wg.Wait() + + // The move always commits: the autosave never touches the page row's UpdateAt, so the move's + // optimistic-lock CAS holds regardless of ordering. + gotPage, err := s.GetPage(page.Id, false) + require.NoError(t, err) + require.Equal(t, spaceB.Id, gotPage.SpaceId, "iter %d: the move must commit the page to the target space", i) + + // The draft is re-homed to the target space exactly once — never duplicated and never orphaned + // in the source. + targetDrafts, err := s.GetDraftsForSpace(user, spaceB.Id, 0, testDraftListLimit) + require.NoError(t, err) + require.Len(t, targetDrafts, 1, "iter %d: the draft must be re-homed to the target exactly once", i) + require.Equal(t, page.Id, targetDrafts[0].PageId, "iter %d: the re-homed draft is the page's draft", i) + + sourceDrafts, err := s.GetDraftsForSpace(user, spaceA.Id, 0, testDraftListLimit) + require.NoError(t, err) + require.Empty(t, sourceDrafts, "iter %d: no draft may be left behind in the source space", i) + + // The re-homed draft's SpaceId matches its page's space, so it stays readable rather than orphaned. + moved, err := s.GetDraft(user, page.Id) + require.NoError(t, err) + require.Equal(t, spaceB.Id, moved.SpaceId, "iter %d: the draft SpaceId must match the moved page", i) + } +} diff --git a/server/store/page_store.go b/server/store/page_store.go index db3f419..7ad4459 100644 --- a/server/store/page_store.go +++ b/server/store/page_store.go @@ -559,6 +559,13 @@ func (s *Store) RestorePage(pageID, spaceID, userID string, maxDepth int) (_ *mo // PageExistsInSpace reports whether pageID is a live page in spaceID, without fetching the // row — callers that only need to 404 on a missing page avoid hauling the page body. func (s *Store) PageExistsInSpace(pageID, spaceID string) (bool, error) { + return s.pageExistsInSpace(s.db, pageID, spaceID) +} + +// pageExistsInSpace is PageExistsInSpace against an explicit executor, so callers inside a +// transaction observe that transaction's own view (e.g. its uncommitted writes and row locks) +// rather than reading through a separate pooled connection. +func (s *Store) pageExistsInSpace(e sqlx.ExtContext, pageID, spaceID string) (bool, error) { if pageID == "" { return false, &ErrInvalidInput{Entity: "Page", Field: "pageID", Value: pageID} } @@ -571,7 +578,7 @@ func (s *Store) PageExistsInSpace(pageID, spaceID string) (bool, error) { Where(sq.Eq{"Id": pageID, "SpaceId": spaceID}). Where(liveNonSnapshotFilter("")) var exists int - if err := s.getBuilder(s.db, &exists, query); err != nil { + if err := s.getBuilder(e, &exists, query); err != nil { if errors.Is(err, sql.ErrNoRows) { return false, nil } diff --git a/server/store/store_test.go b/server/store/store_test.go index dcc8003..fb13dcd 100644 --- a/server/store/store_test.go +++ b/server/store/store_test.go @@ -1021,9 +1021,9 @@ func TestDeletePage(t *testing.T) { d.Props = mmmodel.StringInterface{model.DraftPropsOriginalPageEditAt: created.EditAt} return d } - _, err = s.UpsertDraft(withBaseline(userID), nil, nil) + _, _, err = s.UpsertDraft(withBaseline(userID), nil, nil) require.NoError(t, err) - _, err = s.UpsertDraft(withBaseline(otherUserID), nil, nil) + _, _, err = s.UpsertDraft(withBaseline(otherUserID), nil, nil) require.NoError(t, err) require.NoError(t, deletePageErr(s, created.Id, created.SpaceId, userID)) @@ -1065,17 +1065,26 @@ func TestDeletePage(t *testing.T) { // New-page draft whose pending parent is the published page. draftPageID := mmmodel.NewId() parentID := parent.Id - saved, err := s.UpsertDraft(newDraft(userID, space.Id, draftPageID, ""), &parentID, nil) + _, _, err = s.UpsertDraft(newDraft(userID, space.Id, draftPageID, ""), &parentID, nil) + require.NoError(t, err) + + // Force the draft's stored UpdateAt ahead of wall clock, so a plain SET UpdateAt=now would move + // it backward. Only the GREATEST(now, UpdateAt+1) bump keeps it strictly advancing — a + // wall-clock-only reparent would fail the assertion below, which is what makes this a real guard. + future := mmmodel.GetMillis() + 60*60*1000 + _, err = s.ExecBuilderForTest(s.QueryBuilderForTest(). + Update("DOCS_Draft"). + Set("UpdateAt", future). + Where(sq.Eq{"UserId": userID, "PageId": draftPageID})) require.NoError(t, err) - before := saved.UpdateAt // Deleting the parent triggers reparentDraftsForPage on this draft. require.NoError(t, deletePageErr(s, parent.Id, space.Id, userID)) after, err := s.GetDraft(userID, draftPageID) require.NoError(t, err) - require.Greater(t, after.UpdateAt, before, - "reparent must strictly advance UpdateAt so a stale publish CAS cannot match the reparented token") + require.Equal(t, future+1, after.UpdateAt, + "reparent must strictly advance UpdateAt to stored+1 so a stale publish CAS cannot match the reparented token") }) t.Run("missing page returns not-found", func(t *testing.T) { @@ -1651,7 +1660,7 @@ func TestDraft(t *testing.T) { require.NoError(t, spaceErr) spaceID := space.Id - saved, err := s.UpsertDraft(newDraft(userID, spaceID, pageID, ""), nil, nil) + saved, _, err := s.UpsertDraft(newDraft(userID, spaceID, pageID, ""), nil, nil) require.NoError(t, err) require.NotZero(t, saved.CreateAt) @@ -1669,13 +1678,13 @@ func TestDraft(t *testing.T) { require.NoError(t, spaceErr) spaceID := space.Id - first, err := s.UpsertDraft(newDraft(userID, spaceID, pageID, ""), nil, nil) + first, _, err := s.UpsertDraft(newDraft(userID, spaceID, pageID, ""), nil, nil) require.NoError(t, err) second := newDraft(userID, spaceID, pageID, "") second.CreateAt = first.CreateAt second.Title = "Updated" - _, err = s.UpsertDraft(second, nil, nil) + _, _, err = s.UpsertDraft(second, nil, nil) require.NoError(t, err) got, err := s.GetDraft(userID, pageID) @@ -1694,7 +1703,7 @@ func TestDraft(t *testing.T) { full.Title = "Original title" full.Body = `{"type":"doc","content":[{"type":"paragraph"}]}` full.Props = mmmodel.StringInterface{model.DraftPropsOriginalPageEditAt: float64(1234)} - stored, err := s.UpsertDraft(full, nil, nil) + stored, _, err := s.UpsertDraft(full, nil, nil) require.NoError(t, err) // A body-only heartbeat: no title, no props. Neither may be wiped. @@ -1702,7 +1711,7 @@ func TestDraft(t *testing.T) { bodyOnly.Title = "" bodyOnly.Body = `{"type":"doc","content":[{"type":"paragraph"},{"type":"paragraph"}]}` bodyOnly.Props = nil - saved, err := s.UpsertDraft(bodyOnly, nil, nil) + saved, _, err := s.UpsertDraft(bodyOnly, nil, nil) require.NoError(t, err) require.Equal(t, "Original title", saved.Title, "an omitted title must not wipe the stored one") require.Equal(t, bodyOnly.Body, saved.Body, "the sent body must be written") @@ -1714,7 +1723,7 @@ func TestDraft(t *testing.T) { titleOnly := newDraft(userID, space.Id, pageID, "") titleOnly.Title = "Renamed" titleOnly.Body = "" - saved, err = s.UpsertDraft(titleOnly, nil, nil) + saved, _, err = s.UpsertDraft(titleOnly, nil, nil) require.NoError(t, err) require.Equal(t, "Renamed", saved.Title) require.Equal(t, bodyOnly.Body, saved.Body, "an omitted body must not wipe the stored one") @@ -1728,9 +1737,9 @@ func TestDraft(t *testing.T) { spaceID := space.Id userA, userB := mmmodel.NewId(), mmmodel.NewId() - _, err := s.UpsertDraft(newDraft(userA, spaceID, pageID, ""), nil, nil) + _, _, err := s.UpsertDraft(newDraft(userA, spaceID, pageID, ""), nil, nil) require.NoError(t, err) - _, err = s.UpsertDraft(newDraft(userB, spaceID, pageID, ""), nil, nil) + _, _, err = s.UpsertDraft(newDraft(userB, spaceID, pageID, ""), nil, nil) require.NoError(t, err) gotA, err := s.GetDraft(userA, pageID) @@ -1748,7 +1757,7 @@ func TestDraft(t *testing.T) { require.NoError(t, spaceErr) spaceID := space.Id - _, err := s.UpsertDraft(newDraft(userID, spaceID, pageID, ""), nil, nil) + _, _, err := s.UpsertDraft(newDraft(userID, spaceID, pageID, ""), nil, nil) require.NoError(t, err) require.NoError(t, s.DeleteDraft(userID, pageID)) @@ -1775,9 +1784,9 @@ func TestDraft(t *testing.T) { space, err := s.CreateSpace(newSpace(mmmodel.NewId())) require.NoError(t, err) - _, err = s.UpsertDraft(newDraft(userID, space.Id, mmmodel.NewId(), ""), nil, nil) + _, _, err = s.UpsertDraft(newDraft(userID, space.Id, mmmodel.NewId(), ""), nil, nil) require.NoError(t, err) - second, err := s.UpsertDraft(newDraft(userID, space.Id, mmmodel.NewId(), ""), nil, nil) + second, _, err := s.UpsertDraft(newDraft(userID, space.Id, mmmodel.NewId(), ""), nil, nil) require.NoError(t, err) drafts, err := s.GetDraftsForSpace(userID, space.Id, 0, testDraftListLimit) @@ -1792,7 +1801,7 @@ func TestDraft(t *testing.T) { space, err := s.CreateSpace(newSpace(mmmodel.NewId())) require.NoError(t, err) - draft, err := s.UpsertDraft(newDraft(userID, space.Id, mmmodel.NewId(), ""), nil, nil) + draft, _, err := s.UpsertDraft(newDraft(userID, space.Id, mmmodel.NewId(), ""), nil, nil) require.NoError(t, err) require.NoError(t, s.DeleteSpace(space.Id)) @@ -1853,7 +1862,7 @@ func TestDraft(t *testing.T) { pageInB, err := s.CreatePage(newPage(spaceB.Id, spaceB.ChannelId, userID, ""), testDefaultMaxDepth) require.NoError(t, err) - _, err = s.UpsertDraft(newDraft(userID, spaceA.Id, pageInB.Id, ""), nil, nil) + _, _, err = s.UpsertDraft(newDraft(userID, spaceA.Id, pageInB.Id, ""), nil, nil) require.True(t, store.IsErrInvalidInput(err), "expected invalid input for a cross-space page, got %v", err) }) @@ -1869,7 +1878,7 @@ func TestDraft(t *testing.T) { require.NoError(t, err) dLive := newDraft(userID, space.Id, live.Id, "") dLive.Props = mmmodel.StringInterface{model.DraftPropsOriginalPageEditAt: live.EditAt} - _, err = s.UpsertDraft(dLive, nil, nil) + _, _, err = s.UpsertDraft(dLive, nil, nil) require.NoError(t, err) // A draft whose page is soft-deleted is excluded. @@ -1877,7 +1886,7 @@ func TestDraft(t *testing.T) { require.NoError(t, err) dDeleted := newDraft(userID, space.Id, deleted.Id, "") dDeleted.Props = mmmodel.StringInterface{model.DraftPropsOriginalPageEditAt: deleted.EditAt} - _, err = s.UpsertDraft(dDeleted, nil, nil) + _, _, err = s.UpsertDraft(dDeleted, nil, nil) require.NoError(t, err) require.NoError(t, deletePageErr(s, deleted.Id, deleted.SpaceId, userID)) @@ -1903,7 +1912,7 @@ func TestDraft(t *testing.T) { require.NoError(t, err) dSnap := newDraft(userID, space.Id, snap.Id, "") dSnap.Props = mmmodel.StringInterface{model.DraftPropsOriginalPageEditAt: snap.EditAt} - _, err = s.UpsertDraft(dSnap, nil, nil) + _, _, err = s.UpsertDraft(dSnap, nil, nil) require.NoError(t, err) _, rawErr := s.ExecBuilderForTest(s.QueryBuilderForTest(). Update("DOCS_Page"). @@ -1929,7 +1938,7 @@ func TestDraft(t *testing.T) { require.NoError(t, deletePageErr(s, page.Id, page.SpaceId, userID)) // An autosave landing after the page was deleted must not recreate a draft for it. - _, err = s.UpsertDraft(newDraft(userID, space.Id, page.Id, ""), nil, nil) + _, _, err = s.UpsertDraft(newDraft(userID, space.Id, page.Id, ""), nil, nil) require.True(t, store.IsErrInvalidInput(err), "expected invalid input for a deleted page, got %v", err) }) @@ -1939,7 +1948,7 @@ func TestDraft(t *testing.T) { require.NoError(t, err) require.NoError(t, s.DeleteSpace(space.Id)) - _, err = s.UpsertDraft(newDraft(mmmodel.NewId(), space.Id, mmmodel.NewId(), ""), nil, nil) + _, _, err = s.UpsertDraft(newDraft(mmmodel.NewId(), space.Id, mmmodel.NewId(), ""), nil, nil) require.True(t, store.IsErrNotFound(err), "expected not-found for a deleted space, got %v", err) }) @@ -1954,7 +1963,7 @@ func TestDraft(t *testing.T) { require.NoError(t, err) parentID := parent.Id - saved, err := s.UpsertDraft(newDraft(userID, space.Id, mmmodel.NewId(), parentID), &parentID, nil) + saved, _, err := s.UpsertDraft(newDraft(userID, space.Id, mmmodel.NewId(), parentID), &parentID, nil) require.NoError(t, err) require.Equal(t, parent.Id, saved.ParentId) }) @@ -1965,7 +1974,7 @@ func TestDraft(t *testing.T) { require.NoError(t, err) missingParentID := mmmodel.NewId() - _, err = s.UpsertDraft(newDraft(mmmodel.NewId(), space.Id, mmmodel.NewId(), missingParentID), &missingParentID, nil) + _, _, err = s.UpsertDraft(newDraft(mmmodel.NewId(), space.Id, mmmodel.NewId(), missingParentID), &missingParentID, nil) require.True(t, store.IsErrInvalidInput(err), "expected invalid input for a missing parent, got %v", err) }) @@ -1981,7 +1990,7 @@ func TestDraft(t *testing.T) { require.NoError(t, deletePageErr(s, parent.Id, parent.SpaceId, userID)) parentID := parent.Id - _, err = s.UpsertDraft(newDraft(userID, space.Id, mmmodel.NewId(), parentID), &parentID, nil) + _, _, err = s.UpsertDraft(newDraft(userID, space.Id, mmmodel.NewId(), parentID), &parentID, nil) require.True(t, store.IsErrInvalidInput(err), "expected invalid input for a deleted parent, got %v", err) }) @@ -1998,7 +2007,7 @@ func TestDraft(t *testing.T) { require.NoError(t, err) parentID := parentInB.Id - _, err = s.UpsertDraft(newDraft(userID, spaceA.Id, mmmodel.NewId(), parentID), &parentID, nil) + _, _, err = s.UpsertDraft(newDraft(userID, spaceA.Id, mmmodel.NewId(), parentID), &parentID, nil) require.True(t, store.IsErrInvalidInput(err), "expected invalid input for a cross-space parent, got %v", err) }) @@ -2008,11 +2017,11 @@ func TestDraft(t *testing.T) { require.NoError(t, err) userID := mmmodel.NewId() - parentDraft, err := s.UpsertDraft(newDraft(userID, space.Id, mmmodel.NewId(), ""), nil, nil) + parentDraft, _, err := s.UpsertDraft(newDraft(userID, space.Id, mmmodel.NewId(), ""), nil, nil) require.NoError(t, err) parentPageID := parentDraft.PageId - saved, err := s.UpsertDraft(newDraft(userID, space.Id, mmmodel.NewId(), parentPageID), &parentPageID, nil) + saved, _, err := s.UpsertDraft(newDraft(userID, space.Id, mmmodel.NewId(), parentPageID), &parentPageID, nil) require.NoError(t, err) require.Equal(t, parentDraft.PageId, saved.ParentId) }) @@ -2023,23 +2032,79 @@ func TestDraft(t *testing.T) { require.NoError(t, err) userA, userB := mmmodel.NewId(), mmmodel.NewId() - otherDraft, err := s.UpsertDraft(newDraft(userA, space.Id, mmmodel.NewId(), ""), nil, nil) + otherDraft, _, err := s.UpsertDraft(newDraft(userA, space.Id, mmmodel.NewId(), ""), nil, nil) require.NoError(t, err) otherPageID := otherDraft.PageId - _, err = s.UpsertDraft(newDraft(userB, space.Id, mmmodel.NewId(), otherPageID), &otherPageID, nil) + _, _, err = s.UpsertDraft(newDraft(userB, space.Id, mmmodel.NewId(), otherPageID), &otherPageID, nil) require.True(t, store.IsErrInvalidInput(err), "expected invalid input for another user's draft parent, got %v", err) }) + // TestDraft/"upsert rejects a draft whose parent chain cycles back to itself" exercises + // checkNoDraftCycle's cycle branch: a root new-page draft, a second draft parented under it, + // then re-parenting the root under the second draft closes the loop root -> child -> root. + t.Run("upsert rejects a draft whose parent chain cycles back to itself", func(t *testing.T) { + s := openTestDB(t) + space, err := s.CreateSpace(newSpace(mmmodel.NewId())) + require.NoError(t, err) + userID := mmmodel.NewId() + + rootDraft, _, err := s.UpsertDraft(newDraft(userID, space.Id, mmmodel.NewId(), ""), nil, nil) + require.NoError(t, err) + + rootPageID := rootDraft.PageId + childDraft, _, err := s.UpsertDraft(newDraft(userID, space.Id, mmmodel.NewId(), rootPageID), &rootPageID, nil) + require.NoError(t, err) + + childPageID := childDraft.PageId + _, _, err = s.UpsertDraft(newDraft(userID, space.Id, rootDraft.PageId, childPageID), &childPageID, nil) + require.Error(t, err) + var inv *store.ErrInvalidInput + require.True(t, errors.As(err, &inv), "expected ErrInvalidInput, got %T: %v", err, err) + require.Equal(t, store.ReasonDraftCycle, inv.Reason) + }) + + // TestDraft/"upsert rejects a draft whose parent chain exceeds the max depth" exercises + // checkNoDraftCycle's too-deep branch. Each draft added to the chain is itself parent-chain + // validated, so a chain of exactly DraftCycleCheckMaxDepth new-page drafts is the deepest one + // that can be built without tripping the cap; a further draft parented under the deepest one + // is rejected as too deep. + t.Run("upsert rejects a draft whose parent chain exceeds the max depth", func(t *testing.T) { + s := openTestDB(t) + space, err := s.CreateSpace(newSpace(mmmodel.NewId())) + require.NoError(t, err) + userID := mmmodel.NewId() + + parentID := "" + for range store.DraftCycleCheckMaxDepth { + pageID := mmmodel.NewId() + var parentParam *string + if parentID != "" { + p := parentID + parentParam = &p + } + _, _, err = s.UpsertDraft(newDraft(userID, space.Id, pageID, parentID), parentParam, nil) + require.NoError(t, err) + parentID = pageID + } + + deepestParentID := parentID + _, _, err = s.UpsertDraft(newDraft(userID, space.Id, mmmodel.NewId(), deepestParentID), &deepestParentID, nil) + require.Error(t, err) + var inv *store.ErrInvalidInput + require.True(t, errors.As(err, &inv), "expected ErrInvalidInput, got %T: %v", err, err) + require.Equal(t, store.ReasonDraftTooDeep, inv.Reason) + }) + t.Run("drafts for space is scoped to the user", func(t *testing.T) { s := openTestDB(t) space, err := s.CreateSpace(newSpace(mmmodel.NewId())) require.NoError(t, err) userA, userB := mmmodel.NewId(), mmmodel.NewId() - _, err = s.UpsertDraft(newDraft(userA, space.Id, mmmodel.NewId(), ""), nil, nil) + _, _, err = s.UpsertDraft(newDraft(userA, space.Id, mmmodel.NewId(), ""), nil, nil) require.NoError(t, err) - _, err = s.UpsertDraft(newDraft(userB, space.Id, mmmodel.NewId(), ""), nil, nil) + _, _, err = s.UpsertDraft(newDraft(userB, space.Id, mmmodel.NewId(), ""), nil, nil) require.NoError(t, err) drafts, err := s.GetDraftsForSpace(userA, space.Id, 0, testDraftListLimit) @@ -2059,7 +2124,7 @@ func TestDraft(t *testing.T) { d.Body = `{"type":"doc","content":[{"type":"paragraph"}]}` d.FileIds = mmmodel.StringArray{mmmodel.NewId(), mmmodel.NewId()} d.Props = mmmodel.StringInterface{"k": float64(1700000000123)} - _, err := s.UpsertDraft(d, nil, &d.FileIds) + _, _, err := s.UpsertDraft(d, nil, &d.FileIds) require.NoError(t, err) got, err := s.GetDraft(userID, pageID) @@ -2076,7 +2141,7 @@ func TestDraft(t *testing.T) { require.NoError(t, spaceErr) spaceID := space.Id - _, err := s.UpsertDraft(newDraft(userID, spaceID, pageID, ""), nil, nil) + _, _, err := s.UpsertDraft(newDraft(userID, spaceID, pageID, ""), nil, nil) require.NoError(t, err) got, err := s.GetDraft(userID, pageID) @@ -2099,13 +2164,13 @@ func TestDraft(t *testing.T) { require.NoError(t, err) firstParent, secondParent := firstPage.Id, secondPage.Id - _, err = s.UpsertDraft(newDraft(userID, spaceID, pageID, firstParent), &firstParent, nil) + _, _, err = s.UpsertDraft(newDraft(userID, spaceID, pageID, firstParent), &firstParent, nil) require.NoError(t, err) got, err := s.GetDraft(userID, pageID) require.NoError(t, err) require.Equal(t, firstParent, got.ParentId) - _, err = s.UpsertDraft(newDraft(userID, spaceID, pageID, secondParent), &secondParent, nil) + _, _, err = s.UpsertDraft(newDraft(userID, spaceID, pageID, secondParent), &secondParent, nil) require.NoError(t, err) got, err = s.GetDraft(userID, pageID) require.NoError(t, err) @@ -2122,7 +2187,7 @@ func TestDraft(t *testing.T) { d := newDraft(userID, spaceID, pageID, "") d.Title = "Title Only" d.Body = "" - _, err := s.UpsertDraft(d, nil, nil) + _, _, err := s.UpsertDraft(d, nil, nil) require.NoError(t, err) got, err := s.GetDraft(userID, pageID) @@ -2139,11 +2204,11 @@ func TestDraft(t *testing.T) { spaceB, err := s.CreateSpace(newSpace(mmmodel.NewId())) require.NoError(t, err) - _, err = s.UpsertDraft(newDraft(userID, spaceA.Id, mmmodel.NewId(), ""), nil, nil) + _, _, err = s.UpsertDraft(newDraft(userID, spaceA.Id, mmmodel.NewId(), ""), nil, nil) require.NoError(t, err) - _, err = s.UpsertDraft(newDraft(userID, spaceA.Id, mmmodel.NewId(), ""), nil, nil) + _, _, err = s.UpsertDraft(newDraft(userID, spaceA.Id, mmmodel.NewId(), ""), nil, nil) require.NoError(t, err) - _, err = s.UpsertDraft(newDraft(userID, spaceB.Id, mmmodel.NewId(), ""), nil, nil) + _, _, err = s.UpsertDraft(newDraft(userID, spaceB.Id, mmmodel.NewId(), ""), nil, nil) require.NoError(t, err) drafts, err := s.GetDraftsForSpace(userID, spaceA.Id, 0, testDraftListLimit) @@ -2167,11 +2232,11 @@ func TestDraft(t *testing.T) { // Upsert runs the full model IsValid, so a malformed (non-empty) id is rejected as // invalid input. - _, err := s.UpsertDraft(newDraft("bad", valid, valid, ""), nil, nil) + _, _, err := s.UpsertDraft(newDraft("bad", valid, valid, ""), nil, nil) require.True(t, store.IsErrInvalidInput(err), "upsert with bad user id, got %v", err) // Upsert with nil draft must return ErrInvalidInput. - _, err = s.UpsertDraft(nil, nil, nil) + _, _, err = s.UpsertDraft(nil, nil, nil) require.True(t, store.IsErrInvalidInput(err), "upsert nil draft, got %v", err) // Get/Delete guard only against empty ids (matching the page/space store convention); @@ -2227,7 +2292,7 @@ func TestDeletePageReparentsPendingDrafts(t *testing.T) { // A new-page draft (its own page not yet created) pending as a child of parent. newPageID := mmmodel.NewId() parentID := parent.Id - _, err = s.UpsertDraft(newDraft(userID, space.Id, newPageID, parentID), &parentID, nil) + _, _, err = s.UpsertDraft(newDraft(userID, space.Id, newPageID, parentID), &parentID, nil) require.NoError(t, err) require.NoError(t, deletePageErr(s, parent.Id, parent.SpaceId, userID)) @@ -2457,7 +2522,7 @@ func TestGetActiveEditorsForPage(t *testing.T) { pageID := mmmodel.NewId() userID := mmmodel.NewId() - _, err = s.UpsertDraft(newDraft(userID, space.Id, pageID, ""), nil, nil) + _, _, err = s.UpsertDraft(newDraft(userID, space.Id, pageID, ""), nil, nil) require.NoError(t, err) now := mmmodel.GetMillis() @@ -2484,7 +2549,7 @@ func TestGetActiveEditorsForPage(t *testing.T) { require.NoError(t, err) otherUser := mmmodel.NewId() // Same (reserved) pageID, different space and user — an unpublished new-page draft. - _, err = s.UpsertDraft(newDraft(otherUser, otherSpace.Id, pageID, ""), nil, nil) + _, _, err = s.UpsertDraft(newDraft(otherUser, otherSpace.Id, pageID, ""), nil, nil) require.NoError(t, err) editors, err := s.GetPageActiveEditors(pageID, space.Id, mmmodel.GetMillis()-5*60*1000) @@ -2514,9 +2579,9 @@ func TestGetActiveEditorsForPageMultipleEditorsOrderedByLastActiveAt(t *testing. pageID := mmmodel.NewId() userA, userB := mmmodel.NewId(), mmmodel.NewId() - _, err = s.UpsertDraft(newDraft(userA, space.Id, pageID, ""), nil, nil) + _, _, err = s.UpsertDraft(newDraft(userA, space.Id, pageID, ""), nil, nil) require.NoError(t, err) - _, err = s.UpsertDraft(newDraft(userB, space.Id, pageID, ""), nil, nil) + _, _, err = s.UpsertDraft(newDraft(userB, space.Id, pageID, ""), nil, nil) require.NoError(t, err) // Push userA's LastActiveAt into the past so userB (more recent) should appear first. @@ -2550,7 +2615,7 @@ func TestGetActiveEditorsForPageIgnoresMaintenanceWrites(t *testing.T) { // A new-page draft pending under the parent, last actually edited well outside the window. childPageID := mmmodel.NewId() parentID := parent.Id - _, err = s.UpsertDraft(newDraft(userID, space.Id, childPageID, parentID), &parentID, nil) + _, _, err = s.UpsertDraft(newDraft(userID, space.Id, childPageID, parentID), &parentID, nil) require.NoError(t, err) stale := mmmodel.GetMillis() - 60*60*1000 @@ -2583,7 +2648,7 @@ func TestUpsertDraftBumpsUpdateAtMonotonically(t *testing.T) { userID := mmmodel.NewId() pageID := mmmodel.NewId() - _, err = s.UpsertDraft(newDraft(userID, space.Id, pageID, ""), nil, nil) + _, _, err = s.UpsertDraft(newDraft(userID, space.Id, pageID, ""), nil, nil) require.NoError(t, err) // Force the stored UpdateAt ahead of the next save's wall clock. Without the monotonic bump, @@ -2595,7 +2660,7 @@ func TestUpsertDraftBumpsUpdateAtMonotonically(t *testing.T) { Where(sq.Eq{"UserId": userID, "PageId": pageID})) require.NoError(t, err) - saved, err := s.UpsertDraft(newDraft(userID, space.Id, pageID, ""), nil, nil) + saved, _, err := s.UpsertDraft(newDraft(userID, space.Id, pageID, ""), nil, nil) require.NoError(t, err) require.Equal(t, future+1, saved.UpdateAt, "UpdateAt must advance to stored+1 when the incoming timestamp is not already greater") @@ -2608,7 +2673,7 @@ func TestDeleteDraftVersion(t *testing.T) { userID := mmmodel.NewId() pageID := mmmodel.NewId() - saved, err := s.UpsertDraft(newDraft(userID, space.Id, pageID, ""), nil, nil) + saved, _, err := s.UpsertDraft(newDraft(userID, space.Id, pageID, ""), nil, nil) require.NoError(t, err) t.Run("stale version deletes nothing and leaves the draft intact", func(t *testing.T) { @@ -2646,7 +2711,7 @@ func TestPublishDraft(t *testing.T) { userID := mmmodel.NewId() pageID := mmmodel.NewId() - draft, err := s.UpsertDraft(newDraft(userID, space.Id, pageID, ""), nil, nil) + draft, _, err := s.UpsertDraft(newDraft(userID, space.Id, pageID, ""), nil, nil) require.NoError(t, err) page := &model.Page{Id: pageID, SpaceId: space.Id, Title: "Published", Body: `{"type":"doc","content":[]}`, UserId: userID} @@ -2673,7 +2738,7 @@ func TestPublishDraft(t *testing.T) { require.NoError(t, err) d := newDraft(userID, space.Id, created.Id, "") d.Props = mmmodel.StringInterface{model.DraftPropsOriginalPageEditAt: created.EditAt} - draft, err := s.UpsertDraft(d, nil, nil) + draft, _, err := s.UpsertDraft(d, nil, nil) require.NoError(t, err) edit := *created @@ -2696,7 +2761,7 @@ func TestPublishDraft(t *testing.T) { require.NoError(t, err) d2 := newDraft(userID, space.Id, created.Id, "") d2.Props = mmmodel.StringInterface{model.DraftPropsOriginalPageEditAt: created.EditAt} - draft, err := s.UpsertDraft(d2, nil, nil) + draft, _, err := s.UpsertDraft(d2, nil, nil) require.NoError(t, err) edit := *created @@ -2724,14 +2789,14 @@ func TestPublishDraft(t *testing.T) { require.NoError(t, err) d3 := newDraft(userID, space.Id, created.Id, "") d3.Props = mmmodel.StringInterface{model.DraftPropsOriginalPageEditAt: created.EditAt} - stale, err := s.UpsertDraft(d3, nil, nil) + stale, _, err := s.UpsertDraft(d3, nil, nil) require.NoError(t, err) // The user's editor autosaves again after the publish path read the draft. newer := newDraft(userID, space.Id, created.Id, "") newer.Body = `{"type":"doc","content":[{"type":"paragraph"}]}` newer.Props = mmmodel.StringInterface{model.DraftPropsOriginalPageEditAt: created.EditAt} - newer, err = s.UpsertDraft(newer, nil, nil) + newer, _, err = s.UpsertDraft(newer, nil, nil) require.NoError(t, err) require.Greater(t, newer.UpdateAt, stale.UpdateAt, "the autosave must advance UpdateAt") From 59e82205c6ddf479ab42f4a9803b2e193f1a4a4f Mon Sep 17 00:00:00 2001 From: "Catalin I. Tomai" Date: Mon, 20 Jul 2026 17:18:26 +0200 Subject: [PATCH 25/36] update comments --- server/app/page.go | 7 ++++--- server/app/page_draft.go | 24 ++++++++++------------ server/app/page_presence.go | 15 +++++++------- server/app/service.go | 2 +- server/app/ws_events.go | 4 ++-- server/model/draft.go | 8 ++++---- server/model/page_content.go | 3 +-- server/store/draft_store.go | 40 ++++++++++++++++++------------------ server/store/page_move.go | 20 +++++++++--------- server/store/page_store.go | 2 +- server/store/store.go | 5 +++-- 11 files changed, 65 insertions(+), 65 deletions(-) diff --git a/server/app/page.go b/server/app/page.go index ec2fb5b..719ff32 100644 --- a/server/app/page.go +++ b/server/app/page.go @@ -18,9 +18,10 @@ import ( // store.MaxPageHierarchyDepth (50) is a separate, larger bound used by descendant/ancestor reads. const MaxPageDepth = 10 -// A draft chain publishes into a page chain of the same depth, so the store's draft-cycle bound -// must equal MaxPageDepth. Either subtraction below is a negative constant if they drift, which -// overflows uint and fails to compile — catching drift in both directions. +// MaxPageDepth and store.DraftCycleCheckMaxDepth must stay equal: a draft chain publishes into a +// page chain of the same depth. The two lines below fail to compile if the values ever diverge — +// whichever subtraction goes negative overflows when converted to uint, which Go rejects in a +// constant expression. Both directions are checked so drift is caught whichever constant grew. const ( _ = uint(MaxPageDepth - store.DraftCycleCheckMaxDepth) _ = uint(store.DraftCycleCheckMaxDepth - MaxPageDepth) diff --git a/server/app/page_draft.go b/server/app/page_draft.go index f06f9cb..b3417aa 100644 --- a/server/app/page_draft.go +++ b/server/app/page_draft.go @@ -127,7 +127,7 @@ func (s *Service) UpdatePageDraft(draft *model.Draft, parentID *string, fileIDs // New-page drafts (no published page row yet) must not broadcast presence to the space channel: // that would expose the reserved page ID and the author's identity to all space members before // the page exists. Send the event only to the author so their own UI can track the session. - // UpsertDraft already determined liveness under its page-row lock, so reuse its result here; + // UpsertDraft already determined liveness as part of the same call, so reuse its result here; // only the no-existing-draft branch above resolved it independently. if !pageIsLiveResolved { pageIsLive = savedPageWasLive @@ -309,11 +309,9 @@ func (s *Service) DeletePageDraft(userID, spaceID, pageID, channelID string) *mm return appErr } - // Discard is unconditional: the user wants the draft gone regardless of its current version. An - // autosave already in flight when the discard commits can still re-insert the draft afterward - // (an unpublished new-page draft has no page row for UpsertDraft's staleness guard to key on), so - // a discarded draft can briefly reappear. It is per-user and cleared by discarding again; fully - // preventing it would need a soft-delete tombstone, which is not warranted for this window. + // Discard is unconditional. A concurrent autosave in flight can briefly re-insert the draft after + // this commits — an unpublished new-page draft has no page row for UpsertDraft's staleness guard + // to key on. It is per-user and clears on a repeat discard, so a tombstone isn't warranted. // pageWasLive, delErr := s.store.DeleteDraftReparenting(userID, spaceID, pageID) if delErr != nil { @@ -347,7 +345,7 @@ func (s *Service) GetPageDraftsForSpace(userID, spaceID string, page, perPage in return nil, false, mmmodel.NewAppError("GetPageDraftsForSpace", "app.page_draft.list.invalid_space_id.app_error", nil, "", http.StatusBadRequest) } - // The store's liveness join excludes a soft-deleted space, so this need not re-check liveness. + // The store already excludes drafts in a soft-deleted space, so this need not re-check liveness. offset, limit := paginationOffsetLimit(page, perPage) drafts, err := s.store.GetDraftsForSpace(userID, spaceID, offset, limit) if err != nil { @@ -519,10 +517,11 @@ func (s *Service) PublishPageDraft(userID, spaceID, pageID string, force bool) ( if rErr == nil && raced != nil && raced.SpaceId == spaceID && raced.DeleteAt == 0 { // Discard this caller's now-orphaned draft so it does not linger pointing at a // published page — but only if it still holds the version this publish read. A fresh - // autosave landing after the race winner committed bumps UpdateAt, so the CAS matches - // no row and that newer draft is left intact rather than silently dropped. Cleanup is - // best-effort: the page is already published by the winner, so a failure here is logged - // (a stray draft the user can discard), never surfaced as a publish failure. + // autosave after the race winner committed bumps UpdateAt, so the CAS matches no row + // and that newer draft is left intact rather than dropped. + // + // Best-effort: the page is already published by the winner, so a failure here is + // logged (a stray draft the user can discard), never surfaced as a publish failure. if _, delErr := s.store.DeleteDraftVersion(userID, pageID, draft.UpdateAt); delErr != nil { s.log.Warn("PublishPageDraft: failed to delete orphaned draft after adopting race winner", "page_id", pageID, "user_id", userID, "err", delErr) @@ -533,8 +532,7 @@ func (s *Service) PublishPageDraft(userID, spaceID, pageID string, force bool) ( return raced, false, nil } if rErr != nil { - // A real store failure here is not the same as losing the race; it would otherwise - // be indistinguishable from it in the response, so leave a trace. + // Log this: a real store failure here would otherwise look identical to losing the race. s.log.Warn("PublishPageDraft: failed to read the page that won the publish race", "page_id", pageID, "user_id", userID, "err", rErr) } diff --git a/server/app/page_presence.go b/server/app/page_presence.go index cb4d542..d34a4eb 100644 --- a/server/app/page_presence.go +++ b/server/app/page_presence.go @@ -78,13 +78,14 @@ func (s *Service) publishSelfPresence(draft *model.Draft) { }, draft.UserId) } -// broadcastPagePresence fans a page_presence_updated event out to the space audience, carrying the -// current active-editor set, the time it was taken (so a client can discard an out-of-order snapshot -// delivered from another cluster node), and active_timeout_ms. Broadcasts are only triggered by user -// actions (autosave, discard, publish), so a client that receives no newer snapshot cannot tell a -// still-active editor from one whose session ended abnormally; active_timeout_ms lets it expire the -// snapshot's editors on its own once as_of + active_timeout_ms has passed. channelID is the space's -// backing channel. Best-effort: failures are swallowed. +// broadcastPagePresence fans a page_presence_updated event out to the space audience on channelID +// (the space's backing channel), carrying the current active-editor set, as_of, and active_timeout_ms. +// Best-effort: failures are swallowed. +// +// Broadcasts fire only on user actions (autosave, discard, publish), never periodically, so a client +// that receives no newer snapshot cannot distinguish a still-active editor from one whose session +// ended abnormally. active_timeout_ms lets it expire the snapshot's editors on its own once +// as_of + active_timeout_ms has passed. func (s *Service) broadcastPagePresence(pageID, spaceID, channelID string) { if s.client == nil { return diff --git a/server/app/service.go b/server/app/service.go index 6ce0cae..b33afbf 100644 --- a/server/app/service.go +++ b/server/app/service.go @@ -41,7 +41,7 @@ type Service struct { client *pluginapi.Client // presenceBroadcastLast records the last autosave-triggered presence broadcast time (ms) per - // pageID, used to rate-limit high-frequency autosave broadcasts. Delete and publish paths bypass + // (pageID, userID), used to rate-limit high-frequency autosave broadcasts. Delete and publish paths bypass // this and always broadcast. // // The map is per-process, so each node throttles independently: a user whose autosaves are diff --git a/server/app/ws_events.go b/server/app/ws_events.go index 64b2d9d..45a5d02 100644 --- a/server/app/ws_events.go +++ b/server/app/ws_events.go @@ -35,8 +35,8 @@ const ( wsEventPageDuplicated = "page_duplicated" wsEventPageMovedToSpace = "page_moved_to_space" // wsEventPagePresenceUpdated carries a presence snapshot ({page_id, space_id, active_editors, - // as_of}), not the {page_id, space_id} mutation shape, and fires on every autosave/draft-delete - // rather than on a page write. + // as_of, active_timeout_ms}), not the {page_id, space_id} mutation shape; it is rate-limited on + // autosave but always fires on discard and publish. wsEventPagePresenceUpdated = "page_presence_updated" wsEventSpaceCreated = "space_created" diff --git a/server/model/draft.go b/server/model/draft.go index 8a77634..269db61 100644 --- a/server/model/draft.go +++ b/server/model/draft.go @@ -14,8 +14,10 @@ import ( // DraftFileIdsMaxRunes is the maximum rune length of the serialized FileIds JSON array. const DraftFileIdsMaxRunes = 300 -// DraftPropsOriginalPageEditAt stores the EditAt the user last saw when they opened -// a page for editing — used as the optimistic-lock baseline on publish. +// DraftPropsOriginalPageEditAt is a draft Props key holding the page's EditAt at the moment the user +// opened it for editing. On publish the server compares this against the page's current EditAt; if +// they differ, another user saved the page in the meantime, so the publish is rejected as a conflict +// rather than overwriting the newer content. const DraftPropsOriginalPageEditAt = "original_page_edit_at" // Draft is a per-user autosave draft for a space page, stored in DOCS_Draft. @@ -78,8 +80,6 @@ func (d *Draft) PreSave() { d.CreateAt = now } d.UpdateAt = now - // PreSave runs only on a user's own save of the draft, which is exactly the signal presence - // needs — bulk maintenance writes update the row without going through here. d.LastActiveAt = now } diff --git a/server/model/page_content.go b/server/model/page_content.go index 638fc3d..b09e08e 100644 --- a/server/model/page_content.go +++ b/server/model/page_content.go @@ -39,8 +39,7 @@ func BuildSearchText(doc TipTapDocument) string { // ParseTipTapDocument parses and sanitizes a TipTap JSON string into a TipTapDocument. Unlike the // model's field validators, it returns a plain error (not an *mmmodel.AppError): its failures are -// parse-level and are always collapsed by the app layer into one generic content app-error, so they -// are not meant to be addressable per-reason by an i18n key. +// parse-level, not per-field, so there is no single i18n key to attach per reason. func ParseTipTapDocument(contentJSON string) (TipTapDocument, error) { if contentJSON == "" { return TipTapDocument{ diff --git a/server/store/draft_store.go b/server/store/draft_store.go index f41905c..239f26b 100644 --- a/server/store/draft_store.go +++ b/server/store/draft_store.go @@ -24,7 +24,7 @@ const DraftCycleCheckMaxDepth = 10 // MaxDraftsPerUserPerSpace is the maximum number of draft rows a single user may hold in one // space. Enforced atomically inside UpsertDraft after the space lock, so it holds under -// concurrent creates. The app layer may also use this constant for a fast-path pre-check. +// concurrent creates. const MaxDraftsPerUserPerSpace = 100 // maxActiveEditorsPerPage caps the number of user IDs returned by GetPageActiveEditors. A page @@ -124,7 +124,7 @@ func (s *Store) countDraftsForUser(e sqlx.ExtContext, userID, spaceID string) (i } // draftExistsTx reports whether a draft row keyed by (userID, pageID) currently exists, read within -// tx so it observes the transaction's own view (including the page-row lock the caller already holds). +// tx so it observes the transaction's own uncommitted writes. func (s *Store) draftExistsTx(tx *sqlx.Tx, userID, pageID string) (bool, error) { var one int builder := s.getQueryBuilder(). @@ -143,7 +143,7 @@ func (s *Store) draftExistsTx(tx *sqlx.Tx, userID, pageID string) (bool, error) // checkNoDraftCycle walks the parent chain from startParentID through the caller's draft rows and // returns an error if leafPageID appears anywhere in the chain (cycle) or if the total depth -// (draft chain + live-page ancestor) would exceed MaxPageHierarchyDepth. A published-page +// (draft chain + live-page ancestor) would exceed DraftCycleCheckMaxDepth. A published-page // ancestor (no matching draft row) terminates the recursion early. Squirrel cannot express // recursive CTEs, so raw SQL is used here. func (s *Store) checkNoDraftCycle(tx *sqlx.Tx, userID, leafPageID, startParentID string) error { @@ -216,11 +216,12 @@ FROM chain`, DraftCycleCheckMaxDepth, DraftCycleCheckMaxDepth) // happens inside the single upsert statement, so two concurrent autosaves cannot lose a field by // merging against a stale read. The stored row is returned. // +// The draft's space must be live, and the PageId must belong to the draft's space. +// // parentID encodes the write intent for the ParentId column: nil means "omitted — preserve the // existing stored parent", a pointer to "" means "clear to root", and a pointer to a valid ID // means "set to that ID". The draft struct's own ParentId field is not used on the write path. // -// The draft's space must be live; the PageId must belong to the draft's space. // fileIDs encodes the write intent for the FileIds column: nil means "omitted — preserve the // existing stored value", a pointer to an empty slice means "clear to no attachments", and a // pointer to a non-empty slice means "replace with these IDs". This mirrors parentID's @@ -261,8 +262,8 @@ func (s *Store) UpsertDraft(draft *model.Draft, parentID *string, fileIDs *mmmod // Quota check: enforce MaxDraftsPerUserPerSpace atomically inside the space lock so // concurrent CreateSpaceDraft calls in the same space cannot both pass a stale pre-check // and each insert a row that pushes the total past the cap. - // Skip the check on the UPDATE path (existing draft) to avoid an unnecessary count query - // on every autosave. + // Skip the check on the UPDATE path: updating an existing draft adds no row, so it cannot + // push the total over the cap. isExisting, existErr := s.draftExistsTx(tx, draft.UserId, draft.PageId) if existErr != nil { return nil, false, existErr @@ -358,10 +359,9 @@ func (s *Store) UpsertDraft(draft *model.Draft, parentID *string, fileIDs *mmmod } } - // parentIDParam is SQL NULL when parentID is nil (preserve on conflict), and the dereferenced - // string otherwise. The COALESCE in VALUES ensures NOT NULL is satisfied on INSERT; the CASE in - // the ON CONFLICT clause reads the original bound parameter (not EXCLUDED.ParentId) to - // distinguish nil ("omit, preserve") from "" ("explicit clear to root"). + // The COALESCE in VALUES ensures NOT NULL is satisfied on INSERT; the CASE in the ON CONFLICT + // clause reads the original bound parameter (not EXCLUDED.ParentId) to distinguish nil + // ("omit, preserve") from "" ("explicit clear to root"). builder := s.getQueryBuilder(). Insert("DOCS_Draft"). Columns(draftSelectColumns...). @@ -468,16 +468,12 @@ func (s *Store) DeleteDraftVersion(userID, pageID string, expectedUpdateAt int64 return rows > 0, nil } -// DeleteDraftReparenting atomically reparents the calling user's child drafts (those with -// ParentId = pageID) to the deleted draft's own parent, then deletes the draft keyed by -// (userID, spaceID, pageID). This prevents child drafts from holding a dangling parent after a -// discard. The space row is locked first, before any draft row, matching the space→row lock order -// of every other structural draft mutation (UpsertDraft, PublishDraft): this serializes concurrent -// discards in the same chain on a single lock — so two of them cannot interleave into a dangling -// parent — and keeps the lock order consistent, avoiding an AB-BA deadlock against UpsertDraft. -// Returns ErrNotFound when no draft exists for (userID, spaceID, pageID). pageWasLive is true when -// the deleted draft was an edit draft (the page exists as a live page), false for new-page drafts; -// callers use this to decide whether a presence broadcast is needed. +// DeleteDraftReparenting deletes the draft keyed by (userID, spaceID, pageID). When the discarded +// draft is a new-page draft (no live page yet), its child drafts are first reparented to its own +// parent so they don't dangle; an edit draft's children still point at the live page and are left +// untouched. Returns ErrNotFound when no draft exists for (userID, spaceID, pageID). pageWasLive is +// true when the deleted draft was an edit draft (the page exists as a live page), false for new-page +// drafts; callers use this to decide whether a presence broadcast is needed. func (s *Store) DeleteDraftReparenting(userID, spaceID, pageID string) (pageWasLive bool, err error) { if userID == "" { return false, &ErrInvalidInput{Entity: "Draft", Field: "userId", Value: userID} @@ -495,6 +491,10 @@ func (s *Store) DeleteDraftReparenting(userID, spaceID, pageID string) (pageWasL } defer s.finalizeTransaction(tx, &err) + // Lock the space row before any draft row, matching the space→row lock order of every other + // structural draft mutation (UpsertDraft, PublishDraft): this serializes concurrent discards in + // the same chain so two cannot interleave into a dangling parent, and keeps the lock order + // consistent to avoid an AB-BA deadlock against UpsertDraft. if lockErr := s.lockLiveSpace(tx, spaceID); lockErr != nil { return false, lockErr } diff --git a/server/store/page_move.go b/server/store/page_move.go index 1822240..767afb0 100644 --- a/server/store/page_move.go +++ b/server/store/page_move.go @@ -433,9 +433,7 @@ func (s *Store) collectLiveSubtreeIDs(tx *sqlx.Tx, pageID string) ([]string, int // targetSpaceID/targetChannelID, chunked, within tx. It rewrites SpaceId/ChannelId across // live DOCS_Page rows, their version snapshots (OriginalId IN ids), and DOCS_Draft rows. // Every user's drafts follow their page, not just the mover's: a draft is unpublished work its -// owner has not consented to lose, so a move must not destroy it as a side effect. An owner who -// is not a member of targetSpaceID simply cannot reach the draft — the space-membership check on -// every read gates it — but the content survives and returns if they gain access. +// owner has not consented to lose, so a move must not destroy it as a side effect. func (s *Store) rewriteSubtreeSpace(tx *sqlx.Tx, ids []string, sourceSpaceID, targetSpaceID, targetChannelID, moverUserID string, now int64) error { // Quota guard: count the mover's drafts that will be re-homed into targetSpaceID (those in // source that cover moved pages or sit under them as new-page children) and ensure adding @@ -499,13 +497,15 @@ func (s *Store) rewriteSubtreeSpace(tx *sqlx.Tx, ids []string, sourceSpaceID, ta } // Re-home every owner's drafts for the moved pages, so a draft keeps matching its page's - // space and stays readable to an owner who is a member of the target. UpdateAt uses - // monotonicBump so it stays a valid CAS token even when the move and a concurrent autosave - // share a millisecond boundary. SpaceId = sourceSpaceID prevents cross-space ID collisions - // from re-homing unrelated drafts that happen to share a PageId or ParentId. LastActiveAt is - // reset so a re-homed draft is not reported as an active editor in the target space until its - // owner edits it there — otherwise a source-only owner's recent edit would surface to target - // members through GetPageActiveEditors for the remainder of the active-editor window. + // space and stays readable to an owner who is a member of the target. + // + // UpdateAt uses monotonicBump so it stays a valid CAS token even when the move and a + // concurrent autosave share a millisecond boundary. SpaceId = sourceSpaceID prevents + // re-homing an unrelated draft that happens to share a PageId or ParentId with another space. + // + // LastActiveAt is reset so a re-homed draft is not reported as an active editor in the target + // space until its owner edits it there — otherwise a source-only owner's recent edit would + // surface to target members through GetPageActiveEditors for the rest of the active-editor window. draftUpd := s.getQueryBuilder(). Update("DOCS_Draft"). Set("SpaceId", targetSpaceID). diff --git a/server/store/page_store.go b/server/store/page_store.go index 7ad4459..ad37af5 100644 --- a/server/store/page_store.go +++ b/server/store/page_store.go @@ -729,7 +729,7 @@ func (s *Store) PublishDraft(isNewPage bool, page *model.Page, userID, spaceID s if spaceID == "" { return nil, &ErrInvalidInput{Entity: "Draft", Field: "spaceID", Value: spaceID} } - // spaceID is the caller's authorized space; the page must live in it. A mismatch means the page + // spaceID is the space from the caller's request; the page must live in it. A mismatch means the page // was relocated by a concurrent move-to-space (edit path) or the caller built it for the wrong // space — reject rather than write under the stale/foreign space. if page.SpaceId != spaceID { diff --git a/server/store/store.go b/server/store/store.go index ff87790..1ebdf0a 100644 --- a/server/store/store.go +++ b/server/store/store.go @@ -332,8 +332,9 @@ const ( // ReasonConcurrentEdit: the page's EditAt no longer matches the baseline the caller published // against — someone else edited the page. ReasonConcurrentEdit = "concurrent_edit" - // ReasonConcurrentAutosave: the draft changed after the caller read it — the caller's own - // editor autosaved while the publish was in flight. + // ReasonConcurrentAutosave: the draft's version token no longer matches what the caller read — + // usually a concurrent autosave, but also a bulk write (a page delete reparenting a pending + // draft, or a move-to-space re-homing it) that bumps the token without changing content. ReasonConcurrentAutosave = "concurrent_autosave" ) From 4a6e466d205ca70aee9221275ccf495af679e6a9 Mon Sep 17 00:00:00 2001 From: "Catalin I. Tomai" Date: Mon, 20 Jul 2026 17:52:44 +0200 Subject: [PATCH 26/36] consolidate depth consts --- server/api_handler_test.go | 4 ++-- server/app/page.go | 21 ++++----------------- server/app/page_draft.go | 2 +- server/app/page_duplicate_test.go | 5 ++--- server/app/page_hierarchy.go | 4 ++-- server/app/page_move_test.go | 5 ++--- server/app/page_move_to_space_test.go | 3 +-- server/app/service_test.go | 2 +- server/model/page.go | 5 +++++ server/store/draft_store.go | 12 +++--------- server/store/page_move.go | 6 +++--- server/store/store_test.go | 6 +++--- 12 files changed, 29 insertions(+), 46 deletions(-) diff --git a/server/api_handler_test.go b/server/api_handler_test.go index 2aa7add..0c2b888 100644 --- a/server/api_handler_test.go +++ b/server/api_handler_test.go @@ -765,7 +765,7 @@ func TestHandler_MovePageToSpace_DepthExceeded(t *testing.T) { require.NoError(t, err) parentID := "" - for range app.MaxPageDepth { + for range model.MaxPageDepth { p := seedPage(t, h.store, spaceB.Id, channelB, parentID) parentID = p.Id } @@ -835,7 +835,7 @@ func TestHandler_MovePage_MaxDepthExceeded(t *testing.T) { space := seedSpace(t, h.store, channelID) parentID := "" - for range app.MaxPageDepth { + for range model.MaxPageDepth { p := seedPage(t, h.store, space.Id, channelID, parentID) parentID = p.Id } diff --git a/server/app/page.go b/server/app/page.go index 719ff32..14c9bc7 100644 --- a/server/app/page.go +++ b/server/app/page.go @@ -14,19 +14,6 @@ import ( "github.com/mattermost/mattermost-plugin-docs/server/store" ) -// MaxPageDepth is the page hierarchy depth limit (root is depth 1). -// store.MaxPageHierarchyDepth (50) is a separate, larger bound used by descendant/ancestor reads. -const MaxPageDepth = 10 - -// MaxPageDepth and store.DraftCycleCheckMaxDepth must stay equal: a draft chain publishes into a -// page chain of the same depth. The two lines below fail to compile if the values ever diverge — -// whichever subtraction goes negative overflows when converted to uint, which Go rejects in a -// constant expression. Both directions are checked so drift is caught whichever constant grew. -const ( - _ = uint(MaxPageDepth - store.DraftCycleCheckMaxDepth) - _ = uint(store.DraftCycleCheckMaxDepth - MaxPageDepth) -) - // CreatePage creates a new page in spaceID. ChannelId is derived from the space, not supplied by the caller. // The page ID is always server-generated; callers must not supply one. func (s *Service) CreatePage(spaceID, parentID, title, body, userID string) (*model.Page, *mmmodel.AppError) { @@ -72,7 +59,7 @@ func (s *Service) CreatePage(spaceID, parentID, title, body, userID string) (*mo s.log.Debug("Creating page", "space_id", spaceID, "parent_id", parentID, "user_id", userID) - created, storeErr := s.store.CreatePage(page, MaxPageDepth) + created, storeErr := s.store.CreatePage(page, model.MaxPageDepth) if storeErr != nil { if store.IsErrNotFound(storeErr) { // The space is missing or soft-deleted. @@ -211,7 +198,7 @@ func (s *Service) RestorePage(pageID, spaceID, userID string) (*model.Page, *mmm return nil, mmmodel.NewAppError("RestorePage", "app.page.restore.invalid_user_id.app_error", nil, "", http.StatusBadRequest) } s.log.Debug("Restoring page", "page_id", pageID, "user_id", userID) - restored, restoreErr := s.store.RestorePage(pageID, spaceID, userID, MaxPageDepth) + restored, restoreErr := s.store.RestorePage(pageID, spaceID, userID, model.MaxPageDepth) if restoreErr != nil { if appErr := restoreReasonAppError(restoreErr, map[string]*mmmodel.AppError{ store.ReasonNotRestorable: mmmodel.NewAppError("RestorePage", "app.page.restore.not_restorable.app_error", nil, "", http.StatusBadRequest), @@ -282,7 +269,7 @@ func (s *Service) DuplicatePage(pageID string, sourceSpace *model.Space, userID s.log.Debug("Duplicating page", "page_id", pageID, "source_space_id", sourceSpace.Id, "user_id", userID) - created, createErr := s.store.CreatePageSubtree(pages, MaxPageDepth) + created, createErr := s.store.CreatePageSubtree(pages, model.MaxPageDepth) if createErr != nil { if store.IsErrNotFound(createErr) { return nil, mmmodel.NewAppError("DuplicatePage", "app.page.duplicate.dest_not_found.app_error", nil, "", http.StatusNotFound).Wrap(createErr) @@ -291,7 +278,7 @@ func (s *Service) DuplicatePage(pageID string, sourceSpace *model.Space, userID // plain placement-depth breach uses storeAppError's operation-neutral key. var limErr *store.ErrLimitExceeded if errors.As(createErr, &limErr) && limErr.Reason == store.ReasonSubtreeMaxDepthExceeded { - return nil, mmmodel.NewAppError("DuplicatePage", "app.page.duplicate.subtree_max_depth_exceeded.app_error", map[string]any{"MaxDepth": MaxPageDepth}, "", http.StatusBadRequest).Wrap(createErr) + return nil, mmmodel.NewAppError("DuplicatePage", "app.page.duplicate.subtree_max_depth_exceeded.app_error", map[string]any{"MaxDepth": model.MaxPageDepth}, "", http.StatusBadRequest).Wrap(createErr) } return nil, storeAppError("DuplicatePage", createErr) } diff --git a/server/app/page_draft.go b/server/app/page_draft.go index b3417aa..19f8ab5 100644 --- a/server/app/page_draft.go +++ b/server/app/page_draft.go @@ -491,7 +491,7 @@ func (s *Service) PublishPageDraft(userID, spaceID, pageID string, force bool) ( // 6. Atomic write: page + draft-delete in one transaction. draft.UpdateAt is passed through so a // concurrent autosave rolls this publish back as a conflict rather than shipping older content — // see store.PublishDraft. - page, storeErr := s.store.PublishDraft(isNewPage, pageForWrite, userID, spaceID, force, MaxPageDepth, draft.UpdateAt) + page, storeErr := s.store.PublishDraft(isNewPage, pageForWrite, userID, spaceID, force, model.MaxPageDepth, draft.UpdateAt) if storeErr != nil { switch { // The draft moved under this publish: the caller's own editor autosaved after this call read it, diff --git a/server/app/page_duplicate_test.go b/server/app/page_duplicate_test.go index f20d010..ffc81f6 100644 --- a/server/app/page_duplicate_test.go +++ b/server/app/page_duplicate_test.go @@ -12,7 +12,6 @@ import ( mmmodel "github.com/mattermost/mattermost/server/public/model" - "github.com/mattermost/mattermost-plugin-docs/server/app" "github.com/mattermost/mattermost-plugin-docs/server/model" ) @@ -206,7 +205,7 @@ func TestServiceDuplicatePage_MaxDepthExceeded(t *testing.T) { // copied under it would land at (MaxPageDepth-1)+2 = MaxPageDepth+1 > MaxPageDepth. var deepest *model.Page parentID := "" - for range app.MaxPageDepth { + for range model.MaxPageDepth { deepest = mustCreatePage(t, h.store, space.Id, channelID, userID, parentID) parentID = deepest.Id } @@ -344,7 +343,7 @@ func TestServiceDuplicatePage_IncludeChildren_MaxDepthExceeded(t *testing.T) { // destinationDepth = (MaxPageDepth-2)+2 = MaxPageDepth — one level short of the cap. var deepest *model.Page parentID := "" - for range app.MaxPageDepth - 1 { + for range model.MaxPageDepth - 1 { deepest = mustCreatePage(t, h.store, space.Id, channelID, userID, parentID) parentID = deepest.Id } diff --git a/server/app/page_hierarchy.go b/server/app/page_hierarchy.go index f1f3e02..4e064f8 100644 --- a/server/app/page_hierarchy.go +++ b/server/app/page_hierarchy.go @@ -140,7 +140,7 @@ func (s *Service) MovePage(pageID, spaceID string, newParentID *string, newIndex // committed in between (surviving here via force) would make the earlier-read parent stale and // point clients at the wrong subtree to invalidate. func (s *Service) reparentWithinSpace(where, pageID, spaceID string, newParentID *string, newIndex *int64, expectedUpdateAt *int64, force bool) (*model.Page, *mmmodel.AppError) { - moved, priorParentID, didMove, storeErr := s.store.MovePage(pageID, spaceID, newParentID, newIndex, mmmodel.SafeDereference(expectedUpdateAt), force, MaxPageDepth) + moved, priorParentID, didMove, storeErr := s.store.MovePage(pageID, spaceID, newParentID, newIndex, mmmodel.SafeDereference(expectedUpdateAt), force, model.MaxPageDepth) if storeErr != nil { return nil, storeAppError(where, storeErr) } @@ -231,7 +231,7 @@ func (s *Service) MovePageToSpace(pageID string, sourceSpace, targetSpace *model s.log.Debug("Moving page to space", "page_id", pageID, "source_space_id", sourceSpace.Id, "target_space_id", targetSpace.Id, "user_id", userID) - moved, priorParentID, storeErr := s.store.MovePageToSpace(pageID, sourceSpace.Id, targetSpace.Id, userID, parentPageID, mmmodel.SafeDereference(expectedUpdateAt), force, MaxPageDepth) + moved, priorParentID, storeErr := s.store.MovePageToSpace(pageID, sourceSpace.Id, targetSpace.Id, userID, parentPageID, mmmodel.SafeDereference(expectedUpdateAt), force, model.MaxPageDepth) if storeErr != nil { return nil, storeAppError("MovePageToSpace", storeErr) } diff --git a/server/app/page_move_test.go b/server/app/page_move_test.go index 1151402..7cadf96 100644 --- a/server/app/page_move_test.go +++ b/server/app/page_move_test.go @@ -10,7 +10,6 @@ import ( mmmodel "github.com/mattermost/mattermost/server/public/model" - "github.com/mattermost/mattermost-plugin-docs/server/app" "github.com/mattermost/mattermost-plugin-docs/server/model" "github.com/mattermost/mattermost-plugin-docs/server/store" ) @@ -186,7 +185,7 @@ func TestServiceMovePage_ErrorPaths(t *testing.T) { // A chain of MaxPageDepth pages: the deepest has MaxPageDepth-1 ancestors, so a leaf // moved under it would land at depth (MaxPageDepth-1)+2 = MaxPageDepth+1 > MaxPageDepth. - chain := buildPageChain(t, h.store, space.Id, channelID, userID, app.MaxPageDepth) + chain := buildPageChain(t, h.store, space.Id, channelID, userID, model.MaxPageDepth) deepest := chain[len(chain)-1] leaf := mustCreatePage(t, h.store, space.Id, channelID, userID, "") @@ -206,7 +205,7 @@ func TestServiceMovePage_ErrorPaths(t *testing.T) { // Anchor at depth MaxPageDepth-1 (MaxPageDepth-2 ancestors): a moved page lands at // (MaxPageDepth-2)+2 = MaxPageDepth (passes the per-page check), but its one child would // be one deeper, MaxPageDepth+1, tripping the subtree check. - chain := buildPageChain(t, h.store, space.Id, channelID, userID, app.MaxPageDepth-1) + chain := buildPageChain(t, h.store, space.Id, channelID, userID, model.MaxPageDepth-1) anchor := chain[len(chain)-1] subtreeRoot := mustCreatePage(t, h.store, space.Id, channelID, userID, "") diff --git a/server/app/page_move_to_space_test.go b/server/app/page_move_to_space_test.go index c4e3a4a..a58c6cb 100644 --- a/server/app/page_move_to_space_test.go +++ b/server/app/page_move_to_space_test.go @@ -12,7 +12,6 @@ import ( mmmodel "github.com/mattermost/mattermost/server/public/model" "github.com/mattermost/mattermost/server/public/plugin/plugintest" - "github.com/mattermost/mattermost-plugin-docs/server/app" "github.com/mattermost/mattermost-plugin-docs/server/internal/testutil" "github.com/mattermost/mattermost-plugin-docs/server/model" "github.com/mattermost/mattermost-plugin-docs/server/store" @@ -206,7 +205,7 @@ func TestServiceMovePageToSpace_RejectsDepthExceeded(t *testing.T) { // Build a chain in spaceB down to MaxPageDepth; a child under the deepest node would breach it. parentID := "" - for range app.MaxPageDepth { + for range model.MaxPageDepth { p := mustCreatePage(t, h.store, spaceB.Id, chB, user, parentID) parentID = p.Id } diff --git a/server/app/service_test.go b/server/app/service_test.go index dfdd633..cb2e60b 100644 --- a/server/app/service_test.go +++ b/server/app/service_test.go @@ -474,7 +474,7 @@ func TestServiceCreatePage(t *testing.T) { // Build a full-depth chain (root at depth 1 up to MaxPageDepth); the next // child would be at depth MaxPageDepth+1 and must be rejected. parentID := "" - for range app.MaxPageDepth { + for range model.MaxPageDepth { p, err := h.svc.CreatePage(depthSpace.Id, parentID, "d", "", userID) require.Nil(t, err) parentID = p.Id diff --git a/server/model/page.go b/server/model/page.go index ab840ef..2ceae78 100644 --- a/server/model/page.go +++ b/server/model/page.go @@ -22,6 +22,11 @@ const ( // so this is a storage safety bound against unbounded writes. PageBodyMaxBytes = 2 * 1024 * 1024 + // MaxPageDepth is the page hierarchy depth limit (root is depth 1). It also bounds draft + // nesting: a draft chain publishes into a page chain of the same depth, so the store's + // draft-cycle walk is capped at the same value. + MaxPageDepth = 10 + // PagePropsMaxBytes caps the serialized size of the opaque Props map. PagePropsMaxBytes = 64 * 1024 diff --git a/server/store/draft_store.go b/server/store/draft_store.go index 239f26b..eb37666 100644 --- a/server/store/draft_store.go +++ b/server/store/draft_store.go @@ -16,12 +16,6 @@ import ( "github.com/mattermost/mattermost-plugin-docs/server/model" ) -// DraftCycleCheckMaxDepth bounds the parent-chain walk in checkNoDraftCycle and the nested-draft -// cascade in rewriteSubtreeSpace. It must equal the app layer's page-depth cap (app.MaxPageDepth), -// since a draft chain publishes into a page chain of the same depth; app asserts that equality at -// compile time so the two constants cannot drift. -const DraftCycleCheckMaxDepth = 10 - // MaxDraftsPerUserPerSpace is the maximum number of draft rows a single user may hold in one // space. Enforced atomically inside UpsertDraft after the space lock, so it holds under // concurrent creates. @@ -143,7 +137,7 @@ func (s *Store) draftExistsTx(tx *sqlx.Tx, userID, pageID string) (bool, error) // checkNoDraftCycle walks the parent chain from startParentID through the caller's draft rows and // returns an error if leafPageID appears anywhere in the chain (cycle) or if the total depth -// (draft chain + live-page ancestor) would exceed DraftCycleCheckMaxDepth. A published-page +// (draft chain + live-page ancestor) would exceed model.MaxPageDepth. A published-page // ancestor (no matching draft row) terminates the recursion early. Squirrel cannot express // recursive CTEs, so raw SQL is used here. func (s *Store) checkNoDraftCycle(tx *sqlx.Tx, userID, leafPageID, startParentID string) error { @@ -170,7 +164,7 @@ SELECT AND NOT EXISTS (SELECT 1 FROM DOCS_Draft d2 WHERE d2.UserId = $2 AND d2.PageId = c.node) ORDER BY c.depth DESC LIMIT 1 ), '') AS live_ancestor -FROM chain`, DraftCycleCheckMaxDepth, DraftCycleCheckMaxDepth) +FROM chain`, model.MaxPageDepth, model.MaxPageDepth) var result struct { IsCycle bool `db:"is_cycle"` @@ -195,7 +189,7 @@ FROM chain`, DraftCycleCheckMaxDepth, DraftCycleCheckMaxDepth) return errors.Wrap(err, "cycle check: failed to read live ancestor depth") } // liveDepth counts the ancestor itself; +1 for the new leaf being validated. - if liveDepth+result.ChainDepth+1 > DraftCycleCheckMaxDepth { + if liveDepth+result.ChainDepth+1 > model.MaxPageDepth { return &ErrInvalidInput{Entity: "Draft", Field: "ParentId", Value: startParentID, Reason: ReasonDraftTooDeep} } } diff --git a/server/store/page_move.go b/server/store/page_move.go index 767afb0..b403317 100644 --- a/server/store/page_move.go +++ b/server/store/page_move.go @@ -440,7 +440,7 @@ func (s *Store) rewriteSubtreeSpace(tx *sqlx.Tx, ids []string, sourceSpaceID, ta // them won't exceed MaxDraftsPerUserPerSpace in the target. This count is a lower bound for // the total re-homed set (the cascade loop below can pick up transitively nested new-page // drafts), so a failure here is correct, but a pass does not guarantee the cascade is safe; - // the cascade is bounded by DraftCycleCheckMaxDepth and the count remains low in practice. + // the cascade is bounded by model.MaxPageDepth and the count remains low in practice. // // Only the mover is quota-checked. Other users' re-homed drafts can push them past the cap in // the target space, which is accepted: the cap is a soft storage bound, and re-homing moves @@ -522,9 +522,9 @@ func (s *Store) rewriteSubtreeSpace(tx *sqlx.Tx, ids []string, sourceSpaceID, ta // draft A's PageId, not a live page). The chunk loop above matched only drafts whose ParentId // was a live moved page; draft B is caught here. Draft nesting is same-owner only, so the join // pairs each draft with its parent on UserId rather than singling out the mover. Loop until - // stable, bounded by DraftCycleCheckMaxDepth which caps the draft tree depth. + // stable, bounded by model.MaxPageDepth which caps the draft tree depth. // Squirrel cannot express UPDATE … FROM …, so the statement is built directly. - for range DraftCycleCheckMaxDepth { + for range model.MaxPageDepth { result, e := s.exec(tx, ` UPDATE DOCS_Draft d SET SpaceId = $1, UpdateAt = GREATEST(d.UpdateAt + 1, $2), LastActiveAt = 0 diff --git a/server/store/store_test.go b/server/store/store_test.go index fb13dcd..077f2f4 100644 --- a/server/store/store_test.go +++ b/server/store/store_test.go @@ -485,7 +485,7 @@ func TestFetchDescendantRows(t *testing.T) { } func TestDepthBoundaryExact(t *testing.T) { - const maxDepth = 10 // mirrors app.MaxPageDepth; the store CTE uses 50 + const maxDepth = model.MaxPageDepth // the store CTE uses the larger MaxPageHierarchyDepth (50) s := openTestDB(t) @@ -2066,7 +2066,7 @@ func TestDraft(t *testing.T) { // TestDraft/"upsert rejects a draft whose parent chain exceeds the max depth" exercises // checkNoDraftCycle's too-deep branch. Each draft added to the chain is itself parent-chain - // validated, so a chain of exactly DraftCycleCheckMaxDepth new-page drafts is the deepest one + // validated, so a chain of exactly model.MaxPageDepth new-page drafts is the deepest one // that can be built without tripping the cap; a further draft parented under the deepest one // is rejected as too deep. t.Run("upsert rejects a draft whose parent chain exceeds the max depth", func(t *testing.T) { @@ -2076,7 +2076,7 @@ func TestDraft(t *testing.T) { userID := mmmodel.NewId() parentID := "" - for range store.DraftCycleCheckMaxDepth { + for range model.MaxPageDepth { pageID := mmmodel.NewId() var parentParam *string if parentID != "" { From f12424eaecd0cb412d5f4a74bbcab5a017875c2c Mon Sep 17 00:00:00 2001 From: "Catalin I. Tomai" Date: Tue, 21 Jul 2026 15:02:22 +0200 Subject: [PATCH 27/36] Add Props support and ID validation to draft publish/upsert --- assets/i18n/en.json | 16 + server/api_page_drafts.go | 44 +- server/api_page_drafts_test.go | 132 +++++- server/app/page_content.go | 7 +- server/app/page_draft.go | 57 ++- server/app/page_draft_test.go | 237 +++++++---- server/app/page_presence.go | 2 +- server/app/ws_events_test.go | 18 +- server/model/draft.go | 52 +-- server/model/draft_test.go | 128 +----- server/model/page.go | 2 +- server/model/page_content.go | 90 +++- server/model/page_content_test.go | 39 ++ server/model/props.go | 4 +- server/model/props_test.go | 21 +- server/model/space.go | 2 +- server/store/draft_store.go | 52 ++- .../000005_add_draft_lastactiveat.up.sql | 4 - ...dd_draft_lastactiveat_baseeditat.down.sql} | 1 + ...5_add_draft_lastactiveat_baseeditat.up.sql | 10 + server/store/page_move_test.go | 28 +- server/store/page_store.go | 6 +- server/store/store_test.go | 396 ++++++++++++++---- 23 files changed, 941 insertions(+), 407 deletions(-) delete mode 100644 server/store/migrations/000005_add_draft_lastactiveat.up.sql rename server/store/migrations/{000005_add_draft_lastactiveat.down.sql => 000005_add_draft_lastactiveat_baseeditat.down.sql} (50%) create mode 100644 server/store/migrations/000005_add_draft_lastactiveat_baseeditat.up.sql diff --git a/assets/i18n/en.json b/assets/i18n/en.json index a224308..9be09c7 100644 --- a/assets/i18n/en.json +++ b/assets/i18n/en.json @@ -291,6 +291,18 @@ "id": "app.page_draft.publish.edit_conflict.app_error", "translation": "Someone else edited this page while you were writing. Reopen the page to see their changes, then publish again." }, + { + "id": "app.page_draft.publish.invalid_page_id.app_error", + "translation": "Invalid page ID." + }, + { + "id": "app.page_draft.publish.invalid_space_id.app_error", + "translation": "Invalid space ID." + }, + { + "id": "app.page_draft.publish.invalid_user_id.app_error", + "translation": "Invalid user ID." + }, { "id": "app.page_draft.publish.page_deleted.app_error", "translation": "The page was deleted and can no longer be published." @@ -515,6 +527,10 @@ "id": "app.store.too_large.app_error", "translation": "The result set is too large; narrow your request (limit {{.Limit}})." }, + { + "id": "model.draft.is_valid.base_edit_at.app_error", + "translation": "Invalid draft baseline edit time." + }, { "id": "model.draft.is_valid.body_size.app_error", "translation": "The draft body is too long." diff --git a/server/api_page_drafts.go b/server/api_page_drafts.go index c7b927d..9293a1e 100644 --- a/server/api_page_drafts.go +++ b/server/api_page_drafts.go @@ -18,7 +18,8 @@ const ( // quotes, backslashes, and control characters are escaped (worst case ~6x for all-control-char // input). Size the transport cap for that worst case plus headroom for the title/props/file-ids // and JSON envelope; the decoded body stays capped at model.PageBodyMaxBytes during normalization. - maxDraftBodyBytes = 6*model.PageBodyMaxBytes + (64 << 10) // 64 KiB headroom + draftBodyHeadroomBytes = 64 * 1024 // headroom for the title/props/file-ids and JSON envelope + maxDraftBodyBytes = 6*model.PageBodyMaxBytes + draftBodyHeadroomBytes ) // handleUpdatePageDraft handles PATCH /api/v1/spaces/{space_id}/pages/{page_id}/draft @@ -28,8 +29,8 @@ const ( // clears it. // // For existing published pages, the first request creates the draft (open an edit session). The client -// must include original_page_edit_at in props — the page's EditAt at the moment the user opened -// it — so a subsequent publish can detect a concurrent edit. +// must send the top-level base_edit_at field — the page's EditAt at the moment the user opened it — on +// every autosave, so a subsequent publish can detect a concurrent edit. It is not a props key. // // For new-page drafts (no page row yet), the draft must already exist via POST // /spaces/{space_id}/drafts. This prevents a space member who learns another user's pending page @@ -46,28 +47,37 @@ func (p *Plugin) handleUpdatePageDraft(w http.ResponseWriter, r *http.Request) { } var req struct { - ParentId *string `json:"parent_id"` - Title string `json:"title"` - Body string `json:"body"` - FileIds *mmmodel.StringArray `json:"file_ids"` - Props mmmodel.StringInterface `json:"props"` + ParentId *string `json:"parent_id"` + Title string `json:"title"` + Body string `json:"body"` + FileIds *mmmodel.StringArray `json:"file_ids"` + Props *mmmodel.StringInterface `json:"props"` + BaseEditAt *int64 `json:"base_edit_at"` } if !p.decodeJSONBody(w, r, maxDraftBodyBytes, &req, "handleUpdatePageDraft", false) { return } + // base_edit_at nil → 0 (no baseline: a new-page draft, or an existing-page edit that omitted it, + // which fails closed to a forced publish). Props flows via the pointer below (like file_ids), so it + // is not set on the struct here. + var baseEditAt int64 + if req.BaseEditAt != nil { + baseEditAt = *req.BaseEditAt + } draft := &model.Draft{ - UserId: userID, - SpaceId: spaceID, - PageId: pageID, - Title: req.Title, - Body: req.Body, - Props: req.Props, + UserId: userID, + SpaceId: spaceID, + PageId: pageID, + Title: req.Title, + Body: req.Body, + BaseEditAt: baseEditAt, } // req.ParentId nil → preserve; pointer to "" → clear to root; pointer to ID → set parent. // req.FileIds nil → preserve; pointer to [] → clear; pointer to [...] → replace. - saved, appErr := p.service.UpdatePageDraft(draft, req.ParentId, req.FileIds, space.ChannelId) + // req.Props nil → preserve; non-nil → replace the whole map (an empty map clears all keys). + saved, appErr := p.service.UpdatePageDraft(draft, req.ParentId, req.FileIds, req.Props, space.ChannelId) if appErr != nil { p.writeAppError(w, appErr) return @@ -150,8 +160,8 @@ func (p *Plugin) handleCreateSpaceDraft(w http.ResponseWriter, r *http.Request) // one transaction. // // The optimistic-lock baseline for an edit-publish is not a field on this request: it travels with -// the draft, captured once (as the original_page_edit_at prop) when editing began and carried by the -// autosave requests. This differs from the per-request base_edit_at on handleUpdatePage (and +// the draft, stored in its write-once BaseEditAt column (sent as the top-level base_edit_at field on +// the autosave requests). This differs from the per-request base_edit_at on handleUpdatePage (and // expected_update_at on handleMovePage) because a publish ships whatever the draft already holds // rather than re-supplying a freshly-read baseline. func (p *Plugin) handlePublishPageDraft(w http.ResponseWriter, r *http.Request) { diff --git a/server/api_page_drafts_test.go b/server/api_page_drafts_test.go index a08aef8..5001585 100644 --- a/server/api_page_drafts_test.go +++ b/server/api_page_drafts_test.go @@ -174,9 +174,9 @@ func TestHandler_UpdatePageDraftClearsParentWhenParentIdIsEmptyString(t *testing } // TestHandler_UpdatePageDraftCreatesForExistingPage drives the existing-page edit flow: publish a -// new-page draft to get a live page, then open an edit session by PUT .../draft with the page's -// EditAt baseline in props. Verifies the draft is created, autosave updates it, publish succeeds -// (edit path → 200), and the draft is gone afterwards. +// new-page draft to get a live page, then open an edit session by PATCH .../draft with the page's +// EditAt baseline in the top-level base_edit_at field. Verifies the draft is created, autosave +// updates it, publish succeeds (edit path → 200), and the draft is gone afterwards. func TestHandler_UpdatePageDraftCreatesForExistingPage(t *testing.T) { h := openTestPlugin(t, nil) channelID := mmmodel.NewId() @@ -199,15 +199,21 @@ func TestHandler_UpdatePageDraftCreatesForExistingPage(t *testing.T) { // Step 2: open an edit session — first PUT creates the draft for an existing page. rec = h.do(t, http.MethodPatch, base+"/pages/"+pageID+"/draft", userID, map[string]any{ - "title": "Original", - "props": mmmodel.StringInterface{model.DraftPropsOriginalPageEditAt: page.EditAt}, + "title": "Original", + "base_edit_at": page.EditAt, }) require.Equal(t, http.StatusOK, rec.Code) var editDraft model.Draft require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &editDraft)) require.Equal(t, pageID, editDraft.PageId) - _, hasBaseline := editDraft.EditBaseline() - require.True(t, hasBaseline, "draft must carry the original_page_edit_at baseline so publish can detect conflicts") + require.Equal(t, page.EditAt, editDraft.BaseEditAt, "draft must carry the base_edit_at baseline so publish can detect conflicts") + + // GET must round-trip the same baseline. + rec = h.do(t, http.MethodGet, base+"/pages/"+pageID+"/draft", userID, nil) + require.Equal(t, http.StatusOK, rec.Code) + var fetched model.Draft + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &fetched)) + require.Equal(t, page.EditAt, fetched.BaseEditAt, "GET must return the base_edit_at baseline that was set via PATCH") // Step 3: autosave new content. rec = h.do(t, http.MethodPatch, base+"/pages/"+pageID+"/draft", userID, map[string]any{ @@ -254,14 +260,14 @@ func TestHandler_PublishConflict409(t *testing.T) { // User A and user B both open edit sessions against the same baseline. rec = h.do(t, http.MethodPatch, base+"/pages/"+pageID+"/draft", userA, map[string]any{ - "title": "Edit by A", - "props": mmmodel.StringInterface{model.DraftPropsOriginalPageEditAt: editAt}, + "title": "Edit by A", + "base_edit_at": editAt, }) require.Equal(t, http.StatusOK, rec.Code) rec = h.do(t, http.MethodPatch, base+"/pages/"+pageID+"/draft", userB, map[string]any{ - "title": "Edit by B", - "props": mmmodel.StringInterface{model.DraftPropsOriginalPageEditAt: editAt}, + "title": "Edit by B", + "base_edit_at": editAt, }) require.Equal(t, http.StatusOK, rec.Code) @@ -356,8 +362,8 @@ func TestHandler_ActiveEditorsResponseBody(t *testing.T) { // Open an edit draft — the user must now appear as an active editor. rec = h.do(t, http.MethodPatch, base+"/pages/"+pageID+"/draft", userID, map[string]any{ - "title": "Presence Test", - "props": mmmodel.StringInterface{model.DraftPropsOriginalPageEditAt: page.EditAt}, + "title": "Presence Test", + "base_edit_at": page.EditAt, }) require.Equal(t, http.StatusOK, rec.Code) @@ -366,3 +372,103 @@ func TestHandler_ActiveEditorsResponseBody(t *testing.T) { require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) require.Contains(t, resp.ActiveEditors, userID, "user with an open edit draft must appear as an active editor") } + +// TestHandler_UpdatePageDraftPropsPointerIntent covers the props pointer-intent semantics over the +// HTTP boundary: an absent props field preserves the stored map, an explicit null behaves the same +// as absent, an empty object clears all keys, and a populated object replaces the whole map. +func TestHandler_UpdatePageDraftPropsPointerIntent(t *testing.T) { + h := openTestPlugin(t, nil) + space := seedSpace(t, h.store, mmmodel.NewId()) + userID := mmmodel.NewId() + base := "/api/v1/spaces/" + space.Id + + rec := h.do(t, http.MethodPost, base+"/drafts", userID, map[string]any{"title": "Props Test"}) + require.Equal(t, http.StatusCreated, rec.Code) + var draft model.Draft + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &draft)) + draftPath := base + "/pages/" + draft.PageId + "/draft" + + // Populate props. + rec = h.do(t, http.MethodPatch, draftPath, userID, map[string]any{ + "title": "Props Test", + "props": map[string]any{"color": "red"}, + }) + require.Equal(t, http.StatusOK, rec.Code) + var populated model.Draft + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &populated)) + require.Equal(t, "red", populated.Props["color"]) + + // props absent must preserve the existing map. Each step below unmarshals into a fresh Draft + // value: reusing one across json.Unmarshal calls would merge into the existing non-nil map + // instead of replacing it, masking whether the server actually cleared/replaced the props. + rec = h.do(t, http.MethodPatch, draftPath, userID, map[string]any{ + "title": "Props Test Updated", + }) + require.Equal(t, http.StatusOK, rec.Code) + var afterAbsent model.Draft + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &afterAbsent)) + require.Equal(t, "red", afterAbsent.Props["color"], "omitting props must preserve the existing map") + + // props: null must also preserve the existing map — it unmarshals to a nil pointer, same as + // omitting the field entirely. + rec = h.do(t, http.MethodPatch, draftPath, userID, map[string]any{ + "props": nil, + }) + require.Equal(t, http.StatusOK, rec.Code) + var afterNull model.Draft + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &afterNull)) + require.Equal(t, "red", afterNull.Props["color"], "props: null must preserve the existing map, same as omitting it") + + // props: {} must clear all keys. + rec = h.do(t, http.MethodPatch, draftPath, userID, map[string]any{ + "props": map[string]any{}, + }) + require.Equal(t, http.StatusOK, rec.Code) + var afterClear model.Draft + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &afterClear)) + require.Empty(t, afterClear.Props, "props: {} must clear all keys") + + // props populated again must replace the (now-empty) map. + rec = h.do(t, http.MethodPatch, draftPath, userID, map[string]any{ + "props": map[string]any{"size": "large"}, + }) + require.Equal(t, http.StatusOK, rec.Code) + var afterReplace model.Draft + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &afterReplace)) + require.Equal(t, "large", afterReplace.Props["size"]) + require.NotContains(t, afterReplace.Props, "color", "props replace must drop keys not present in the new map") +} + +// TestHandler_UpdatePageDraftPropsBaselineNoLongerHonored covers the removal of the old +// props.original_page_edit_at baseline convention: placing the value only under that props key must +// NOT establish an optimistic-lock baseline. Since a baseline-less edit-open against a live page is +// itself rejected (the store refuses to create a baseline-less edit draft for an existing page — see +// TestPublishRejectsNoBaselineOnExistingPageEdit in server/app/page_draft_test.go), the PATCH must +// fail with the same conflict the client would get if it had supplied no baseline at all. +func TestHandler_UpdatePageDraftPropsBaselineNoLongerHonored(t *testing.T) { + h := openTestPlugin(t, nil) + channelID := mmmodel.NewId() + space := seedSpace(t, h.store, channelID) + userID := mmmodel.NewId() + base := "/api/v1/spaces/" + space.Id + + rec := h.do(t, http.MethodPost, base+"/drafts", userID, map[string]any{"title": "Doc"}) + require.Equal(t, http.StatusCreated, rec.Code) + var draft model.Draft + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &draft)) + pageID := draft.PageId + + rec = h.do(t, http.MethodPost, base+"/pages/"+pageID+"/draft/publish", userID, nil) + require.Equal(t, http.StatusCreated, rec.Code) + var page model.Page + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &page)) + + // Open an edit session but put the baseline only under the old props key, not the top-level + // base_edit_at field. This must be rejected with 409, exactly as if base_edit_at had been + // omitted entirely (proving the props key carries no special meaning any more). + rec = h.do(t, http.MethodPatch, base+"/pages/"+pageID+"/draft", userID, map[string]any{ + "title": "Edited", + "props": map[string]any{"original_page_edit_at": page.EditAt}, + }) + require.Equal(t, http.StatusConflict, rec.Code, "props.original_page_edit_at must not be honored as a baseline") +} diff --git a/server/app/page_content.go b/server/app/page_content.go index dfeb55c..3c21e25 100644 --- a/server/app/page_content.go +++ b/server/app/page_content.go @@ -86,9 +86,10 @@ func marshalTipTapDoc(doc model.TipTapDocument) (string, string, error) { return string(sanitized), model.BuildSearchText(doc), nil } -// maxPlainTextParagraphs caps the number of paragraph nodes produced when wrapping plain text. -// A newline-only body at PageBodyMaxBytes would otherwise produce ~2M maps before the -// post-normalization body-size check could reject the output. +// maxPlainTextParagraphs caps the number of paragraph nodes produced when converting plain text +// to a TipTap document. convertPlainTextToTipTap splits on newlines and turns each line into its +// own paragraph node (a map[string]any), so a newline-only body at PageBodyMaxBytes would +// otherwise build ~2M such maps before the post-normalization body-size check could reject it. const maxPlainTextParagraphs = 10_000 // convertPlainTextToTipTap wraps plain text in a minimal TipTap document. Returns an error when diff --git a/server/app/page_draft.go b/server/app/page_draft.go index 19f8ab5..eff50cf 100644 --- a/server/app/page_draft.go +++ b/server/app/page_draft.go @@ -29,7 +29,9 @@ func presenceBroadcastKey(pageID, userID string) string { // heartbeats cannot clobber each other's changes. // parentID encodes the write intent for ParentId: nil preserves the stored value, a pointer to "" // clears to root, and a pointer to a valid ID sets the parent. See store.UpsertDraft for details. -func (s *Service) UpdatePageDraft(draft *model.Draft, parentID *string, fileIDs *mmmodel.StringArray, channelID string) (*model.Draft, *mmmodel.AppError) { +// props encodes the write intent for Props: nil preserves the stored map, a non-nil pointer replaces +// it wholesale (an empty map clears all keys); its serialized size is validated here. +func (s *Service) UpdatePageDraft(draft *model.Draft, parentID *string, fileIDs *mmmodel.StringArray, props *mmmodel.StringInterface, channelID string) (*model.Draft, *mmmodel.AppError) { if draft == nil { return nil, mmmodel.NewAppError("UpdatePageDraft", "app.page_draft.update.nil_draft.app_error", nil, "", http.StatusBadRequest) } @@ -65,9 +67,14 @@ func (s *Service) UpdatePageDraft(draft *model.Draft, parentID *string, fileIDs draft.Body = sanitizedBody } - // Only the optimistic-lock baseline is a recognized prop; drop anything else the client sent so - // it cannot accumulate in the stored map, which the store merges into rather than replaces. - draft.SanitizeProps() + // Validate the written Props size here: props is passed to the store separately (pointer intent), + // so it is not the draft.Props field IsValid checks — without this the PagePropsMaxBytes bound + // would be silently bypassed on the write path. Mirrors the fileIDs size guard below. + if props != nil { + if propsErr := model.ValidatePropsSize("UpdatePageDraft", "page_id="+draft.PageId, *props, model.PagePropsMaxBytes); propsErr != nil { + return nil, propsErr + } + } // Validate fileIDs size here because fileIDs is passed to the store separately and is not placed // into draft.FileIds before IsValid runs — the store's UpsertDraft merges it in SQL. @@ -84,8 +91,9 @@ func (s *Service) UpdatePageDraft(draft *model.Draft, parentID *string, fileIDs } } - // pageIsLiveResolved tracks whether we already know the live-page answer from the - // not-found branch below (so we don't issue a second PageExistsInSpace after the upsert). + // pageIsLiveResolved is true when the not-found branch below has already + // checked whether a live page exists in the space. The post-upsert check + // reads this flag to skip calling PageExistsInSpace a second time. pageIsLive := false pageIsLiveResolved := false @@ -111,7 +119,7 @@ func (s *Service) UpdatePageDraft(draft *model.Draft, parentID *string, fileIDs } } - saved, savedPageWasLive, err := s.store.UpsertDraft(draft, parentID, fileIDs) + saved, savedPageWasLive, err := s.store.UpsertDraft(draft, parentID, fileIDs, props) if err != nil { switch store.ConflictReason(err) { case store.ReasonConcurrentEdit: @@ -198,7 +206,7 @@ func (s *Service) CreateSpaceDraft(userID, spaceID, title, pageParentID string) // UpdatePageDraft's guard — which rejects drafts for non-existent pages on the autosave // path — would incorrectly block this call. The page is never live here, so the liveness // flag is discarded. - saved, _, err := s.store.UpsertDraft(draft, parentPtr, nil) + saved, _, err := s.store.UpsertDraft(draft, parentPtr, nil, nil) if err != nil { // Translate hierarchy errors with create-specific keys so the client receives an // appropriate message. invalidInputAppError maps these to update.* keys, which don't @@ -362,6 +370,15 @@ func (s *Service) GetPageDraftsForSpace(userID, spaceID string, page, perPage in // - wasCreated=true → a new page was inserted by this call (handler should return 201) // - wasCreated=false → an existing page was updated, or a concurrent create was adopted (return 200) func (s *Service) PublishPageDraft(userID, spaceID, pageID string, force bool) (*model.Page, bool, *mmmodel.AppError) { + if !mmmodel.IsValidId(userID) { + return nil, false, mmmodel.NewAppError("PublishPageDraft", "app.page_draft.publish.invalid_user_id.app_error", nil, "", http.StatusBadRequest) + } + if !mmmodel.IsValidId(spaceID) { + return nil, false, mmmodel.NewAppError("PublishPageDraft", "app.page_draft.publish.invalid_space_id.app_error", nil, "", http.StatusBadRequest) + } + if !mmmodel.IsValidId(pageID) { + return nil, false, mmmodel.NewAppError("PublishPageDraft", "app.page_draft.publish.invalid_page_id.app_error", nil, "", http.StatusBadRequest) + } s.log.Debug("Publishing page draft", "space_id", spaceID, "page_id", pageID, "user_id", userID, "force", force) // 1. Fetch draft (idempotency guard: 404 = draft already published or discarded). draft, appErr := s.GetPageDraft(userID, spaceID, pageID) @@ -415,7 +432,12 @@ func (s *Service) PublishPageDraft(userID, spaceID, pageID string, force bool) ( return nil, false, contentErr } - // 5. Build the *model.Page for the store call. + // 5. Build the *model.Page for the store call. Props follow the same write intent as + // PagePatch.Props: a new page adopts the draft's props outright, while an edit replaces the + // live page's props only when the draft carries a non-empty map and preserves them otherwise. + // (A bare Draft.Props map cannot express "clear to empty" distinctly from "unset", so an empty + // draft map means preserve, consistent with how Title/Body are carried below.) No client sets + // page props through drafts today; this keeps the publish path ready for when one does. var pageForWrite *model.Page if isNewPage { title, titleErr := validateTitle("PublishPageDraft", draft.Title, model.PageTitleMaxRunes) @@ -431,13 +453,15 @@ func (s *Service) PublishPageDraft(userID, spaceID, pageID string, force bool) ( Title: title, Body: body, SearchText: searchText, + Props: draft.Props, UserId: userID, LastModifiedBy: userID, } } else { // Edit path: require an optimistic-lock baseline unless force, so a client that never // captured the page's EditAt cannot silently overwrite a concurrent edit. - baseEditAt, haveBaseline := draft.EditBaseline() + baseEditAt := draft.BaseEditAt + haveBaseline := baseEditAt != 0 if !force && !haveBaseline { return nil, false, mmmodel.NewAppError("PublishPageDraft", "app.page_draft.publish.baseline_required.app_error", nil, "", http.StatusBadRequest) @@ -464,15 +488,18 @@ func (s *Service) PublishPageDraft(userID, spaceID, pageID string, force bool) ( pageForWrite.Body = body pageForWrite.SearchText = searchText } + if len(draft.Props) > 0 { + pageForWrite.Props = draft.Props + } if haveBaseline { pageForWrite.EditAt = baseEditAt } - // A draft that carries only an optimistic-lock baseline — no Title, no Body — has no page - // content to write. Publishing it would bump EditAt and emit page_updated with no actual + // A draft that carries only an optimistic-lock baseline — no Title, no Body, no Props — has no + // page change to write. Publishing it would bump EditAt and emit page_updated with no actual // change, invalidating other editors' baselines for nothing. Treat it as a discard instead: // delete the draft and return the page as-is. - if pageForWrite.Title == "" && pageForWrite.Body == "" { + if pageForWrite.Title == "" && pageForWrite.Body == "" && len(pageForWrite.Props) == 0 { deleted, delErr := s.store.DeleteDraftVersion(userID, pageID, draft.UpdateAt) if delErr != nil { return nil, false, storeAppError("PublishPageDraft", delErr) @@ -523,7 +550,7 @@ func (s *Service) PublishPageDraft(userID, spaceID, pageID string, force bool) ( // Best-effort: the page is already published by the winner, so a failure here is // logged (a stray draft the user can discard), never surfaced as a publish failure. if _, delErr := s.store.DeleteDraftVersion(userID, pageID, draft.UpdateAt); delErr != nil { - s.log.Warn("PublishPageDraft: failed to delete orphaned draft after adopting race winner", + s.log.Warn("failed to delete orphaned draft after adopting race winner", "page_id", pageID, "user_id", userID, "err", delErr) } // The draft is consumed; clear the rate-limit entry and broadcast presence so @@ -533,7 +560,7 @@ func (s *Service) PublishPageDraft(userID, spaceID, pageID string, force bool) ( } if rErr != nil { // Log this: a real store failure here would otherwise look identical to losing the race. - s.log.Warn("PublishPageDraft: failed to read the page that won the publish race", + s.log.Warn("failed to read the page that won the publish race", "page_id", pageID, "user_id", userID, "err", rErr) } } diff --git a/server/app/page_draft_test.go b/server/app/page_draft_test.go index 47ae913..8d99b11 100644 --- a/server/app/page_draft_test.go +++ b/server/app/page_draft_test.go @@ -30,7 +30,7 @@ func publishNewPage(t *testing.T, h *testHarness, spaceID, userID, title, bodyTe require.Nil(t, appErr) reservedID := draft.PageId - _, appErr = h.svc.UpdatePageDraft(&model.Draft{UserId: userID, SpaceId: spaceID, PageId: reservedID, Title: title, Body: docWith(bodyText)}, nil, nil, "") + _, appErr = h.svc.UpdatePageDraft(&model.Draft{UserId: userID, SpaceId: spaceID, PageId: reservedID, Title: title, Body: docWith(bodyText)}, nil, nil, nil, "") require.Nil(t, appErr) page, wasCreated, appErr := h.svc.PublishPageDraft(userID, spaceID, reservedID, false) @@ -51,11 +51,11 @@ func TestUpdatePageDraftPreservesBodyOnTitleOnlyAutosave(t *testing.T) { pageID := draft.PageId // Autosave real content. - _, appErr = h.svc.UpdatePageDraft(&model.Draft{UserId: userID, SpaceId: space.Id, PageId: pageID, Title: "Doc", Body: docWith("keep me")}, nil, nil, "") + _, appErr = h.svc.UpdatePageDraft(&model.Draft{UserId: userID, SpaceId: space.Id, PageId: pageID, Title: "Doc", Body: docWith("keep me")}, nil, nil, nil, "") require.Nil(t, appErr) // A heartbeat that sends only the title (empty body) must not wipe the stored draft body. - saved, appErr := h.svc.UpdatePageDraft(&model.Draft{UserId: userID, SpaceId: space.Id, PageId: pageID, Title: "Doc"}, nil, nil, "") + saved, appErr := h.svc.UpdatePageDraft(&model.Draft{UserId: userID, SpaceId: space.Id, PageId: pageID, Title: "Doc"}, nil, nil, nil, "") require.Nil(t, appErr) require.Contains(t, saved.Body, "keep me", "a title-only autosave must not clear the draft body") } @@ -71,8 +71,8 @@ func TestPublishEmptyDraftBodyDoesNotWipePage(t *testing.T) { // the heartbeat case that previously wiped the page on publish. _, appErr := h.svc.UpdatePageDraft(&model.Draft{ UserId: userID, SpaceId: space.Id, PageId: page.Id, Title: "Important", - Props: mmmodel.StringInterface{model.DraftPropsOriginalPageEditAt: float64(page.EditAt)}, - }, nil, nil, "") + BaseEditAt: page.EditAt, + }, nil, nil, nil, "") require.Nil(t, appErr) republished, _, appErr := h.svc.PublishPageDraft(userID, space.Id, page.Id, false) @@ -81,7 +81,7 @@ func TestPublishEmptyDraftBodyDoesNotWipePage(t *testing.T) { } // TestPublishNoOpDraftDiscardsAndReturnsExistingPage verifies that publishing a draft that carries -// no content change — only the optimistic-lock baseline prop, no Title, no Body — is treated as a +// no content change — only the optimistic-lock baseline, no Title, no Body — is treated as a // discard rather than a no-op page write: the draft is deleted, the existing page comes back // unchanged, and wasCreated is false. func TestPublishNoOpDraftDiscardsAndReturnsExistingPage(t *testing.T) { @@ -91,11 +91,11 @@ func TestPublishNoOpDraftDiscardsAndReturnsExistingPage(t *testing.T) { page := publishNewPage(t, h, space.Id, userID, "Doc", "original") - // Start an edit session whose only autosave carries the baseline prop — no Title, no Body. + // Start an edit session whose only autosave carries the baseline — no Title, no Body. _, appErr := h.svc.UpdatePageDraft(&model.Draft{ UserId: userID, SpaceId: space.Id, PageId: page.Id, - Props: mmmodel.StringInterface{model.DraftPropsOriginalPageEditAt: float64(page.EditAt)}, - }, nil, nil, "") + BaseEditAt: page.EditAt, + }, nil, nil, nil, "") require.Nil(t, appErr) result, wasCreated, appErr := h.svc.PublishPageDraft(userID, space.Id, page.Id, false) @@ -118,17 +118,17 @@ func TestPublishRejectsMissingBaselineOnEdit(t *testing.T) { page := publishNewPage(t, h, space.Id, userID, "Doc", "v1") // UpsertDraft guards the first edit-draft save: attempting to create a draft for an existing - // page without original_page_edit_at is rejected at the store layer, so the client must send + // page without a base_edit_at baseline is rejected at the store layer, so the client must send // the baseline on the very first autosave (the response tells it to reload and set it). - _, appErr := h.svc.UpdatePageDraft(&model.Draft{UserId: userID, SpaceId: space.Id, PageId: page.Id, Title: "Doc", Body: docWith("v2")}, nil, nil, "") + _, appErr := h.svc.UpdatePageDraft(&model.Draft{UserId: userID, SpaceId: space.Id, PageId: page.Id, Title: "Doc", Body: docWith("v2")}, nil, nil, nil, "") require.NotNil(t, appErr) require.Equal(t, http.StatusConflict, appErr.StatusCode) // With a proper baseline the draft is created; force=true publishes regardless of baseline. _, appErr = h.svc.UpdatePageDraft(&model.Draft{ UserId: userID, SpaceId: space.Id, PageId: page.Id, Title: "Doc", Body: docWith("v2"), - Props: mmmodel.StringInterface{model.DraftPropsOriginalPageEditAt: float64(page.EditAt)}, - }, nil, nil, "") + BaseEditAt: page.EditAt, + }, nil, nil, nil, "") require.Nil(t, appErr) forced, _, appErr := h.svc.PublishPageDraft(userID, space.Id, page.Id, true) @@ -136,6 +136,43 @@ func TestPublishRejectsMissingBaselineOnEdit(t *testing.T) { require.Contains(t, forced.Body, "v2") } +// TestPublishRejectsNoBaselineOnExistingPageEdit covers the app-level baseline_required guard in +// PublishPageDraft: a stored edit-draft with BaseEditAt still 0 (no baseline was ever captured) must +// be rejected with 400 unless force=true. The store's UpsertDraft guard normally blocks a +// baseline-less draft from ever being created against a live page (see +// TestPublishRejectsMissingBaselineOnEdit), so this simulates the race where the draft's first +// autosave lands as a new-page draft and the underlying page becomes live afterward without the +// draft ever acquiring a baseline — reached here by inserting the draft row directly. +func TestPublishRejectsNoBaselineOnExistingPageEdit(t *testing.T) { + h := openTestService(t) + channelID := mmmodel.NewId() + space := mustCreateSpace(t, h.store, channelID) + userID := mmmodel.NewId() + + page := mustCreatePage(t, h.store, space.Id, channelID, userID, "") + + now := mmmodel.GetMillis() + _, err := h.db.Exec( + `INSERT INTO docs_draft (userid,spaceid,pageid,parentid,title,body,fileids,props,createat,updateat,lastactiveat,baseeditat) + VALUES ($1,$2,$3,'',$4,$5,'[]','{}', $6,$6,$6,0)`, + userID, space.Id, page.Id, "Edited", docWith("v2"), now, + ) + require.NoError(t, err) + + // Without a baseline, a non-force publish of an edit must be rejected. + _, _, appErr := h.svc.PublishPageDraft(userID, space.Id, page.Id, false) + require.NotNil(t, appErr) + require.Equal(t, http.StatusBadRequest, appErr.StatusCode) + require.Equal(t, "app.page_draft.publish.baseline_required.app_error", appErr.Id) + + // force=true bypasses the missing-baseline guard and publishes the edit. + forced, wasCreated, appErr := h.svc.PublishPageDraft(userID, space.Id, page.Id, true) + require.Nil(t, appErr) + require.False(t, wasCreated) + require.Equal(t, "Edited", forced.Title) + require.Contains(t, forced.Body, "v2") +} + func TestPublishStaleBaselineConflicts(t *testing.T) { h := openTestService(t) space := mustCreateSpace(t, h.store, mmmodel.NewId()) @@ -148,8 +185,8 @@ func TestPublishStaleBaselineConflicts(t *testing.T) { // not consumed by any publish), so the stale-baseline conflict surfaces at publish time. _, appErr := h.svc.UpdatePageDraft(&model.Draft{ UserId: userID, SpaceId: space.Id, PageId: page.Id, Title: "Doc", Body: docWith("v3"), - Props: mmmodel.StringInterface{model.DraftPropsOriginalPageEditAt: float64(staleEditAt)}, - }, nil, nil, "") + BaseEditAt: staleEditAt, + }, nil, nil, nil, "") require.Nil(t, appErr) // A concurrent direct edit advances the page's EditAt out from under that baseline, without @@ -175,8 +212,8 @@ func TestPublishAfterPageDeleteReturns404(t *testing.T) { page := publishNewPage(t, h, space.Id, userID, "Doomed", "x") _, appErr := h.svc.UpdatePageDraft(&model.Draft{ UserId: userID, SpaceId: space.Id, PageId: page.Id, Title: "Doomed", Body: docWith("y"), - Props: mmmodel.StringInterface{model.DraftPropsOriginalPageEditAt: float64(page.EditAt)}, - }, nil, nil, "") + BaseEditAt: page.EditAt, + }, nil, nil, nil, "") require.Nil(t, appErr) requireStoreDeletePage(t, h.store, page.Id, space.Id, userID) @@ -233,8 +270,8 @@ func TestActiveEditorsSurfacesHeartbeat(t *testing.T) { // An autosave is the heartbeat; the editor must then appear as active. _, appErr := h.svc.UpdatePageDraft(&model.Draft{ UserId: userID, SpaceId: space.Id, PageId: page.Id, Title: "Doc", Body: docWith("editing"), - Props: mmmodel.StringInterface{model.DraftPropsOriginalPageEditAt: float64(page.EditAt)}, - }, nil, nil, "") + BaseEditAt: page.EditAt, + }, nil, nil, nil, "") require.Nil(t, appErr) snapshot, appErr := h.svc.GetPageActiveEditors(page.Id, space.Id) @@ -264,19 +301,20 @@ func TestPublishForceOverridesStaleBaseline(t *testing.T) { // Start an edit session with a draft baselined at the current EditAt; the draft persists. _, appErr := h.svc.UpdatePageDraft(&model.Draft{ UserId: userID, SpaceId: space.Id, PageId: page.Id, Title: "Doc", Body: docWith("v3"), - Props: mmmodel.StringInterface{model.DraftPropsOriginalPageEditAt: float64(staleEditAt)}, - }, nil, nil, "") + BaseEditAt: staleEditAt, + }, nil, nil, nil, "") require.Nil(t, appErr) // A concurrent direct edit advances the page's EditAt, making the draft's baseline stale. concurrent := docWith("concurrent") - _, appErr = h.svc.UpdatePage(page.Id, space.Id, &model.PagePatch{Body: &concurrent}, new(staleEditAt), false, userID) + concurrentPage, appErr := h.svc.UpdatePage(page.Id, space.Id, &model.PagePatch{Body: &concurrent}, new(staleEditAt), false, userID) require.Nil(t, appErr) // force=true must override the stale-baseline CAS and win with the draft's content. forced, _, appErr := h.svc.PublishPageDraft(userID, space.Id, page.Id, true) require.Nil(t, appErr) require.Contains(t, forced.Body, "v3", "force must override a stale baseline") + require.Greater(t, forced.EditAt, concurrentPage.EditAt, "a force-publish must still advance the page's EditAt") } func TestPublishForceDoesNotRevertUntouchedField(t *testing.T) { @@ -290,8 +328,8 @@ func TestPublishForceDoesNotRevertUntouchedField(t *testing.T) { // A title-only edit: the draft carries a new title but no body, baselined at the current EditAt. _, appErr := h.svc.UpdatePageDraft(&model.Draft{ UserId: userID, SpaceId: space.Id, PageId: page.Id, Title: "New title", - Props: mmmodel.StringInterface{model.DraftPropsOriginalPageEditAt: float64(baseEditAt)}, - }, nil, nil, "") + BaseEditAt: baseEditAt, + }, nil, nil, nil, "") require.Nil(t, appErr) // A concurrent edit changes the BODY — a field the draft never touched — and advances EditAt. @@ -318,8 +356,8 @@ func TestUpdatePageDraftRejectsStaleBaselineAfterPublish(t *testing.T) { // Edit and publish: the draft is consumed and the page's EditAt advances. _, appErr := h.svc.UpdatePageDraft(&model.Draft{ UserId: userID, SpaceId: space.Id, PageId: page.Id, Title: "Doc", Body: docWith("v2"), - Props: mmmodel.StringInterface{model.DraftPropsOriginalPageEditAt: float64(baseEditAt)}, - }, nil, nil, "") + BaseEditAt: baseEditAt, + }, nil, nil, nil, "") require.Nil(t, appErr) _, _, appErr = h.svc.PublishPageDraft(userID, space.Id, page.Id, false) require.Nil(t, appErr) @@ -328,8 +366,8 @@ func TestUpdatePageDraftRejectsStaleBaselineAfterPublish(t *testing.T) { // draft on the now-published page. _, appErr = h.svc.UpdatePageDraft(&model.Draft{ UserId: userID, SpaceId: space.Id, PageId: page.Id, Title: "Doc", Body: docWith("late"), - Props: mmmodel.StringInterface{model.DraftPropsOriginalPageEditAt: float64(baseEditAt)}, - }, nil, nil, "") + BaseEditAt: baseEditAt, + }, nil, nil, nil, "") require.NotNil(t, appErr) require.Equal(t, http.StatusConflict, appErr.StatusCode) @@ -339,7 +377,7 @@ func TestUpdatePageDraftRejectsStaleBaselineAfterPublish(t *testing.T) { require.Equal(t, http.StatusNotFound, appErr.StatusCode) } -func TestUpdatePageDraftMergesPropsPreservingBaseline(t *testing.T) { +func TestUpdatePageDraftPreservesPropsOnOmit(t *testing.T) { h := openTestService(t) space := mustCreateSpace(t, h.store, mmmodel.NewId()) userID := mmmodel.NewId() @@ -348,20 +386,89 @@ func TestUpdatePageDraftMergesPropsPreservingBaseline(t *testing.T) { require.Nil(t, appErr) pageID := draft.PageId - // First autosave records the optimistic-lock baseline prop. + // First autosave writes a props map (non-nil pointer → whole-value replace). saved, appErr := h.svc.UpdatePageDraft(&model.Draft{ UserId: userID, SpaceId: space.Id, PageId: pageID, Title: "Doc", Body: docWith("v1"), - Props: mmmodel.StringInterface{model.DraftPropsOriginalPageEditAt: float64(1234)}, - }, nil, nil, "") + }, nil, nil, &mmmodel.StringInterface{"custom": "v"}, "") require.Nil(t, appErr) - require.EqualValues(t, 1234, saved.Props[model.DraftPropsOriginalPageEditAt]) + require.Equal(t, "v", saved.Props["custom"]) - // A later autosave that omits props must preserve the stored baseline (key-wise merge, no clobber). + // A later autosave that omits props (nil pointer) must preserve the stored map. saved, appErr = h.svc.UpdatePageDraft(&model.Draft{ UserId: userID, SpaceId: space.Id, PageId: pageID, Title: "Doc", Body: docWith("v2"), - }, nil, nil, "") + }, nil, nil, nil, "") + require.Nil(t, appErr) + require.Equal(t, "v", saved.Props["custom"], "omitted props must preserve the stored map") + + // An explicit empty map (non-nil pointer) must clear the stored props. + saved, appErr = h.svc.UpdatePageDraft(&model.Draft{ + UserId: userID, SpaceId: space.Id, PageId: pageID, Title: "Doc", Body: docWith("v3"), + }, nil, nil, &mmmodel.StringInterface{}, "") + require.Nil(t, appErr) + require.Empty(t, saved.Props, "an explicit empty props map must clear the stored props") +} + +// A draft autosave whose (valid TipTap) body exceeds PageBodyMaxBytes must be rejected with 400 — +// the body is well-formed and small in node count, so it clears content normalization and is caught +// by Draft.IsValid's size guard in the store, surfaced through the app layer. +func TestUpdatePageDraftRejectsOversizedBody(t *testing.T) { + h := openTestService(t) + space := mustCreateSpace(t, h.store, mmmodel.NewId()) + userID := mmmodel.NewId() + + draft, appErr := h.svc.CreateSpaceDraft(userID, space.Id, "Doc", "") + require.Nil(t, appErr) + + // One paragraph, one text node whose text alone exceeds PageBodyMaxBytes — clears the node/ + // paragraph limits but overflows the serialized-body size cap. + oversized := docWith(strings.Repeat("x", model.PageBodyMaxBytes)) + _, appErr = h.svc.UpdatePageDraft(&model.Draft{ + UserId: userID, SpaceId: space.Id, PageId: draft.PageId, Title: "Doc", Body: oversized, + }, nil, nil, nil, "") + require.NotNil(t, appErr, "an oversized draft body must be rejected") + require.Equal(t, http.StatusBadRequest, appErr.StatusCode) +} + +// Props follow PagePatch.Props write intent on publish: a new page adopts the draft's props, an +// edit whose draft carries a non-empty props map replaces the live page's props, and an edit that +// carries no props preserves them. +func TestPublishCarriesDraftProps(t *testing.T) { + h := openTestService(t) + space := mustCreateSpace(t, h.store, mmmodel.NewId()) + userID := mmmodel.NewId() + + // New-page publish adopts the draft's props. + draft, appErr := h.svc.CreateSpaceDraft(userID, space.Id, "Doc", "") + require.Nil(t, appErr) + pageID := draft.PageId + _, appErr = h.svc.UpdatePageDraft(&model.Draft{ + UserId: userID, SpaceId: space.Id, PageId: pageID, Title: "Doc", Body: docWith("v1"), + }, nil, nil, &mmmodel.StringInterface{"color": "blue"}, "") + require.Nil(t, appErr) + + page, wasCreated, appErr := h.svc.PublishPageDraft(userID, space.Id, pageID, false) + require.Nil(t, appErr) + require.True(t, wasCreated) + require.Equal(t, "blue", page.Props["color"], "a new page must adopt the draft's props on publish") + + // An edit whose draft carries a non-empty props map replaces the page's props. + _, appErr = h.svc.UpdatePageDraft(&model.Draft{ + UserId: userID, SpaceId: space.Id, PageId: pageID, Title: "Doc", BaseEditAt: page.EditAt, + }, nil, nil, &mmmodel.StringInterface{"color": "red"}, "") require.Nil(t, appErr) - require.EqualValues(t, 1234, saved.Props[model.DraftPropsOriginalPageEditAt], "omitted props must preserve the stored baseline") + edited, _, appErr := h.svc.PublishPageDraft(userID, space.Id, pageID, false) + require.Nil(t, appErr) + require.Equal(t, "red", edited.Props["color"], "an edit with non-empty draft props must replace the page's props") + + // An edit that carries content but no props preserves the live page's props. + _, appErr = h.svc.UpdatePageDraft(&model.Draft{ + UserId: userID, SpaceId: space.Id, PageId: pageID, Body: docWith("v2"), BaseEditAt: edited.EditAt, + }, nil, nil, nil, "") + require.Nil(t, appErr) + preserved, _, appErr := h.svc.PublishPageDraft(userID, space.Id, pageID, false) + require.Nil(t, appErr) + require.Equal(t, "red", preserved.Props["color"], "an edit carrying no props must preserve the page's props") + require.Contains(t, preserved.Body, "v2") } func TestPublishRejectsForeignSpacePage(t *testing.T) { @@ -377,12 +484,12 @@ func TestPublishRejectsForeignSpacePage(t *testing.T) { // userB cannot reserve a draft in space B against userA's not-yet-live page id — // the cross-space reservation is now correctly rejected at the app layer. - _, appErr = h.svc.UpdatePageDraft(&model.Draft{UserId: userB, SpaceId: spaceB.Id, PageId: pageID, Title: "B doc", Body: docWith("b content")}, nil, nil, "") + _, appErr = h.svc.UpdatePageDraft(&model.Draft{UserId: userB, SpaceId: spaceB.Id, PageId: pageID, Title: "B doc", Body: docWith("b content")}, nil, nil, nil, "") require.NotNil(t, appErr, "cross-space draft reservation must be rejected") require.Equal(t, http.StatusNotFound, appErr.StatusCode, "cross-space draft reservation returns 404") // userA autosaves content and publishes, so the page becomes live in space A. - _, appErr = h.svc.UpdatePageDraft(&model.Draft{UserId: userA, SpaceId: spaceA.Id, PageId: pageID, Title: "A doc", Body: docWith("a content")}, nil, nil, "") + _, appErr = h.svc.UpdatePageDraft(&model.Draft{UserId: userA, SpaceId: spaceA.Id, PageId: pageID, Title: "A doc", Body: docWith("a content")}, nil, nil, nil, "") require.Nil(t, appErr) pageA, wasCreated, appErr := h.svc.PublishPageDraft(userA, spaceA.Id, pageID, false) require.Nil(t, appErr) @@ -413,11 +520,11 @@ func TestUpdatePageDraftPreservesTitleOnBodyOnlyAutosave(t *testing.T) { require.Nil(t, appErr) pageID := draft.PageId - _, appErr = h.svc.UpdatePageDraft(&model.Draft{UserId: userID, SpaceId: space.Id, PageId: pageID, Title: "Keep Title", Body: docWith("v1")}, nil, nil, "") + _, appErr = h.svc.UpdatePageDraft(&model.Draft{UserId: userID, SpaceId: space.Id, PageId: pageID, Title: "Keep Title", Body: docWith("v1")}, nil, nil, nil, "") require.Nil(t, appErr) // A heartbeat that sends only the body (empty title) must not wipe the stored title. - saved, appErr := h.svc.UpdatePageDraft(&model.Draft{UserId: userID, SpaceId: space.Id, PageId: pageID, Body: docWith("v2")}, nil, nil, "") + saved, appErr := h.svc.UpdatePageDraft(&model.Draft{UserId: userID, SpaceId: space.Id, PageId: pageID, Body: docWith("v2")}, nil, nil, nil, "") require.Nil(t, appErr) require.Equal(t, "Keep Title", saved.Title, "a body-only autosave must not clear the draft title") } @@ -434,7 +541,7 @@ func TestUpdatePageDraftSanitizesBody(t *testing.T) { saved, appErr := h.svc.UpdatePageDraft(&model.Draft{ UserId: userID, SpaceId: space.Id, PageId: draft.PageId, Title: "Doc", Body: `{"type":"doc","content":[{"type":"image","attrs":{"src":"x","onerror":"alert(document.cookie)"}}]}`, - }, nil, nil, "") + }, nil, nil, nil, "") require.Nil(t, appErr) require.NotContains(t, saved.Body, "onerror", "autosave must sanitize the draft body") } @@ -453,7 +560,7 @@ func TestPublishEditIgnoresStaleParentGuard(t *testing.T) { childDraft, appErr := h.svc.CreateSpaceDraft(userID, space.Id, "Child", parent.Id) require.Nil(t, appErr) childID := childDraft.PageId - _, appErr = h.svc.UpdatePageDraft(&model.Draft{UserId: userID, SpaceId: space.Id, PageId: childID, Title: "Child", Body: docWith("c1")}, nil, nil, "") + _, appErr = h.svc.UpdatePageDraft(&model.Draft{UserId: userID, SpaceId: space.Id, PageId: childID, Title: "Child", Body: docWith("c1")}, nil, nil, nil, "") require.Nil(t, appErr) child, _, appErr := h.svc.PublishPageDraft(userID, space.Id, childID, false) require.Nil(t, appErr) @@ -462,8 +569,8 @@ func TestPublishEditIgnoresStaleParentGuard(t *testing.T) { parentID := parent.Id _, appErr = h.svc.UpdatePageDraft(&model.Draft{ UserId: userID, SpaceId: space.Id, PageId: childID, Title: "Child", Body: docWith("c2"), - Props: mmmodel.StringInterface{model.DraftPropsOriginalPageEditAt: float64(child.EditAt)}, - }, &parentID, nil, "") + BaseEditAt: child.EditAt, + }, &parentID, nil, nil, "") require.Nil(t, appErr) // The parent is deleted mid-edit (its children are promoted), leaving the draft's parent id stale. @@ -481,7 +588,7 @@ func TestUpdatePageDraftRejectsInvalidPageID(t *testing.T) { space := mustCreateSpace(t, h.store, mmmodel.NewId()) userID := mmmodel.NewId() - _, appErr := h.svc.UpdatePageDraft(&model.Draft{UserId: userID, SpaceId: space.Id, PageId: "not-a-valid-id", Title: "x"}, nil, nil, "") + _, appErr := h.svc.UpdatePageDraft(&model.Draft{UserId: userID, SpaceId: space.Id, PageId: "not-a-valid-id", Title: "x"}, nil, nil, nil, "") require.NotNil(t, appErr) require.Equal(t, http.StatusBadRequest, appErr.StatusCode) } @@ -511,7 +618,7 @@ func TestUpdatePageDraftRejectsInvalidUserID(t *testing.T) { h := openTestService(t) space := mustCreateSpace(t, h.store, mmmodel.NewId()) - _, appErr := h.svc.UpdatePageDraft(&model.Draft{UserId: "bad-id", SpaceId: space.Id, PageId: mmmodel.NewId()}, nil, nil, "") + _, appErr := h.svc.UpdatePageDraft(&model.Draft{UserId: "bad-id", SpaceId: space.Id, PageId: mmmodel.NewId()}, nil, nil, nil, "") require.NotNil(t, appErr) require.Equal(t, http.StatusBadRequest, appErr.StatusCode) } @@ -519,33 +626,11 @@ func TestUpdatePageDraftRejectsInvalidUserID(t *testing.T) { func TestUpdatePageDraftRejectsInvalidSpaceID(t *testing.T) { h := openTestService(t) - _, appErr := h.svc.UpdatePageDraft(&model.Draft{UserId: mmmodel.NewId(), SpaceId: "bad-id", PageId: mmmodel.NewId()}, nil, nil, "") + _, appErr := h.svc.UpdatePageDraft(&model.Draft{UserId: mmmodel.NewId(), SpaceId: "bad-id", PageId: mmmodel.NewId()}, nil, nil, nil, "") require.NotNil(t, appErr) require.Equal(t, http.StatusBadRequest, appErr.StatusCode) } -func TestUpdatePageDraftPropWhitelistDropsForeignKeys(t *testing.T) { - h := openTestService(t) - space := mustCreateSpace(t, h.store, mmmodel.NewId()) - userID := mmmodel.NewId() - - draft, appErr := h.svc.CreateSpaceDraft(userID, space.Id, "Doc", "") - require.Nil(t, appErr) - - // UpdatePageDraft with an unrecognized prop key. - saved, appErr := h.svc.UpdatePageDraft(&model.Draft{ - UserId: userID, SpaceId: space.Id, PageId: draft.PageId, - Props: mmmodel.StringInterface{ - model.DraftPropsOriginalPageEditAt: float64(123), - "evil_key": "should be dropped", - }, - }, nil, nil, "") - require.Nil(t, appErr) - require.Equal(t, float64(123), saved.Props[model.DraftPropsOriginalPageEditAt], "allowed prop must be preserved") - _, hasForeign := saved.Props["evil_key"] - require.False(t, hasForeign, "unrecognized prop key must be stripped by the whitelist") -} - func TestCreateSpaceDraftRejectsEmptyTitle(t *testing.T) { h := openTestService(t) space := mustCreateSpace(t, h.store, mmmodel.NewId()) @@ -586,7 +671,7 @@ func TestUpdatePageDraftRejectsResurrectionAfterNewPagePublish(t *testing.T) { require.Nil(t, appErr) pageID := draft.PageId - _, appErr = h.svc.UpdatePageDraft(&model.Draft{UserId: userID, SpaceId: space.Id, PageId: pageID, Body: docWith("v1")}, nil, nil, "") + _, appErr = h.svc.UpdatePageDraft(&model.Draft{UserId: userID, SpaceId: space.Id, PageId: pageID, Body: docWith("v1")}, nil, nil, nil, "") require.Nil(t, appErr) _, _, appErr = h.svc.PublishPageDraft(userID, space.Id, pageID, false) require.Nil(t, appErr) @@ -594,7 +679,7 @@ func TestUpdatePageDraftRejectsResurrectionAfterNewPagePublish(t *testing.T) { // Late autosave with no baseline: the draft is gone, so this must be rejected. _, appErr = h.svc.UpdatePageDraft(&model.Draft{ UserId: userID, SpaceId: space.Id, PageId: pageID, Body: docWith("late"), - }, nil, nil, "") + }, nil, nil, nil, "") require.NotNil(t, appErr) require.Equal(t, http.StatusConflict, appErr.StatusCode) @@ -619,13 +704,13 @@ func TestUpdatePageDraftRejectsDraftParentCycle(t *testing.T) { // B → A is valid (A has no parent). _, appErr = h.svc.UpdatePageDraft(&model.Draft{ UserId: userID, SpaceId: space.Id, PageId: draftB.PageId, - }, &draftA.PageId, nil, "") + }, &draftA.PageId, nil, nil, "") require.Nil(t, appErr) // A → B would create A → B → A: a cycle. This must be rejected. _, appErr = h.svc.UpdatePageDraft(&model.Draft{ UserId: userID, SpaceId: space.Id, PageId: draftA.PageId, - }, &draftB.PageId, nil, "") + }, &draftB.PageId, nil, nil, "") require.NotNil(t, appErr) require.Equal(t, http.StatusBadRequest, appErr.StatusCode) } @@ -649,7 +734,7 @@ func TestUpdatePageDraftRejectsDraftHierarchyTooDeep(t *testing.T) { for i := 1; i < chainLen; i++ { _, appErr := h.svc.UpdatePageDraft(&model.Draft{ UserId: userID, SpaceId: space.Id, PageId: drafts[i].PageId, - }, &drafts[i-1].PageId, nil, "") + }, &drafts[i-1].PageId, nil, nil, "") require.Nil(t, appErr, "chaining draft %d under draft %d must succeed", i, i-1) } @@ -659,7 +744,7 @@ func TestUpdatePageDraftRejectsDraftHierarchyTooDeep(t *testing.T) { leaf := drafts[chainLen-1].PageId _, appErr = h.svc.UpdatePageDraft(&model.Draft{ UserId: userID, SpaceId: space.Id, PageId: extra.PageId, - }, &leaf, nil, "") + }, &leaf, nil, nil, "") require.NotNil(t, appErr) require.Equal(t, http.StatusBadRequest, appErr.StatusCode) } @@ -678,7 +763,7 @@ func TestDeletePageDraftReparentsChildren(t *testing.T) { _, appErr = h.svc.UpdatePageDraft(&model.Draft{ UserId: userID, SpaceId: space.Id, PageId: draftC.PageId, - }, &draftP.PageId, nil, "") + }, &draftP.PageId, nil, nil, "") require.Nil(t, appErr) // Discard the parent draft. diff --git a/server/app/page_presence.go b/server/app/page_presence.go index d34a4eb..edbd50c 100644 --- a/server/app/page_presence.go +++ b/server/app/page_presence.go @@ -59,7 +59,7 @@ func (s *Service) sweepPresenceBroadcastLast(now int64) { func (s *Service) getActiveEditors(pageID, spaceID string) ([]string, bool) { editors, err := s.store.GetPageActiveEditors(pageID, spaceID, activeEditorSince()) if err != nil { - s.log.Warn("getActiveEditors: failed to query active editors; skipping broadcast", + s.log.Warn("failed to query active editors; skipping broadcast", "page_id", pageID, "err", err) return nil, false } diff --git a/server/app/ws_events_test.go b/server/app/ws_events_test.go index 66d8024..2824b18 100644 --- a/server/app/ws_events_test.go +++ b/server/app/ws_events_test.go @@ -191,8 +191,8 @@ func TestServiceUpdatePageDraft_PublishesPresenceEvent(t *testing.T) { _, appErr := h.svc.UpdatePageDraft(&model.Draft{ UserId: userID, SpaceId: space.Id, PageId: page.Id, Title: "Doc", - Props: mmmodel.StringInterface{model.DraftPropsOriginalPageEditAt: float64(page.EditAt)}, - }, nil, nil, channelID) + BaseEditAt: page.EditAt, + }, nil, nil, nil, channelID) require.Nil(t, appErr) mockAPI.AssertCalled(t, "PublishWebSocketEvent", "page_presence_updated", @@ -253,8 +253,8 @@ func TestServicePublishPageDraft_PublishesUpdatedEvent(t *testing.T) { // Start an edit session against the live page's baseline, then publish it. _, appErr := h.svc.UpdatePageDraft(&model.Draft{ UserId: userID, SpaceId: space.Id, PageId: page.Id, Title: "Doc", Body: docWith("edited"), - Props: mmmodel.StringInterface{model.DraftPropsOriginalPageEditAt: float64(page.EditAt)}, - }, nil, nil, "") + BaseEditAt: page.EditAt, + }, nil, nil, nil, "") require.Nil(t, appErr) republished, wasCreated, appErr := h.svc.PublishPageDraft(userID, space.Id, page.Id, false) @@ -295,8 +295,8 @@ func TestServiceDeletePageDraft_PublishesPresenceEvent(t *testing.T) { _, appErr := h.svc.UpdatePageDraft(&model.Draft{ UserId: userID, SpaceId: space.Id, PageId: page.Id, Title: "Doc", - Props: mmmodel.StringInterface{model.DraftPropsOriginalPageEditAt: float64(page.EditAt)}, - }, nil, nil, channelID) + BaseEditAt: page.EditAt, + }, nil, nil, nil, channelID) require.Nil(t, appErr) require.Nil(t, h.svc.DeletePageDraft(userID, space.Id, page.Id, channelID)) @@ -334,7 +334,7 @@ func TestServiceUpdatePageDraft_NewPageDraftPublishesToUserOnly(t *testing.T) { _, appErr = h.svc.UpdatePageDraft(&model.Draft{ UserId: userID, SpaceId: space.Id, PageId: draft.PageId, Title: "Unpublished", - }, nil, nil, channelID) + }, nil, nil, nil, channelID) require.Nil(t, appErr) // The broadcast must be user-scoped: only the author learns about their own unreleased page. @@ -369,8 +369,8 @@ func TestServiceUpdatePageDraft_PresenceRateLimitSuppressesSecondBroadcast(t *te autosave := func() { _, appErr := h.svc.UpdatePageDraft(&model.Draft{ UserId: userID, SpaceId: space.Id, PageId: page.Id, Title: "Doc", - Props: mmmodel.StringInterface{model.DraftPropsOriginalPageEditAt: float64(page.EditAt)}, - }, nil, nil, channelID) + BaseEditAt: page.EditAt, + }, nil, nil, nil, channelID) require.Nil(t, appErr) } autosave() // first: should broadcast diff --git a/server/model/draft.go b/server/model/draft.go index 269db61..34aa556 100644 --- a/server/model/draft.go +++ b/server/model/draft.go @@ -14,12 +14,6 @@ import ( // DraftFileIdsMaxRunes is the maximum rune length of the serialized FileIds JSON array. const DraftFileIdsMaxRunes = 300 -// DraftPropsOriginalPageEditAt is a draft Props key holding the page's EditAt at the moment the user -// opened it for editing. On publish the server compares this against the page's current EditAt; if -// they differ, another user saved the page in the meantime, so the publish is rejected as a conflict -// rather than overwriting the newer content. -const DraftPropsOriginalPageEditAt = "original_page_edit_at" - // Draft is a per-user autosave draft for a space page, stored in DOCS_Draft. // // A draft is keyed by (UserId, PageId): PageId is the page id reserved when the @@ -44,6 +38,14 @@ type Draft struct { CreateAt int64 `json:"create_at"` UpdateAt int64 `json:"update_at"` LastActiveAt int64 `json:"last_active_at"` + // BaseEditAt is the optimistic-lock (CAS) baseline: the page EditAt the client saw when it opened + // this page for editing, compared against the page's current EditAt on publish to reject a + // concurrent-edit conflict. Write-once — frozen at the SQL layer on conflict (see + // Store.UpsertDraft), so unlike the mutable Page.EditAt it never changes once established. 0 means + // no baseline: either a new-page draft or an existing-page edit whose baseline was never captured + // (fails closed to a forced publish). "New page" is decided by page existence, never by + // BaseEditAt == 0. + BaseEditAt int64 `json:"base_edit_at"` } // DraftSummary is the metadata projection returned by draft collection endpoints. It deliberately @@ -96,6 +98,7 @@ func (d *Draft) Auditable() map[string]any { "create_at": d.CreateAt, "update_at": d.UpdateAt, "last_active_at": d.LastActiveAt, + "base_edit_at": d.BaseEditAt, } } @@ -132,6 +135,12 @@ func (d *Draft) IsValid() *mmmodel.AppError { return mmmodel.NewAppError("Draft.IsValid", "model.draft.is_valid.last_active_at.app_error", nil, "page_id="+d.PageId, http.StatusBadRequest) } + // BaseEditAt is an optimistic-lock baseline (a page EditAt) or 0 for "no baseline"; a negative + // value is never legitimate. 0 is legal and must not be rejected. + if d.BaseEditAt < 0 { + return mmmodel.NewAppError("Draft.IsValid", "model.draft.is_valid.base_edit_at.app_error", nil, "page_id="+d.PageId, http.StatusBadRequest) + } + // A draft publishes into a page, so it is bound by the page content limits. if utf8.RuneCountInString(d.Title) > PageTitleMaxRunes { return mmmodel.NewAppError("Draft.IsValid", "model.draft.is_valid.title_length.app_error", nil, "page_id="+d.PageId, http.StatusBadRequest) @@ -145,7 +154,7 @@ func (d *Draft) IsValid() *mmmodel.AppError { return mmmodel.NewAppError("Draft.IsValid", "model.draft.is_valid.file_ids.app_error", nil, "page_id="+d.PageId, http.StatusBadRequest) } - if err := validatePropsSize("Draft.IsValid", "page_id="+d.PageId, d.Props, PagePropsMaxBytes); err != nil { + if err := ValidatePropsSize("Draft.IsValid", "page_id="+d.PageId, d.Props, PagePropsMaxBytes); err != nil { return err } @@ -156,32 +165,3 @@ func (d *Draft) IsValid() *mmmodel.AppError { func (d *Draft) GetProps() mmmodel.StringInterface { return ensureProps(d.Props) } - -// SanitizeProps strips any props key not on the recognized allowlist. Call on every write path to -// prevent unknown client-supplied keys from accumulating in the stored map. -func (d *Draft) SanitizeProps() { - allowed := mmmodel.StringInterface{} - if v, ok := d.Props[DraftPropsOriginalPageEditAt]; ok { - allowed[DraftPropsOriginalPageEditAt] = v - } - d.Props = allowed -} - -// EditBaseline extracts the optimistic-lock baseline from the draft's props. JSON-decoded numbers -// arrive as float64; a programmatically-set int64/int is also accepted. Returns (0, false) when -// the baseline is absent or zero (e.g. a new-page draft). -func (d *Draft) EditBaseline() (int64, bool) { - v, ok := d.GetProps()[DraftPropsOriginalPageEditAt] - if !ok { - return 0, false - } - switch n := v.(type) { - case float64: - return int64(n), n != 0 - case int64: - return n, n != 0 - case int: - return int64(n), n != 0 - } - return 0, false -} diff --git a/server/model/draft_test.go b/server/model/draft_test.go index 647b119..bc0aff8 100644 --- a/server/model/draft_test.go +++ b/server/model/draft_test.go @@ -127,6 +127,20 @@ func TestDraftIsValid(t *testing.T) { require.NotNil(t, aerr) require.Equal(t, "model.draft.is_valid.update_at.app_error", aerr.Id) }) + + t.Run("negative BaseEditAt rejected", func(t *testing.T) { + d := validDraft() + d.BaseEditAt = -1 + aerr := d.IsValid() + require.NotNil(t, aerr) + require.Equal(t, "model.draft.is_valid.base_edit_at.app_error", aerr.Id) + }) + + t.Run("zero BaseEditAt allowed", func(t *testing.T) { + d := validDraft() + d.BaseEditAt = 0 + require.Nil(t, d.IsValid()) + }) } func TestDraftIsValidLastActiveAtZeroRejected(t *testing.T) { @@ -165,113 +179,9 @@ func TestDraftGetPropsNilReturnsEmpty(t *testing.T) { require.Empty(t, d.GetProps()) } -func TestDraftSanitizeProps(t *testing.T) { - t.Run("keeps only the baseline key, unknown keys dropped", func(t *testing.T) { - d := &model.Draft{Props: mmmodel.StringInterface{ - model.DraftPropsOriginalPageEditAt: int64(12345), - "unknown_key": "value", - "another_unknown": 42, - }} - d.SanitizeProps() - require.Equal(t, mmmodel.StringInterface{model.DraftPropsOriginalPageEditAt: int64(12345)}, d.Props) - }) - - t.Run("nil Props becomes an empty map", func(t *testing.T) { - d := &model.Draft{Props: nil} - d.SanitizeProps() - require.NotNil(t, d.Props) - require.Empty(t, d.Props) - }) - - t.Run("only unknown keys becomes an empty map", func(t *testing.T) { - d := &model.Draft{Props: mmmodel.StringInterface{"unknown": "x"}} - d.SanitizeProps() - require.NotNil(t, d.Props) - require.Empty(t, d.Props) - }) - - t.Run("baseline key absent yields an empty map", func(t *testing.T) { - d := &model.Draft{Props: mmmodel.StringInterface{"foo": "bar"}} - d.SanitizeProps() - require.NotNil(t, d.Props) - require.Empty(t, d.Props) - }) -} - -func TestDraftEditBaseline(t *testing.T) { - t.Run("float64 non-zero converts to int64", func(t *testing.T) { - d := &model.Draft{Props: mmmodel.StringInterface{model.DraftPropsOriginalPageEditAt: float64(1610000000000)}} - v, ok := d.EditBaseline() - require.True(t, ok) - require.Equal(t, int64(1610000000000), v) - }) - - t.Run("float64 zero", func(t *testing.T) { - d := &model.Draft{Props: mmmodel.StringInterface{model.DraftPropsOriginalPageEditAt: float64(0)}} - v, ok := d.EditBaseline() - require.False(t, ok) - require.Equal(t, int64(0), v) - }) - - t.Run("int64 non-zero passes through", func(t *testing.T) { - d := &model.Draft{Props: mmmodel.StringInterface{model.DraftPropsOriginalPageEditAt: int64(42)}} - v, ok := d.EditBaseline() - require.True(t, ok) - require.Equal(t, int64(42), v) - }) - - t.Run("int64 zero", func(t *testing.T) { - d := &model.Draft{Props: mmmodel.StringInterface{model.DraftPropsOriginalPageEditAt: int64(0)}} - v, ok := d.EditBaseline() - require.False(t, ok) - require.Equal(t, int64(0), v) - }) - - t.Run("int non-zero converts to int64", func(t *testing.T) { - d := &model.Draft{Props: mmmodel.StringInterface{model.DraftPropsOriginalPageEditAt: int(7)}} - v, ok := d.EditBaseline() - require.True(t, ok) - require.Equal(t, int64(7), v) - }) - - t.Run("int zero", func(t *testing.T) { - d := &model.Draft{Props: mmmodel.StringInterface{model.DraftPropsOriginalPageEditAt: int(0)}} - v, ok := d.EditBaseline() - require.False(t, ok) - require.Equal(t, int64(0), v) - }) - - t.Run("missing key", func(t *testing.T) { - d := &model.Draft{Props: mmmodel.StringInterface{}} - v, ok := d.EditBaseline() - require.False(t, ok) - require.Equal(t, int64(0), v) - }) - - t.Run("wrong type string does not panic", func(t *testing.T) { - d := &model.Draft{Props: mmmodel.StringInterface{model.DraftPropsOriginalPageEditAt: "not-a-number"}} - require.NotPanics(t, func() { - v, ok := d.EditBaseline() - require.False(t, ok) - require.Equal(t, int64(0), v) - }) - }) - - t.Run("wrong type slice does not panic", func(t *testing.T) { - d := &model.Draft{Props: mmmodel.StringInterface{model.DraftPropsOriginalPageEditAt: []any{1, 2, 3}}} - require.NotPanics(t, func() { - v, ok := d.EditBaseline() - require.False(t, ok) - require.Equal(t, int64(0), v) - }) - }) - - t.Run("wrong type bool does not panic", func(t *testing.T) { - d := &model.Draft{Props: mmmodel.StringInterface{model.DraftPropsOriginalPageEditAt: true}} - require.NotPanics(t, func() { - v, ok := d.EditBaseline() - require.False(t, ok) - require.Equal(t, int64(0), v) - }) - }) +func TestDraftAuditable(t *testing.T) { + d := validDraft() + d.BaseEditAt = 42 + auditable := d.Auditable() + require.Equal(t, int64(42), auditable["base_edit_at"]) } diff --git a/server/model/page.go b/server/model/page.go index 2ceae78..9c0e584 100644 --- a/server/model/page.go +++ b/server/model/page.go @@ -257,7 +257,7 @@ func (p *Page) IsValid() *mmmodel.AppError { return mmmodel.NewAppError("Page.IsValid", "model.page.is_valid.search_text_without_body.app_error", nil, "id="+p.Id, http.StatusBadRequest) } - if err := validatePropsSize("Page.IsValid", "id="+p.Id, p.Props, PagePropsMaxBytes); err != nil { + if err := ValidatePropsSize("Page.IsValid", "id="+p.Id, p.Props, PagePropsMaxBytes); err != nil { return err } diff --git a/server/model/page_content.go b/server/model/page_content.go index b09e08e..f67d702 100644 --- a/server/model/page_content.go +++ b/server/model/page_content.go @@ -293,9 +293,12 @@ func sanitizeAttrs(attrs map[string]any, depth int) error { return nil } -// sanitizeAttrValue recurses into the containers an attribute value may hold. Scalars are left -// alone: only a map can carry a dangerous key. Like sanitizeAttrs, it fails closed past -// maxTipTapDepth. +// sanitizeAttrValue recurses into the containers an attribute value may hold, neutralizing dangerous +// URL schemes on any bare string reached inside an array. A scalar string held directly under a map +// key is left to stripDangerousKeys, which sanitizes it only under a URL-designated key; but a string +// inside an array has no key to mark it, so a dangerous scheme there — e.g. ["javascript:alert(1)"] +// under a non-URL-designated key an editor extension may define — would otherwise pass through +// untouched. Like sanitizeAttrs, it fails closed past maxTipTapDepth. func sanitizeAttrValue(val any, depth int) error { if depth > maxTipTapDepth { return errAttrDepthExceeded @@ -304,7 +307,11 @@ func sanitizeAttrValue(val any, depth int) error { case map[string]any: return sanitizeAttrs(v, depth+1) case []any: - for _, item := range v { + for i, item := range v { + if s, ok := item.(string); ok { + v[i] = neutralizeBareArrayURLString(s) + continue + } if err := sanitizeAttrValue(item, depth+1); err != nil { return err } @@ -487,24 +494,42 @@ func urlScheme(s string) (string, bool) { // its scheme, so an obfuscated "java\tscript:" cannot slip past the scheme check. var urlStripChars = strings.NewReplacer("\t", "", "\n", "", "\r", "", "\x00", "") -// sanitizeURL returns the URL unchanged if its scheme is on the allowlist (or it is a relative -// reference), and "" otherwise. It defends against control-character, leading-whitespace, and -// HTML-entity obfuscation of dangerous schemes (e.g. "java script:alert(1)"). -func sanitizeURL(url string) string { - // Decode HTML entities and strip the tab/newline/CR browsers ignore, so an obfuscated scheme - // (entity-encoded ":" or an embedded control char) is detected. The decode is used only for - // scheme detection; the original url is what gets returned when allowed. - // Two strip passes: the first removes literal control chars, the second removes any that - // html.UnescapeString re-introduces (e.g. " " → "\t"). - // Percent-encoded characters (%09, %0A, etc.) are intentionally NOT stripped: browsers do not - // strip percent-encoded chars from scheme names, so "java%09script:" never parses as "javascript:". +// decodeURLScheme extracts the scheme a browser would resolve from url, defeating the obfuscation a +// dangerous scheme can hide behind. It decodes HTML entities and strips the tab/newline/CR/null +// browsers ignore, so an entity-encoded ":" or an embedded control char is detected. Two strip +// passes: the first removes literal control chars, the second removes any html.UnescapeString +// re-introduces (e.g. " " → "\t"). Percent-encoded characters (%09, %0A, etc.) are intentionally +// NOT stripped: browsers do not strip them from scheme names, so "java%09script:" never parses as +// "javascript:". Returns the lowercased scheme, the lowercased cleaned string (for data: prefix +// matching), and whether a scheme is present (false for a relative reference). The decode is used +// only for detection; callers return the original string when they allow it. +func decodeURLScheme(url string) (scheme, lower string, hasScheme bool) { cleaned := urlStripChars.Replace(url) cleaned = html.UnescapeString(cleaned) cleaned = urlStripChars.Replace(cleaned) cleaned = strings.TrimFunc(cleaned, func(r rune) bool { return r <= ' ' }) - lower := strings.ToLower(cleaned) + lower = strings.ToLower(cleaned) + scheme, hasScheme = urlScheme(lower) + return scheme, lower, hasScheme +} - scheme, hasScheme := urlScheme(lower) +// isSafeImageDataURL reports whether a data: URL carries a base64 payload sniffing as an allowed +// raster image. lower is the lowercased, obfuscation-decoded form of url from decodeURLScheme. +func isSafeImageDataURL(url, lower string) bool { + for _, prefix := range safeImageDataPrefixes { + if strings.HasPrefix(lower, prefix) && isBase64ImagePayload(url) { + return true + } + } + return false +} + +// sanitizeURL returns the URL unchanged if its scheme is on the allowlist (or it is a relative +// reference), and "" otherwise. It defends against control-character, leading-whitespace, and +// HTML-entity obfuscation of dangerous schemes (e.g. "java script:alert(1)"). Applied to a +// value under a URL-designated attribute key (href, src, data-*, …). +func sanitizeURL(url string) string { + scheme, lower, hasScheme := decodeURLScheme(url) if !hasScheme { return url } @@ -512,13 +537,36 @@ func sanitizeURL(url string) string { case "http", "https", "mailto", "tel": return url case "data": - for _, prefix := range safeImageDataPrefixes { - if strings.HasPrefix(lower, prefix) && isBase64ImagePayload(url) { - return url - } + if isSafeImageDataURL(url, lower) { + return url } return "" default: return "" } } + +// neutralizeBareArrayURLString blanks a string reached as a bare element of an array attribute value +// if it carries a script-executing or foreign-content URL scheme. Unlike sanitizeURL (a strict +// allowlist applied where an attribute key marks the value a URL), a bare array element has no key to +// mark it, so only the unambiguously dangerous schemes are neutralized: a plain colon-bearing string +// an extension may legitimately store in an array — a "12:30" timestamp, a "16:9" ratio — is +// preserved, while a javascript:/vbscript:/non-image data: payload smuggled through a non-URL array +// key is dropped. +func neutralizeBareArrayURLString(s string) string { + scheme, lower, hasScheme := decodeURLScheme(s) + if !hasScheme { + return s + } + switch scheme { + case "javascript", "vbscript": + return "" + case "data": + if isSafeImageDataURL(s, lower) { + return s + } + return "" + default: + return s + } +} diff --git a/server/model/page_content_test.go b/server/model/page_content_test.go index a2917dc..47a140a 100644 --- a/server/model/page_content_test.go +++ b/server/model/page_content_test.go @@ -444,6 +444,45 @@ func TestParseTipTapDocumentDropsNonStringURLAttr(t *testing.T) { require.NotContains(t, attrs, "href", "non-string URL attr must be dropped") } +func TestParseTipTapDocumentNeutralizesBareArrayURLStrings(t *testing.T) { + // A dangerous scheme carried as a bare string inside an array attribute value — under a key an + // editor extension may define that is not one of the designated URL keys — has no attribute key + // to mark it a URL, so stripDangerousKeys does not reach it. The array walk must still neutralize + // the executable scheme while leaving legitimate colon-bearing strings (a timestamp, a ratio) and + // safe URLs untouched. + raw := map[string]any{ + "type": "doc", + "content": []any{ + map[string]any{ + "type": "customEmbed", + "attrs": map[string]any{ + "sources": []any{"javascript:alert(1)", "vbscript:msgbox(1)", "http://ok.example", "12:30", "16:9"}, + "nested": []any{[]any{"javascript:alert(2)"}}, + }, + }, + }, + } + b, err := json.Marshal(raw) + require.NoError(t, err) + doc, err := model.ParseTipTapDocument(string(b)) + require.NoError(t, err) + + out := marshal(t, doc) + require.NotContains(t, out, "javascript:alert", "bare javascript: array element must be neutralized") + require.NotContains(t, out, "vbscript:", "bare vbscript: array element must be neutralized") + require.Contains(t, out, "http://ok.example", "a safe URL in the array must be preserved") + require.Contains(t, out, "12:30", "a non-URL colon-bearing string must be preserved") + require.Contains(t, out, "16:9", "a non-URL ratio string must be preserved") + + var parsed map[string]any + require.NoError(t, json.Unmarshal([]byte(out), &parsed)) + attrs := parsed["content"].([]any)[0].(map[string]any)["attrs"].(map[string]any) + sources := attrs["sources"].([]any) + require.Equal(t, "", sources[0], "javascript: element blanked in place, preserving array length") + require.Equal(t, "", sources[1], "vbscript: element blanked in place") + require.Equal(t, "http://ok.example", sources[2]) +} + func TestParseTipTapDocumentRejectsTooDeep(t *testing.T) { // A pathologically deep document is rejected rather than walked. depth := 200 diff --git a/server/model/props.go b/server/model/props.go index 98f0218..9ff8692 100644 --- a/server/model/props.go +++ b/server/model/props.go @@ -20,9 +20,9 @@ func ensureProps(props mmmodel.StringInterface) mmmodel.StringInterface { return maps.Clone(props) } -// validatePropsSize enforces the serialized-size cap on a Props map. +// ValidatePropsSize enforces the serialized-size cap on a Props map. // The where argument identifies the calling operation for logs; the message keys are shared across callers. -func validatePropsSize(where, details string, props mmmodel.StringInterface, maxBytes int) *mmmodel.AppError { +func ValidatePropsSize(where, details string, props mmmodel.StringInterface, maxBytes int) *mmmodel.AppError { if props == nil { return nil } diff --git a/server/model/props_test.go b/server/model/props_test.go index 48d05fb..5bd4e45 100644 --- a/server/model/props_test.go +++ b/server/model/props_test.go @@ -13,7 +13,8 @@ import ( "github.com/mattermost/mattermost-plugin-docs/server/model" ) -// validatePropsSize is unexported; exercise it through Page.IsValid which delegates to it. +// TestValidatePropsSize exercises model.ValidatePropsSize indirectly through Page.IsValid, +// which delegates to it. func TestValidatePropsSize(t *testing.T) { t.Run("nil props passes", func(t *testing.T) { p := validPage() @@ -42,3 +43,21 @@ func TestValidatePropsSize(t *testing.T) { require.Equal(t, "model.shared.props_too_large.app_error", err.Id) }) } + +func TestValidatePropsSizeDirect(t *testing.T) { + t.Run("nil props passes", func(t *testing.T) { + require.Nil(t, model.ValidatePropsSize("where", "details", nil, model.PagePropsMaxBytes)) + }) + + t.Run("props within limit passes", func(t *testing.T) { + props := mmmodel.StringInterface{"key": "value"} + require.Nil(t, model.ValidatePropsSize("where", "details", props, model.PagePropsMaxBytes)) + }) + + t.Run("props over limit fails", func(t *testing.T) { + props := mmmodel.StringInterface{"key": strings.Repeat("x", model.PagePropsMaxBytes+1)} + err := model.ValidatePropsSize("where", "details", props, model.PagePropsMaxBytes) + require.NotNil(t, err) + require.Equal(t, "model.shared.props_too_large.app_error", err.Id) + }) +} diff --git a/server/model/space.go b/server/model/space.go index a5ccb72..ad3029d 100644 --- a/server/model/space.go +++ b/server/model/space.go @@ -183,7 +183,7 @@ func (s *Space) IsValid() *mmmodel.AppError { return mmmodel.NewAppError("Space.IsValid", "model.space.is_valid.icon_length.app_error", nil, "id="+s.Id, http.StatusBadRequest) } - if err := validatePropsSize("Space.IsValid", "id="+s.Id, s.Props, SpacePropsMaxBytes); err != nil { + if err := ValidatePropsSize("Space.IsValid", "id="+s.Id, s.Props, SpacePropsMaxBytes); err != nil { return err } diff --git a/server/store/draft_store.go b/server/store/draft_store.go index eb37666..47a3bcd 100644 --- a/server/store/draft_store.go +++ b/server/store/draft_store.go @@ -27,7 +27,7 @@ const MaxDraftsPerUserPerSpace = 100 const maxActiveEditorsPerPage = 100 var draftSelectColumns = []string{ - "UserId", "SpaceId", "PageId", "ParentId", "Title", "Body", "FileIds", "Props", "CreateAt", "UpdateAt", "LastActiveAt", + "UserId", "SpaceId", "PageId", "ParentId", "Title", "Body", "FileIds", "Props", "CreateAt", "UpdateAt", "LastActiveAt", "BaseEditAt", } // draftMetaColumns is the metadata column set for draft queries — Body omitted because it can be up to PageBodyMaxBytes per draft. @@ -83,8 +83,8 @@ func (s *Store) reparentDraftsForPage(tx *sqlx.Tx, pageID, newParentID string, n // draftParentExistsTx reports whether userID has a draft for parentID in spaceID, under the // same visibility rule as GetDraft, reading within tx so it observes uncommitted state. It locks -// the matched parent-draft row (FOR UPDATE OF d) so a concurrent DeleteDraft cannot remove the -// parent between this check and the child-draft insert. Only the draft row is locked, since the +// the matched parent-draft row (FOR UPDATE OF d) so a concurrent DeleteDraftReparenting cannot +// remove the parent between this check and the child-draft insert. Only the draft row is locked, since the // liveness filter LEFT JOINs the nullable page side, which cannot be a FOR UPDATE target. func (s *Store) draftParentExistsTx(tx *sqlx.Tx, userID, spaceID, parentID string) (bool, error) { builder := applyDraftLivenessFilter( @@ -201,9 +201,9 @@ FROM chain`, model.MaxPageDepth, model.MaxPageDepth) // // An autosave may carry only the fields the editor changed, so on the update path an empty Title or // Body means "not sent", not "cleared", and the stored value is kept (a cleared document is -// EmptyTipTapJSON, not ""). ParentId and FileIds do not follow this empty-means-not-sent rule: they -// use explicit pointer intent (see the parentID/fileIDs paragraphs below), where a nil pointer — not -// an empty value — means "not sent". Props are merged key-wise over the stored map. +// EmptyTipTapJSON, not ""). ParentId, FileIds, and Props do not follow this empty-means-not-sent +// rule: they use explicit pointer intent (see the parentID/fileIDs/props paragraphs below), where a +// nil pointer — not an empty value — means "not sent". // CreateAt keeps the existing row's original value. UpdateAt is bumped strictly monotonically // (GREATEST(incoming, stored+1)), so it is a collision-free version token: publish CAS-deletes the // draft on this value, and two saves within the same millisecond can no longer share it. All of this @@ -220,7 +220,13 @@ FROM chain`, model.MaxPageDepth, model.MaxPageDepth) // existing stored value", a pointer to an empty slice means "clear to no attachments", and a // pointer to a non-empty slice means "replace with these IDs". This mirrors parentID's // preserve/clear/set semantics. -func (s *Store) UpsertDraft(draft *model.Draft, parentID *string, fileIDs *mmmodel.StringArray) (_ *model.Draft, pageWasLive bool, err error) { +// +// props encodes the write intent for the Props column: nil means "omitted — preserve the existing +// stored map", and a non-nil pointer replaces the whole map with the pointed-to value (an empty map +// clears all keys). This is a whole-value replace, not a key-wise merge, mirroring parentID/fileIDs. +// The written value's serialized size must be validated by the caller (App layer): the struct's own +// Props field — the only one IsValid checks — is not what gets written. +func (s *Store) UpsertDraft(draft *model.Draft, parentID *string, fileIDs *mmmodel.StringArray, props *mmmodel.StringInterface) (_ *model.Draft, pageWasLive bool, err error) { if draft == nil { return nil, false, &ErrInvalidInput{Entity: "Draft", Field: "draft", Value: nil} } @@ -238,6 +244,18 @@ func (s *Store) UpsertDraft(draft *model.Draft, parentID *string, fileIDs *mmmod fileIDsParam = mmmodel.ArrayToJSON([]string(*fileIDs)) } + // propsParam follows the same nil/non-nil semantics: nil → SQL NULL (preserve on conflict), + // non-nil → the whole map (replace). A non-nil pointer to a nil map is normalized to an empty + // map so it serializes as JSONB '{}' (clear), never JSON null. + var propsParam any + if props != nil { + p := *props + if p == nil { + p = mmmodel.StringInterface{} + } + propsParam = p + } + draft.PreSave() if validErr := draft.IsValid(); validErr != nil { return nil, false, &ErrInvalidInput{Entity: "Draft", Field: "IsValid", Value: validErr.Error(), Reason: validErr.Id} @@ -292,6 +310,15 @@ func (s *Store) UpsertDraft(draft *model.Draft, parentID *string, fileIDs *mmmod if page.DeleteAt != 0 || page.OriginalId != "" || page.SpaceID != draft.SpaceId { return nil, false, &ErrInvalidInput{Entity: "Draft", Field: "PageId", Value: draft.PageId} } + // Establish-time baseline sanity check: on the establishing INSERT (no draft row yet), a + // baseline ahead of the live page is impossible — the client cannot have seen a version newer + // than the one that exists — so reject it as invalid input (400 via storeAppError). isExisting + // is authoritative here: lockLiveSpace's per-space FOR UPDATE serializes same-space upserts + // before it is read, so a concurrent establish cannot make it stale. The update path needs no + // guard — BaseEditAt is write-once, so the incoming value is ignored on conflict. + if !isExisting && draft.BaseEditAt > page.EditAt { + return nil, false, &ErrInvalidInput{Entity: "Draft", Field: "BaseEditAt", Value: draft.BaseEditAt} + } // Refuse to resurrect a draft a concurrent publish already consumed. When this autosave's // edit-session baseline is behind the page's current EditAt, the page advanced under it (a // publish or another edit). A still-existing draft row may keep saving — the conflict is @@ -300,9 +327,10 @@ func (s *Store) UpsertDraft(draft *model.Draft, parentID *string, fileIDs *mmmod // page row FOR UPDATE serializes this with PublishDraft's own draft delete, so the existence // check is stable within the transaction. conflictReason := "" - if base, ok := draft.EditBaseline(); ok && page.EditAt > base { + base := draft.BaseEditAt + if base != 0 && page.EditAt > base { conflictReason = ReasonConcurrentEdit - } else if !ok { + } else if base == 0 { // New-page autosave: no optimistic-lock baseline was set. The page row now exists, // which means a concurrent publish claimed this page id. If the draft no longer // exists (publish deleted it), reject rather than resurrect it — a re-INSERT here @@ -359,17 +387,17 @@ func (s *Store) UpsertDraft(draft *model.Draft, parentID *string, fileIDs *mmmod builder := s.getQueryBuilder(). Insert("DOCS_Draft"). Columns(draftSelectColumns...). - Values(draft.UserId, draft.SpaceId, draft.PageId, sq.Expr("COALESCE(?::varchar(26), '')", parentIDParam), draft.Title, draft.Body, sq.Expr("COALESCE(?::text, '[]')", fileIDsParam), draft.GetProps(), draft.CreateAt, draft.UpdateAt, draft.LastActiveAt). + Values(draft.UserId, draft.SpaceId, draft.PageId, sq.Expr("COALESCE(?::varchar(26), '')", parentIDParam), draft.Title, draft.Body, sq.Expr("COALESCE(?::text, '[]')", fileIDsParam), sq.Expr("COALESCE(?::jsonb, '{}'::jsonb)", propsParam), draft.CreateAt, draft.UpdateAt, draft.LastActiveAt, draft.BaseEditAt). Suffix(`ON CONFLICT (UserId, PageId) DO UPDATE SET SpaceId = DOCS_Draft.SpaceId, ParentId = CASE WHEN ?::varchar(26) IS NULL THEN DOCS_Draft.ParentId ELSE EXCLUDED.ParentId END, Title = COALESCE(NULLIF(EXCLUDED.Title, ''), DOCS_Draft.Title), Body = COALESCE(NULLIF(EXCLUDED.Body, ''), DOCS_Draft.Body), FileIds = CASE WHEN ?::text IS NULL THEN DOCS_Draft.FileIds ELSE EXCLUDED.FileIds END, - Props = DOCS_Draft.Props || EXCLUDED.Props, + Props = CASE WHEN ?::jsonb IS NULL THEN DOCS_Draft.Props ELSE EXCLUDED.Props END, UpdateAt = GREATEST(EXCLUDED.UpdateAt, DOCS_Draft.UpdateAt + 1), LastActiveAt = GREATEST(EXCLUDED.LastActiveAt, DOCS_Draft.LastActiveAt) - RETURNING `+strings.Join(draftSelectColumns, ", "), parentIDParam, fileIDsParam) + RETURNING `+strings.Join(draftSelectColumns, ", "), parentIDParam, fileIDsParam, propsParam) // Read the stored row back: the omitted-field preserve and the props merge happen in the // statement above, so the returned row — not the caller's struct — is the saved draft. diff --git a/server/store/migrations/000005_add_draft_lastactiveat.up.sql b/server/store/migrations/000005_add_draft_lastactiveat.up.sql deleted file mode 100644 index 878f898..0000000 --- a/server/store/migrations/000005_add_draft_lastactiveat.up.sql +++ /dev/null @@ -1,4 +0,0 @@ --- LastActiveAt records the user's own last autosave of the draft, which is what editor presence is --- derived from. It is distinct from UpdateAt, which can also be bumped by internal maintenance --- writes that do not reflect user activity and would otherwise report the user as an active editor. -ALTER TABLE DOCS_Draft ADD COLUMN IF NOT EXISTS LastActiveAt BIGINT NOT NULL DEFAULT 0; diff --git a/server/store/migrations/000005_add_draft_lastactiveat.down.sql b/server/store/migrations/000005_add_draft_lastactiveat_baseeditat.down.sql similarity index 50% rename from server/store/migrations/000005_add_draft_lastactiveat.down.sql rename to server/store/migrations/000005_add_draft_lastactiveat_baseeditat.down.sql index 85e8743..60c74c4 100644 --- a/server/store/migrations/000005_add_draft_lastactiveat.down.sql +++ b/server/store/migrations/000005_add_draft_lastactiveat_baseeditat.down.sql @@ -1 +1,2 @@ +ALTER TABLE DOCS_Draft DROP COLUMN IF EXISTS BaseEditAt; ALTER TABLE DOCS_Draft DROP COLUMN IF EXISTS LastActiveAt; diff --git a/server/store/migrations/000005_add_draft_lastactiveat_baseeditat.up.sql b/server/store/migrations/000005_add_draft_lastactiveat_baseeditat.up.sql new file mode 100644 index 0000000..cb5b749 --- /dev/null +++ b/server/store/migrations/000005_add_draft_lastactiveat_baseeditat.up.sql @@ -0,0 +1,10 @@ +-- LastActiveAt records the user's own last autosave of the draft, which is what editor presence is +-- derived from. It is distinct from UpdateAt, which can also be bumped by internal maintenance +-- writes that do not reflect user activity and would otherwise report the user as an active editor. +ALTER TABLE DOCS_Draft ADD COLUMN IF NOT EXISTS LastActiveAt BIGINT NOT NULL DEFAULT 0; + +-- BaseEditAt is the optimistic-lock baseline: the page EditAt the client saw at edit-open, compared +-- against the page's current EditAt on publish to reject concurrent-edit conflicts. It is write-once +-- (never listed in UpsertDraft's ON CONFLICT ... DO UPDATE SET). Existing rows default to 0 (no +-- baseline), which fails closed to requiring a forced publish. +ALTER TABLE DOCS_Draft ADD COLUMN IF NOT EXISTS BaseEditAt BIGINT NOT NULL DEFAULT 0; diff --git a/server/store/page_move_test.go b/server/store/page_move_test.go index febd0c1..880c993 100644 --- a/server/store/page_move_test.go +++ b/server/store/page_move_test.go @@ -160,11 +160,11 @@ func TestMovePageToSpace_Store(t *testing.T) { // An in-progress edit draft on the page, and a pending new-page draft parented under it // (its own PageId has no page row yet). dEdit := newDraft(user, spaceA.Id, page.Id, "") - dEdit.Props = mmmodel.StringInterface{model.DraftPropsOriginalPageEditAt: page.EditAt} - _, _, err = s.UpsertDraft(dEdit, nil, nil) + dEdit.BaseEditAt = page.EditAt + _, _, err = s.UpsertDraft(dEdit, nil, nil, nil) require.NoError(t, err) parentPageID := page.Id - _, _, err = s.UpsertDraft(newDraft(user, spaceA.Id, mmmodel.NewId(), parentPageID), &parentPageID, nil) + _, _, err = s.UpsertDraft(newDraft(user, spaceA.Id, mmmodel.NewId(), parentPageID), &parentPageID, nil, nil) require.NoError(t, err) sourceBefore, err := s.GetDraftsForSpace(user, spaceA.Id, 0, testDraftListLimit) @@ -206,8 +206,8 @@ func TestMovePageToSpace_Store(t *testing.T) { // A second user holds an in-progress edit draft on the same page. otherDraft := newDraft(other, spaceA.Id, page.Id, "") - otherDraft.Props = mmmodel.StringInterface{model.DraftPropsOriginalPageEditAt: page.EditAt} - _, _, err = s.UpsertDraft(otherDraft, nil, nil) + otherDraft.BaseEditAt = page.EditAt + _, _, err = s.UpsertDraft(otherDraft, nil, nil, nil) require.NoError(t, err) _, _, err = s.MovePageToSpace(page.Id, spaceA.Id, spaceB.Id, mover, nil, page.UpdateAt, false, store.MaxPageHierarchyDepth) @@ -237,8 +237,8 @@ func TestMovePageToSpace_Store(t *testing.T) { require.NoError(t, err) // One mover draft on the page to be moved. editDraft := newDraft(mover, spaceA.Id, page.Id, "") - editDraft.Props = mmmodel.StringInterface{model.DraftPropsOriginalPageEditAt: page.EditAt} - _, _, err = s.UpsertDraft(editDraft, nil, nil) + editDraft.BaseEditAt = page.EditAt + _, _, err = s.UpsertDraft(editDraft, nil, nil, nil) require.NoError(t, err) chB := mmmodel.NewId() @@ -246,7 +246,7 @@ func TestMovePageToSpace_Store(t *testing.T) { require.NoError(t, err) // Fill the mover's quota in the target space, so re-homing even one more trips the cap. for range store.MaxDraftsPerUserPerSpace { - _, _, err = s.UpsertDraft(newDraft(mover, spaceB.Id, mmmodel.NewId(), ""), nil, nil) + _, _, err = s.UpsertDraft(newDraft(mover, spaceB.Id, mmmodel.NewId(), ""), nil, nil, nil) require.NoError(t, err) } @@ -277,8 +277,8 @@ func TestMovePageToSpace_Store(t *testing.T) { require.NoError(t, err) editDraft := newDraft(user, spaceA.Id, page.Id, "") - editDraft.Props = mmmodel.StringInterface{model.DraftPropsOriginalPageEditAt: page.EditAt} - _, _, err = s.UpsertDraft(editDraft, nil, nil) + editDraft.BaseEditAt = page.EditAt + _, _, err = s.UpsertDraft(editDraft, nil, nil, nil) require.NoError(t, err) windowStart := mmmodel.GetMillis() - 60*1000 @@ -594,8 +594,8 @@ func TestMovePageToSpace_ConcurrentAutosaveInvariants(t *testing.T) { // An in-progress edit draft on the page, baselined at the page's current EditAt. seed := newDraft(user, spaceA.Id, page.Id, "") - seed.Props = mmmodel.StringInterface{model.DraftPropsOriginalPageEditAt: page.EditAt} - _, _, err = s.UpsertDraft(seed, nil, nil) + seed.BaseEditAt = page.EditAt + _, _, err = s.UpsertDraft(seed, nil, nil, nil) require.NoError(t, err) // Race the move against an autosave on the same draft, released together. Both calls open @@ -615,8 +615,8 @@ func TestMovePageToSpace_ConcurrentAutosaveInvariants(t *testing.T) { defer wg.Done() <-start autosave := newDraft(user, spaceA.Id, page.Id, "") - autosave.Props = mmmodel.StringInterface{model.DraftPropsOriginalPageEditAt: page.EditAt} - _, _, _ = s.UpsertDraft(autosave, nil, nil) + autosave.BaseEditAt = page.EditAt + _, _, _ = s.UpsertDraft(autosave, nil, nil, nil) }() close(start) wg.Wait() diff --git a/server/store/page_store.go b/server/store/page_store.go index ad37af5..89135e5 100644 --- a/server/store/page_store.go +++ b/server/store/page_store.go @@ -816,7 +816,8 @@ func (s *Store) PublishDraft(isNewPage bool, page *model.Page, userID, spaceID s // Apply only the fields the draft carried against the locked row, preserving current's value // for any empty (unset) field. An empty Title/Body means "not sent" (a cleared document is // EmptyTipTapJSON, not ""), so a partial autosave never wipes an untouched field — and a - // force-publish cannot revert a concurrent edit to a field this draft did not change. + // force-publish cannot revert a concurrent edit to a field this draft did not change. Props + // follow the same rule: a non-empty map replaces, an empty/nil map preserves current's props. if page.Title != "" { current.Title = page.Title } @@ -824,6 +825,9 @@ func (s *Store) PublishDraft(isNewPage bool, page *model.Page, userID, spaceID s current.Body = page.Body current.SearchText = page.SearchText } + if len(page.Props) > 0 { + current.Props = page.Props + } current.LastModifiedBy = page.LastModifiedBy current.PreUpdate() if validErr := current.IsValid(); validErr != nil { diff --git a/server/store/store_test.go b/server/store/store_test.go index 077f2f4..81f9094 100644 --- a/server/store/store_test.go +++ b/server/store/store_test.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "runtime" + "strings" "sync" "sync/atomic" "testing" @@ -1018,12 +1019,12 @@ func TestDeletePage(t *testing.T) { otherUserID := mmmodel.NewId() withBaseline := func(uid string) *model.Draft { d := newDraft(uid, space.Id, created.Id, "") - d.Props = mmmodel.StringInterface{model.DraftPropsOriginalPageEditAt: created.EditAt} + d.BaseEditAt = created.EditAt return d } - _, _, err = s.UpsertDraft(withBaseline(userID), nil, nil) + _, _, err = s.UpsertDraft(withBaseline(userID), nil, nil, nil) require.NoError(t, err) - _, _, err = s.UpsertDraft(withBaseline(otherUserID), nil, nil) + _, _, err = s.UpsertDraft(withBaseline(otherUserID), nil, nil, nil) require.NoError(t, err) require.NoError(t, deletePageErr(s, created.Id, created.SpaceId, userID)) @@ -1065,7 +1066,7 @@ func TestDeletePage(t *testing.T) { // New-page draft whose pending parent is the published page. draftPageID := mmmodel.NewId() parentID := parent.Id - _, _, err = s.UpsertDraft(newDraft(userID, space.Id, draftPageID, ""), &parentID, nil) + _, _, err = s.UpsertDraft(newDraft(userID, space.Id, draftPageID, ""), &parentID, nil, nil) require.NoError(t, err) // Force the draft's stored UpdateAt ahead of wall clock, so a plain SET UpdateAt=now would move @@ -1660,7 +1661,7 @@ func TestDraft(t *testing.T) { require.NoError(t, spaceErr) spaceID := space.Id - saved, _, err := s.UpsertDraft(newDraft(userID, spaceID, pageID, ""), nil, nil) + saved, _, err := s.UpsertDraft(newDraft(userID, spaceID, pageID, ""), nil, nil, nil) require.NoError(t, err) require.NotZero(t, saved.CreateAt) @@ -1678,13 +1679,13 @@ func TestDraft(t *testing.T) { require.NoError(t, spaceErr) spaceID := space.Id - first, _, err := s.UpsertDraft(newDraft(userID, spaceID, pageID, ""), nil, nil) + first, _, err := s.UpsertDraft(newDraft(userID, spaceID, pageID, ""), nil, nil, nil) require.NoError(t, err) second := newDraft(userID, spaceID, pageID, "") second.CreateAt = first.CreateAt second.Title = "Updated" - _, _, err = s.UpsertDraft(second, nil, nil) + _, _, err = s.UpsertDraft(second, nil, nil, nil) require.NoError(t, err) got, err := s.GetDraft(userID, pageID) @@ -1702,8 +1703,8 @@ func TestDraft(t *testing.T) { full := newDraft(userID, space.Id, pageID, "") full.Title = "Original title" full.Body = `{"type":"doc","content":[{"type":"paragraph"}]}` - full.Props = mmmodel.StringInterface{model.DraftPropsOriginalPageEditAt: float64(1234)} - stored, _, err := s.UpsertDraft(full, nil, nil) + full.BaseEditAt = 1234 + stored, _, err := s.UpsertDraft(full, nil, nil, nil) require.NoError(t, err) // A body-only heartbeat: no title, no props. Neither may be wiped. @@ -1711,19 +1712,19 @@ func TestDraft(t *testing.T) { bodyOnly.Title = "" bodyOnly.Body = `{"type":"doc","content":[{"type":"paragraph"},{"type":"paragraph"}]}` bodyOnly.Props = nil - saved, _, err := s.UpsertDraft(bodyOnly, nil, nil) + saved, _, err := s.UpsertDraft(bodyOnly, nil, nil, nil) require.NoError(t, err) require.Equal(t, "Original title", saved.Title, "an omitted title must not wipe the stored one") require.Equal(t, bodyOnly.Body, saved.Body, "the sent body must be written") - require.Equal(t, float64(1234), saved.Props[model.DraftPropsOriginalPageEditAt], - "an omitted prop must not drop the stored optimistic-lock baseline") + require.Equal(t, int64(1234), saved.BaseEditAt, + "an omitted baseline must not drop the stored optimistic-lock baseline") require.Equal(t, stored.CreateAt, saved.CreateAt, "CreateAt preserved across upsert") // A title-only heartbeat: no body. The body just written must survive. titleOnly := newDraft(userID, space.Id, pageID, "") titleOnly.Title = "Renamed" titleOnly.Body = "" - saved, _, err = s.UpsertDraft(titleOnly, nil, nil) + saved, _, err = s.UpsertDraft(titleOnly, nil, nil, nil) require.NoError(t, err) require.Equal(t, "Renamed", saved.Title) require.Equal(t, bodyOnly.Body, saved.Body, "an omitted body must not wipe the stored one") @@ -1737,9 +1738,9 @@ func TestDraft(t *testing.T) { spaceID := space.Id userA, userB := mmmodel.NewId(), mmmodel.NewId() - _, _, err := s.UpsertDraft(newDraft(userA, spaceID, pageID, ""), nil, nil) + _, _, err := s.UpsertDraft(newDraft(userA, spaceID, pageID, ""), nil, nil, nil) require.NoError(t, err) - _, _, err = s.UpsertDraft(newDraft(userB, spaceID, pageID, ""), nil, nil) + _, _, err = s.UpsertDraft(newDraft(userB, spaceID, pageID, ""), nil, nil, nil) require.NoError(t, err) gotA, err := s.GetDraft(userA, pageID) @@ -1757,7 +1758,7 @@ func TestDraft(t *testing.T) { require.NoError(t, spaceErr) spaceID := space.Id - _, _, err := s.UpsertDraft(newDraft(userID, spaceID, pageID, ""), nil, nil) + _, _, err := s.UpsertDraft(newDraft(userID, spaceID, pageID, ""), nil, nil, nil) require.NoError(t, err) require.NoError(t, s.DeleteDraft(userID, pageID)) @@ -1784,9 +1785,9 @@ func TestDraft(t *testing.T) { space, err := s.CreateSpace(newSpace(mmmodel.NewId())) require.NoError(t, err) - _, _, err = s.UpsertDraft(newDraft(userID, space.Id, mmmodel.NewId(), ""), nil, nil) + _, _, err = s.UpsertDraft(newDraft(userID, space.Id, mmmodel.NewId(), ""), nil, nil, nil) require.NoError(t, err) - second, _, err := s.UpsertDraft(newDraft(userID, space.Id, mmmodel.NewId(), ""), nil, nil) + second, _, err := s.UpsertDraft(newDraft(userID, space.Id, mmmodel.NewId(), ""), nil, nil, nil) require.NoError(t, err) drafts, err := s.GetDraftsForSpace(userID, space.Id, 0, testDraftListLimit) @@ -1801,7 +1802,7 @@ func TestDraft(t *testing.T) { space, err := s.CreateSpace(newSpace(mmmodel.NewId())) require.NoError(t, err) - draft, _, err := s.UpsertDraft(newDraft(userID, space.Id, mmmodel.NewId(), ""), nil, nil) + draft, _, err := s.UpsertDraft(newDraft(userID, space.Id, mmmodel.NewId(), ""), nil, nil, nil) require.NoError(t, err) require.NoError(t, s.DeleteSpace(space.Id)) @@ -1862,7 +1863,7 @@ func TestDraft(t *testing.T) { pageInB, err := s.CreatePage(newPage(spaceB.Id, spaceB.ChannelId, userID, ""), testDefaultMaxDepth) require.NoError(t, err) - _, _, err = s.UpsertDraft(newDraft(userID, spaceA.Id, pageInB.Id, ""), nil, nil) + _, _, err = s.UpsertDraft(newDraft(userID, spaceA.Id, pageInB.Id, ""), nil, nil, nil) require.True(t, store.IsErrInvalidInput(err), "expected invalid input for a cross-space page, got %v", err) }) @@ -1877,16 +1878,16 @@ func TestDraft(t *testing.T) { live, err := s.CreatePage(newPage(space.Id, channelID, userID, ""), testDefaultMaxDepth) require.NoError(t, err) dLive := newDraft(userID, space.Id, live.Id, "") - dLive.Props = mmmodel.StringInterface{model.DraftPropsOriginalPageEditAt: live.EditAt} - _, _, err = s.UpsertDraft(dLive, nil, nil) + dLive.BaseEditAt = live.EditAt + _, _, err = s.UpsertDraft(dLive, nil, nil, nil) require.NoError(t, err) // A draft whose page is soft-deleted is excluded. deleted, err := s.CreatePage(newPage(space.Id, channelID, userID, ""), testDefaultMaxDepth) require.NoError(t, err) dDeleted := newDraft(userID, space.Id, deleted.Id, "") - dDeleted.Props = mmmodel.StringInterface{model.DraftPropsOriginalPageEditAt: deleted.EditAt} - _, _, err = s.UpsertDraft(dDeleted, nil, nil) + dDeleted.BaseEditAt = deleted.EditAt + _, _, err = s.UpsertDraft(dDeleted, nil, nil, nil) require.NoError(t, err) require.NoError(t, deletePageErr(s, deleted.Id, deleted.SpaceId, userID)) @@ -1911,8 +1912,8 @@ func TestDraft(t *testing.T) { snap, err := s.CreatePage(newPage(space.Id, channelID, userID, ""), testDefaultMaxDepth) require.NoError(t, err) dSnap := newDraft(userID, space.Id, snap.Id, "") - dSnap.Props = mmmodel.StringInterface{model.DraftPropsOriginalPageEditAt: snap.EditAt} - _, _, err = s.UpsertDraft(dSnap, nil, nil) + dSnap.BaseEditAt = snap.EditAt + _, _, err = s.UpsertDraft(dSnap, nil, nil, nil) require.NoError(t, err) _, rawErr := s.ExecBuilderForTest(s.QueryBuilderForTest(). Update("DOCS_Page"). @@ -1938,7 +1939,7 @@ func TestDraft(t *testing.T) { require.NoError(t, deletePageErr(s, page.Id, page.SpaceId, userID)) // An autosave landing after the page was deleted must not recreate a draft for it. - _, _, err = s.UpsertDraft(newDraft(userID, space.Id, page.Id, ""), nil, nil) + _, _, err = s.UpsertDraft(newDraft(userID, space.Id, page.Id, ""), nil, nil, nil) require.True(t, store.IsErrInvalidInput(err), "expected invalid input for a deleted page, got %v", err) }) @@ -1948,7 +1949,7 @@ func TestDraft(t *testing.T) { require.NoError(t, err) require.NoError(t, s.DeleteSpace(space.Id)) - _, _, err = s.UpsertDraft(newDraft(mmmodel.NewId(), space.Id, mmmodel.NewId(), ""), nil, nil) + _, _, err = s.UpsertDraft(newDraft(mmmodel.NewId(), space.Id, mmmodel.NewId(), ""), nil, nil, nil) require.True(t, store.IsErrNotFound(err), "expected not-found for a deleted space, got %v", err) }) @@ -1963,7 +1964,7 @@ func TestDraft(t *testing.T) { require.NoError(t, err) parentID := parent.Id - saved, _, err := s.UpsertDraft(newDraft(userID, space.Id, mmmodel.NewId(), parentID), &parentID, nil) + saved, _, err := s.UpsertDraft(newDraft(userID, space.Id, mmmodel.NewId(), parentID), &parentID, nil, nil) require.NoError(t, err) require.Equal(t, parent.Id, saved.ParentId) }) @@ -1974,7 +1975,7 @@ func TestDraft(t *testing.T) { require.NoError(t, err) missingParentID := mmmodel.NewId() - _, _, err = s.UpsertDraft(newDraft(mmmodel.NewId(), space.Id, mmmodel.NewId(), missingParentID), &missingParentID, nil) + _, _, err = s.UpsertDraft(newDraft(mmmodel.NewId(), space.Id, mmmodel.NewId(), missingParentID), &missingParentID, nil, nil) require.True(t, store.IsErrInvalidInput(err), "expected invalid input for a missing parent, got %v", err) }) @@ -1990,7 +1991,7 @@ func TestDraft(t *testing.T) { require.NoError(t, deletePageErr(s, parent.Id, parent.SpaceId, userID)) parentID := parent.Id - _, _, err = s.UpsertDraft(newDraft(userID, space.Id, mmmodel.NewId(), parentID), &parentID, nil) + _, _, err = s.UpsertDraft(newDraft(userID, space.Id, mmmodel.NewId(), parentID), &parentID, nil, nil) require.True(t, store.IsErrInvalidInput(err), "expected invalid input for a deleted parent, got %v", err) }) @@ -2007,7 +2008,7 @@ func TestDraft(t *testing.T) { require.NoError(t, err) parentID := parentInB.Id - _, _, err = s.UpsertDraft(newDraft(userID, spaceA.Id, mmmodel.NewId(), parentID), &parentID, nil) + _, _, err = s.UpsertDraft(newDraft(userID, spaceA.Id, mmmodel.NewId(), parentID), &parentID, nil, nil) require.True(t, store.IsErrInvalidInput(err), "expected invalid input for a cross-space parent, got %v", err) }) @@ -2017,11 +2018,11 @@ func TestDraft(t *testing.T) { require.NoError(t, err) userID := mmmodel.NewId() - parentDraft, _, err := s.UpsertDraft(newDraft(userID, space.Id, mmmodel.NewId(), ""), nil, nil) + parentDraft, _, err := s.UpsertDraft(newDraft(userID, space.Id, mmmodel.NewId(), ""), nil, nil, nil) require.NoError(t, err) parentPageID := parentDraft.PageId - saved, _, err := s.UpsertDraft(newDraft(userID, space.Id, mmmodel.NewId(), parentPageID), &parentPageID, nil) + saved, _, err := s.UpsertDraft(newDraft(userID, space.Id, mmmodel.NewId(), parentPageID), &parentPageID, nil, nil) require.NoError(t, err) require.Equal(t, parentDraft.PageId, saved.ParentId) }) @@ -2032,11 +2033,11 @@ func TestDraft(t *testing.T) { require.NoError(t, err) userA, userB := mmmodel.NewId(), mmmodel.NewId() - otherDraft, _, err := s.UpsertDraft(newDraft(userA, space.Id, mmmodel.NewId(), ""), nil, nil) + otherDraft, _, err := s.UpsertDraft(newDraft(userA, space.Id, mmmodel.NewId(), ""), nil, nil, nil) require.NoError(t, err) otherPageID := otherDraft.PageId - _, _, err = s.UpsertDraft(newDraft(userB, space.Id, mmmodel.NewId(), otherPageID), &otherPageID, nil) + _, _, err = s.UpsertDraft(newDraft(userB, space.Id, mmmodel.NewId(), otherPageID), &otherPageID, nil, nil) require.True(t, store.IsErrInvalidInput(err), "expected invalid input for another user's draft parent, got %v", err) }) @@ -2049,15 +2050,15 @@ func TestDraft(t *testing.T) { require.NoError(t, err) userID := mmmodel.NewId() - rootDraft, _, err := s.UpsertDraft(newDraft(userID, space.Id, mmmodel.NewId(), ""), nil, nil) + rootDraft, _, err := s.UpsertDraft(newDraft(userID, space.Id, mmmodel.NewId(), ""), nil, nil, nil) require.NoError(t, err) rootPageID := rootDraft.PageId - childDraft, _, err := s.UpsertDraft(newDraft(userID, space.Id, mmmodel.NewId(), rootPageID), &rootPageID, nil) + childDraft, _, err := s.UpsertDraft(newDraft(userID, space.Id, mmmodel.NewId(), rootPageID), &rootPageID, nil, nil) require.NoError(t, err) childPageID := childDraft.PageId - _, _, err = s.UpsertDraft(newDraft(userID, space.Id, rootDraft.PageId, childPageID), &childPageID, nil) + _, _, err = s.UpsertDraft(newDraft(userID, space.Id, rootDraft.PageId, childPageID), &childPageID, nil, nil) require.Error(t, err) var inv *store.ErrInvalidInput require.True(t, errors.As(err, &inv), "expected ErrInvalidInput, got %T: %v", err, err) @@ -2083,13 +2084,13 @@ func TestDraft(t *testing.T) { p := parentID parentParam = &p } - _, _, err = s.UpsertDraft(newDraft(userID, space.Id, pageID, parentID), parentParam, nil) + _, _, err = s.UpsertDraft(newDraft(userID, space.Id, pageID, parentID), parentParam, nil, nil) require.NoError(t, err) parentID = pageID } deepestParentID := parentID - _, _, err = s.UpsertDraft(newDraft(userID, space.Id, mmmodel.NewId(), deepestParentID), &deepestParentID, nil) + _, _, err = s.UpsertDraft(newDraft(userID, space.Id, mmmodel.NewId(), deepestParentID), &deepestParentID, nil, nil) require.Error(t, err) var inv *store.ErrInvalidInput require.True(t, errors.As(err, &inv), "expected ErrInvalidInput, got %T: %v", err, err) @@ -2102,9 +2103,9 @@ func TestDraft(t *testing.T) { require.NoError(t, err) userA, userB := mmmodel.NewId(), mmmodel.NewId() - _, _, err = s.UpsertDraft(newDraft(userA, space.Id, mmmodel.NewId(), ""), nil, nil) + _, _, err = s.UpsertDraft(newDraft(userA, space.Id, mmmodel.NewId(), ""), nil, nil, nil) require.NoError(t, err) - _, _, err = s.UpsertDraft(newDraft(userB, space.Id, mmmodel.NewId(), ""), nil, nil) + _, _, err = s.UpsertDraft(newDraft(userB, space.Id, mmmodel.NewId(), ""), nil, nil, nil) require.NoError(t, err) drafts, err := s.GetDraftsForSpace(userA, space.Id, 0, testDraftListLimit) @@ -2124,7 +2125,7 @@ func TestDraft(t *testing.T) { d.Body = `{"type":"doc","content":[{"type":"paragraph"}]}` d.FileIds = mmmodel.StringArray{mmmodel.NewId(), mmmodel.NewId()} d.Props = mmmodel.StringInterface{"k": float64(1700000000123)} - _, _, err := s.UpsertDraft(d, nil, &d.FileIds) + _, _, err := s.UpsertDraft(d, nil, &d.FileIds, &d.Props) require.NoError(t, err) got, err := s.GetDraft(userID, pageID) @@ -2141,7 +2142,7 @@ func TestDraft(t *testing.T) { require.NoError(t, spaceErr) spaceID := space.Id - _, _, err := s.UpsertDraft(newDraft(userID, spaceID, pageID, ""), nil, nil) + _, _, err := s.UpsertDraft(newDraft(userID, spaceID, pageID, ""), nil, nil, nil) require.NoError(t, err) got, err := s.GetDraft(userID, pageID) @@ -2164,13 +2165,13 @@ func TestDraft(t *testing.T) { require.NoError(t, err) firstParent, secondParent := firstPage.Id, secondPage.Id - _, _, err = s.UpsertDraft(newDraft(userID, spaceID, pageID, firstParent), &firstParent, nil) + _, _, err = s.UpsertDraft(newDraft(userID, spaceID, pageID, firstParent), &firstParent, nil, nil) require.NoError(t, err) got, err := s.GetDraft(userID, pageID) require.NoError(t, err) require.Equal(t, firstParent, got.ParentId) - _, _, err = s.UpsertDraft(newDraft(userID, spaceID, pageID, secondParent), &secondParent, nil) + _, _, err = s.UpsertDraft(newDraft(userID, spaceID, pageID, secondParent), &secondParent, nil, nil) require.NoError(t, err) got, err = s.GetDraft(userID, pageID) require.NoError(t, err) @@ -2187,7 +2188,7 @@ func TestDraft(t *testing.T) { d := newDraft(userID, spaceID, pageID, "") d.Title = "Title Only" d.Body = "" - _, _, err := s.UpsertDraft(d, nil, nil) + _, _, err := s.UpsertDraft(d, nil, nil, nil) require.NoError(t, err) got, err := s.GetDraft(userID, pageID) @@ -2204,11 +2205,11 @@ func TestDraft(t *testing.T) { spaceB, err := s.CreateSpace(newSpace(mmmodel.NewId())) require.NoError(t, err) - _, _, err = s.UpsertDraft(newDraft(userID, spaceA.Id, mmmodel.NewId(), ""), nil, nil) + _, _, err = s.UpsertDraft(newDraft(userID, spaceA.Id, mmmodel.NewId(), ""), nil, nil, nil) require.NoError(t, err) - _, _, err = s.UpsertDraft(newDraft(userID, spaceA.Id, mmmodel.NewId(), ""), nil, nil) + _, _, err = s.UpsertDraft(newDraft(userID, spaceA.Id, mmmodel.NewId(), ""), nil, nil, nil) require.NoError(t, err) - _, _, err = s.UpsertDraft(newDraft(userID, spaceB.Id, mmmodel.NewId(), ""), nil, nil) + _, _, err = s.UpsertDraft(newDraft(userID, spaceB.Id, mmmodel.NewId(), ""), nil, nil, nil) require.NoError(t, err) drafts, err := s.GetDraftsForSpace(userID, spaceA.Id, 0, testDraftListLimit) @@ -2232,11 +2233,11 @@ func TestDraft(t *testing.T) { // Upsert runs the full model IsValid, so a malformed (non-empty) id is rejected as // invalid input. - _, _, err := s.UpsertDraft(newDraft("bad", valid, valid, ""), nil, nil) + _, _, err := s.UpsertDraft(newDraft("bad", valid, valid, ""), nil, nil, nil) require.True(t, store.IsErrInvalidInput(err), "upsert with bad user id, got %v", err) // Upsert with nil draft must return ErrInvalidInput. - _, _, err = s.UpsertDraft(nil, nil, nil) + _, _, err = s.UpsertDraft(nil, nil, nil, nil) require.True(t, store.IsErrInvalidInput(err), "upsert nil draft, got %v", err) // Get/Delete guard only against empty ids (matching the page/space store convention); @@ -2292,7 +2293,7 @@ func TestDeletePageReparentsPendingDrafts(t *testing.T) { // A new-page draft (its own page not yet created) pending as a child of parent. newPageID := mmmodel.NewId() parentID := parent.Id - _, _, err = s.UpsertDraft(newDraft(userID, space.Id, newPageID, parentID), &parentID, nil) + _, _, err = s.UpsertDraft(newDraft(userID, space.Id, newPageID, parentID), &parentID, nil, nil) require.NoError(t, err) require.NoError(t, deletePageErr(s, parent.Id, parent.SpaceId, userID)) @@ -2522,7 +2523,7 @@ func TestGetActiveEditorsForPage(t *testing.T) { pageID := mmmodel.NewId() userID := mmmodel.NewId() - _, _, err = s.UpsertDraft(newDraft(userID, space.Id, pageID, ""), nil, nil) + _, _, err = s.UpsertDraft(newDraft(userID, space.Id, pageID, ""), nil, nil, nil) require.NoError(t, err) now := mmmodel.GetMillis() @@ -2549,7 +2550,7 @@ func TestGetActiveEditorsForPage(t *testing.T) { require.NoError(t, err) otherUser := mmmodel.NewId() // Same (reserved) pageID, different space and user — an unpublished new-page draft. - _, _, err = s.UpsertDraft(newDraft(otherUser, otherSpace.Id, pageID, ""), nil, nil) + _, _, err = s.UpsertDraft(newDraft(otherUser, otherSpace.Id, pageID, ""), nil, nil, nil) require.NoError(t, err) editors, err := s.GetPageActiveEditors(pageID, space.Id, mmmodel.GetMillis()-5*60*1000) @@ -2579,9 +2580,9 @@ func TestGetActiveEditorsForPageMultipleEditorsOrderedByLastActiveAt(t *testing. pageID := mmmodel.NewId() userA, userB := mmmodel.NewId(), mmmodel.NewId() - _, _, err = s.UpsertDraft(newDraft(userA, space.Id, pageID, ""), nil, nil) + _, _, err = s.UpsertDraft(newDraft(userA, space.Id, pageID, ""), nil, nil, nil) require.NoError(t, err) - _, _, err = s.UpsertDraft(newDraft(userB, space.Id, pageID, ""), nil, nil) + _, _, err = s.UpsertDraft(newDraft(userB, space.Id, pageID, ""), nil, nil, nil) require.NoError(t, err) // Push userA's LastActiveAt into the past so userB (more recent) should appear first. @@ -2615,7 +2616,7 @@ func TestGetActiveEditorsForPageIgnoresMaintenanceWrites(t *testing.T) { // A new-page draft pending under the parent, last actually edited well outside the window. childPageID := mmmodel.NewId() parentID := parent.Id - _, _, err = s.UpsertDraft(newDraft(userID, space.Id, childPageID, parentID), &parentID, nil) + _, _, err = s.UpsertDraft(newDraft(userID, space.Id, childPageID, parentID), &parentID, nil, nil) require.NoError(t, err) stale := mmmodel.GetMillis() - 60*60*1000 @@ -2648,7 +2649,7 @@ func TestUpsertDraftBumpsUpdateAtMonotonically(t *testing.T) { userID := mmmodel.NewId() pageID := mmmodel.NewId() - _, _, err = s.UpsertDraft(newDraft(userID, space.Id, pageID, ""), nil, nil) + _, _, err = s.UpsertDraft(newDraft(userID, space.Id, pageID, ""), nil, nil, nil) require.NoError(t, err) // Force the stored UpdateAt ahead of the next save's wall clock. Without the monotonic bump, @@ -2660,7 +2661,7 @@ func TestUpsertDraftBumpsUpdateAtMonotonically(t *testing.T) { Where(sq.Eq{"UserId": userID, "PageId": pageID})) require.NoError(t, err) - saved, _, err := s.UpsertDraft(newDraft(userID, space.Id, pageID, ""), nil, nil) + saved, _, err := s.UpsertDraft(newDraft(userID, space.Id, pageID, ""), nil, nil, nil) require.NoError(t, err) require.Equal(t, future+1, saved.UpdateAt, "UpdateAt must advance to stored+1 when the incoming timestamp is not already greater") @@ -2673,7 +2674,7 @@ func TestDeleteDraftVersion(t *testing.T) { userID := mmmodel.NewId() pageID := mmmodel.NewId() - saved, _, err := s.UpsertDraft(newDraft(userID, space.Id, pageID, ""), nil, nil) + saved, _, err := s.UpsertDraft(newDraft(userID, space.Id, pageID, ""), nil, nil, nil) require.NoError(t, err) t.Run("stale version deletes nothing and leaves the draft intact", func(t *testing.T) { @@ -2711,7 +2712,7 @@ func TestPublishDraft(t *testing.T) { userID := mmmodel.NewId() pageID := mmmodel.NewId() - draft, _, err := s.UpsertDraft(newDraft(userID, space.Id, pageID, ""), nil, nil) + draft, _, err := s.UpsertDraft(newDraft(userID, space.Id, pageID, ""), nil, nil, nil) require.NoError(t, err) page := &model.Page{Id: pageID, SpaceId: space.Id, Title: "Published", Body: `{"type":"doc","content":[]}`, UserId: userID} @@ -2737,8 +2738,8 @@ func TestPublishDraft(t *testing.T) { created, err := s.CreatePage(newPage(space.Id, channelID, userID, ""), testDefaultMaxDepth) require.NoError(t, err) d := newDraft(userID, space.Id, created.Id, "") - d.Props = mmmodel.StringInterface{model.DraftPropsOriginalPageEditAt: created.EditAt} - draft, _, err := s.UpsertDraft(d, nil, nil) + d.BaseEditAt = created.EditAt + draft, _, err := s.UpsertDraft(d, nil, nil, nil) require.NoError(t, err) edit := *created @@ -2760,8 +2761,8 @@ func TestPublishDraft(t *testing.T) { created, err := s.CreatePage(newPage(space.Id, channelID, userID, ""), testDefaultMaxDepth) require.NoError(t, err) d2 := newDraft(userID, space.Id, created.Id, "") - d2.Props = mmmodel.StringInterface{model.DraftPropsOriginalPageEditAt: created.EditAt} - draft, _, err := s.UpsertDraft(d2, nil, nil) + d2.BaseEditAt = created.EditAt + draft, _, err := s.UpsertDraft(d2, nil, nil, nil) require.NoError(t, err) edit := *created @@ -2788,15 +2789,15 @@ func TestPublishDraft(t *testing.T) { created, err := s.CreatePage(newPage(space.Id, channelID, userID, ""), testDefaultMaxDepth) require.NoError(t, err) d3 := newDraft(userID, space.Id, created.Id, "") - d3.Props = mmmodel.StringInterface{model.DraftPropsOriginalPageEditAt: created.EditAt} - stale, _, err := s.UpsertDraft(d3, nil, nil) + d3.BaseEditAt = created.EditAt + stale, _, err := s.UpsertDraft(d3, nil, nil, nil) require.NoError(t, err) // The user's editor autosaves again after the publish path read the draft. newer := newDraft(userID, space.Id, created.Id, "") newer.Body = `{"type":"doc","content":[{"type":"paragraph"}]}` - newer.Props = mmmodel.StringInterface{model.DraftPropsOriginalPageEditAt: created.EditAt} - newer, _, err = s.UpsertDraft(newer, nil, nil) + newer.BaseEditAt = created.EditAt + newer, _, err = s.UpsertDraft(newer, nil, nil, nil) require.NoError(t, err) require.Greater(t, newer.UpdateAt, stale.UpdateAt, "the autosave must advance UpdateAt") @@ -2818,3 +2819,256 @@ func TestPublishDraft(t *testing.T) { require.Equal(t, newer.Body, survived.Body) }) } + +// TestUpsertDraftBaseEditAtWriteOnce verifies BaseEditAt is frozen at the establishing INSERT: a +// later upsert on the same (UserId, PageId) key carries a different BaseEditAt, but the stored +// (and returned) value never moves off the value the draft was established with. +func TestUpsertDraftBaseEditAtWriteOnce(t *testing.T) { + s := openTestDB(t) + + channelID := mmmodel.NewId() + userID := mmmodel.NewId() + space, err := s.CreateSpace(newSpace(channelID)) + require.NoError(t, err) + page, err := s.CreatePage(newPage(space.Id, channelID, userID, ""), testDefaultMaxDepth) + require.NoError(t, err) + + established := newDraft(userID, space.Id, page.Id, "") + established.BaseEditAt = page.EditAt + saved, _, err := s.UpsertDraft(established, nil, nil, nil) + require.NoError(t, err) + require.Equal(t, page.EditAt, saved.BaseEditAt) + + later := newDraft(userID, space.Id, page.Id, "") + later.BaseEditAt = page.EditAt + 1000 + updated, _, err := s.UpsertDraft(later, nil, nil, nil) + require.NoError(t, err) + require.Equal(t, page.EditAt, updated.BaseEditAt, + "BaseEditAt is write-once: a later upsert must not change the established baseline") + + // The persisted row (not just the returned struct) must reflect the same frozen value. + persisted, err := s.GetDraft(userID, page.Id) + require.NoError(t, err) + require.Equal(t, page.EditAt, persisted.BaseEditAt) +} + +// TestUpsertDraftPropsReplaceOrKeep verifies the whole-value replace-or-keep semantics of the props +// write-intent pointer: nil preserves the stored map untouched, a non-nil pointer replaces the whole +// map (dropping any key it doesn't carry), and a non-nil pointer to an empty map clears every key. +func TestUpsertDraftPropsReplaceOrKeep(t *testing.T) { + s := openTestDB(t) + + userID, pageID := mmmodel.NewId(), mmmodel.NewId() + space, err := s.CreateSpace(newSpace(mmmodel.NewId())) + require.NoError(t, err) + + d := newDraft(userID, space.Id, pageID, "") + d.Props = mmmodel.StringInterface{"foo": "bar"} + stored, _, err := s.UpsertDraft(d, nil, nil, &d.Props) + require.NoError(t, err) + require.Equal(t, "bar", stored.Props["foo"]) + + // A nil props pointer omits the write and preserves the stored map. + omit := newDraft(userID, space.Id, pageID, "") + afterOmit, _, err := s.UpsertDraft(omit, nil, nil, nil) + require.NoError(t, err) + require.Equal(t, "bar", afterOmit.Props["foo"], "a nil props pointer must preserve the stored map") + + // A non-nil props pointer replaces the whole map: the unrelated "foo" key set above is gone, + // not merged with the new "baz" key. + replace := newDraft(userID, space.Id, pageID, "") + replace.Props = mmmodel.StringInterface{"baz": "qux"} + afterReplace, _, err := s.UpsertDraft(replace, nil, nil, &replace.Props) + require.NoError(t, err) + require.Equal(t, "qux", afterReplace.Props["baz"]) + require.NotContains(t, afterReplace.Props, "foo", + "a non-nil props pointer must replace the whole map, not merge keys") + + // A non-nil pointer to an empty map clears every key. + toClear := newDraft(userID, space.Id, pageID, "") + emptyProps := mmmodel.StringInterface{} + cleared, _, err := s.UpsertDraft(toClear, nil, nil, &emptyProps) + require.NoError(t, err) + require.Empty(t, cleared.Props, "a non-nil pointer to an empty map must clear all keys") +} + +// TestUpsertDraftOversizedPropsRejected verifies the store rejects a draft whose Props field +// (the field Draft.IsValid actually checks) exceeds PagePropsMaxBytes, regardless of what the +// props write-intent pointer carries. This is enforced by Draft.IsValid, not by the pointer's +// contents — sizing the pointer's target (rather than draft.Props) is the App layer's job. +func TestUpsertDraftOversizedPropsRejected(t *testing.T) { + s := openTestDB(t) + + userID, pageID := mmmodel.NewId(), mmmodel.NewId() + space, err := s.CreateSpace(newSpace(mmmodel.NewId())) + require.NoError(t, err) + + d := newDraft(userID, space.Id, pageID, "") + d.Props = mmmodel.StringInterface{"k": strings.Repeat("x", model.PagePropsMaxBytes)} + _, _, err = s.UpsertDraft(d, nil, nil, &d.Props) + require.Error(t, err) + require.True(t, store.IsErrInvalidInput(err), "oversized draft.Props must be rejected by Draft.IsValid, got %v", err) +} + +// TestUpsertDraftEstablishGuardRejectsBaselineAheadOfPage verifies the establish-time guard: an +// establishing INSERT (no existing draft row) whose BaseEditAt is ahead of the live page's current +// EditAt is impossible (the client cannot have seen a version newer than the one that exists) and +// is rejected as invalid input. A baseline equal to the page's EditAt is accepted; a baseline +// behind it is not caught by this guard but is still rejected by the separate resurrection check +// (see TestUpsertDraftResurrectionClassification). +func TestUpsertDraftEstablishGuardRejectsBaselineAheadOfPage(t *testing.T) { + s := openTestDB(t) + + channelID := mmmodel.NewId() + userID := mmmodel.NewId() + space, err := s.CreateSpace(newSpace(channelID)) + require.NoError(t, err) + + t.Run("ahead of the live page is rejected", func(t *testing.T) { + page, err := s.CreatePage(newPage(space.Id, channelID, userID, ""), testDefaultMaxDepth) + require.NoError(t, err) + + ahead := newDraft(userID, space.Id, page.Id, "") + ahead.BaseEditAt = page.EditAt + 1000 + _, _, err = s.UpsertDraft(ahead, nil, nil, nil) + require.Error(t, err) + var inv *store.ErrInvalidInput + require.True(t, errors.As(err, &inv), "expected ErrInvalidInput, got %T: %v", err, err) + require.Equal(t, "BaseEditAt", inv.Field) + }) + + t.Run("equal to the live page is accepted", func(t *testing.T) { + page, err := s.CreatePage(newPage(space.Id, channelID, userID, ""), testDefaultMaxDepth) + require.NoError(t, err) + + equal := newDraft(userID, space.Id, page.Id, "") + equal.BaseEditAt = page.EditAt + _, _, err = s.UpsertDraft(equal, nil, nil, nil) + require.NoError(t, err, "an establishing baseline equal to the live page's EditAt must be accepted") + }) + + // A baseline strictly behind the live page's EditAt passes the ahead-only guard above (it is + // not "ahead"), but is still rejected — by the separate resurrection check just below the + // guard, since this is still a first-ever establish (no existing draft row) and the page + // advanced past the caller's baseline. This is a real optimistic-lock conflict, not a bug: + // the client's session is already stale on its very first save. + t.Run("behind the live page is rejected as a stale baseline, not by the ahead-only guard", func(t *testing.T) { + page, err := s.CreatePage(newPage(space.Id, channelID, userID, ""), testDefaultMaxDepth) + require.NoError(t, err) + + behind := newDraft(userID, space.Id, page.Id, "") + behind.BaseEditAt = page.EditAt - 1 + _, _, err = s.UpsertDraft(behind, nil, nil, nil) + require.Error(t, err) + require.False(t, store.IsErrInvalidInput(err), "a behind baseline must not trip the ahead-only establish guard") + require.True(t, store.IsErrConflict(err), "expected ErrConflict, got %T: %v", err, err) + require.Equal(t, store.ReasonConcurrentEdit, store.ConflictReason(err)) + }) +} + +// TestUpsertDraftConcurrentFirstAutosavesSerialize verifies that two concurrent establishing +// upserts for the same (userID, pageID) on an existing page do not both take the "no existing +// draft" branch: the per-space FOR UPDATE lock (lockLiveSpace) serializes them, so only the first +// is a true establish and every later one observes the row the first inserted and is treated as an +// update — neither is falsely rejected by the establish-time guard. +func TestUpsertDraftConcurrentFirstAutosavesSerialize(t *testing.T) { + s := openTestDB(t) + + channelID := mmmodel.NewId() + userID := mmmodel.NewId() + space, err := s.CreateSpace(newSpace(channelID)) + require.NoError(t, err) + page, err := s.CreatePage(newPage(space.Id, channelID, userID, ""), testDefaultMaxDepth) + require.NoError(t, err) + + const n = 8 + start := make(chan struct{}) + var wg sync.WaitGroup + errs := make([]error, n) + wg.Add(n) + for i := range n { + go func() { + defer wg.Done() + <-start + d := newDraft(userID, space.Id, page.Id, "") + d.BaseEditAt = page.EditAt + _, _, errs[i] = s.UpsertDraft(d, nil, nil, nil) + }() + } + close(start) + wg.Wait() + + for i, uErr := range errs { + require.NoError(t, uErr, "concurrent first-autosave %d must not be falsely rejected by the establish guard", i) + } + + got, err := s.GetDraft(userID, page.Id) + require.NoError(t, err) + require.Equal(t, page.EditAt, got.BaseEditAt) +} + +// TestUpsertDraftResurrectionClassification verifies UpsertDraft distinguishes the two resurrection +// reasons: an autosave with a stale non-zero BaseEditAt behind the page's current EditAt (the page +// advanced under it) classifies as ReasonConcurrentEdit, while an autosave with no baseline (0) on a +// page id a concurrent publish just claimed classifies as ReasonConcurrentAutosave. Both fire only +// when the draft row a resurrection would recreate no longer exists (a concurrent publish consumed +// it), matching the "refuse to resurrect a consumed draft" contract in UpsertDraft. +func TestUpsertDraftResurrectionClassification(t *testing.T) { + t.Run("stale non-zero baseline behind the page classifies as concurrent edit", func(t *testing.T) { + s := openTestDB(t) + channelID := mmmodel.NewId() + userID := mmmodel.NewId() + space, err := s.CreateSpace(newSpace(channelID)) + require.NoError(t, err) + page, err := s.CreatePage(newPage(space.Id, channelID, userID, ""), testDefaultMaxDepth) + require.NoError(t, err) + + d := newDraft(userID, space.Id, page.Id, "") + d.BaseEditAt = page.EditAt + _, _, err = s.UpsertDraft(d, nil, nil, nil) + require.NoError(t, err) + + // The page is edited (advancing EditAt past the draft's baseline), and the draft is + // removed — simulating a concurrent publish that consumed it. + newTitle := "Edited concurrently" + edited, err := s.UpdatePage(page.Id, page.SpaceId, &model.PagePatch{Title: &newTitle}, page.EditAt, false, userID) + require.NoError(t, err) + require.Greater(t, edited.EditAt, page.EditAt) + require.NoError(t, s.DeleteDraft(userID, page.Id)) + + // A stale-baseline autosave tries to re-establish the now-consumed draft. + stale := newDraft(userID, space.Id, page.Id, "") + stale.BaseEditAt = page.EditAt + _, _, err = s.UpsertDraft(stale, nil, nil, nil) + require.Error(t, err) + require.True(t, store.IsErrConflict(err), "expected ErrConflict, got %T: %v", err, err) + require.Equal(t, store.ReasonConcurrentEdit, store.ConflictReason(err)) + }) + + t.Run("no baseline on a page a concurrent publish just claimed classifies as concurrent autosave", func(t *testing.T) { + s := openTestDB(t) + channelID := mmmodel.NewId() + userID := mmmodel.NewId() + space, err := s.CreateSpace(newSpace(channelID)) + require.NoError(t, err) + + pageID := mmmodel.NewId() + d := newDraft(userID, space.Id, pageID, "") + _, _, err = s.UpsertDraft(d, nil, nil, nil) + require.NoError(t, err) + + // A concurrent publish creates the page at that exact id and removes the draft. + published := newPage(space.Id, channelID, userID, "") + published.Id = pageID + _, err = s.CreatePage(published, testDefaultMaxDepth) + require.NoError(t, err) + require.NoError(t, s.DeleteDraft(userID, pageID)) + + // A baseline-less autosave tries to re-establish the now-consumed new-page draft. + stale := newDraft(userID, space.Id, pageID, "") + _, _, err = s.UpsertDraft(stale, nil, nil, nil) + require.Error(t, err) + require.True(t, store.IsErrConflict(err), "expected ErrConflict, got %T: %v", err, err) + require.Equal(t, store.ReasonConcurrentAutosave, store.ConflictReason(err)) + }) +} From f7bbfda10eca2f10356a9ce370656221fb1f8f7f Mon Sep 17 00:00:00 2001 From: "Catalin I. Tomai" Date: Wed, 22 Jul 2026 13:13:47 +0200 Subject: [PATCH 28/36] update comments - small improvs --- server/api_page_presence.go | 2 +- server/app/page_content.go | 9 +++---- server/app/page_draft.go | 44 +++++++++++++++++++++------------- server/app/pagination.go | 8 +------ server/app/ws_events.go | 7 +++--- server/model/page.go | 4 +--- server/store/draft_store.go | 2 ++ server/store/page_hierarchy.go | 5 +++- server/store/page_move.go | 4 +--- server/store/space_store.go | 2 ++ server/store/store.go | 2 ++ 11 files changed, 51 insertions(+), 38 deletions(-) diff --git a/server/api_page_presence.go b/server/api_page_presence.go index 89823d1..b67edb2 100644 --- a/server/api_page_presence.go +++ b/server/api_page_presence.go @@ -11,7 +11,7 @@ import ( // handleGetPageActiveEditors handles // GET /api/v1/spaces/{space_id}/pages/{page_id}/active-editors -// Returns the user IDs currently active on the page. +// Returns the active-editors snapshot for the page (active_editors, as_of, active_timeout_ms). func (p *Plugin) handleGetPageActiveEditors(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) spaceID := vars["space_id"] diff --git a/server/app/page_content.go b/server/app/page_content.go index 3c21e25..a505b6d 100644 --- a/server/app/page_content.go +++ b/server/app/page_content.go @@ -22,7 +22,7 @@ import ( // pages with model.EmptyTipTapJSON (a rendered-empty document). These are two DISTINCT empty // representations, and consumers may assign them different meaning: the publish path treats "" as // "field not sent, preserve the existing page body" and EmptyTipTapJSON as "explicitly cleared". -func normalizePageContent(where, body string) (string, string, *mmmodel.AppError) { +func normalizePageContent(where, body string) (normBody, searchText string, appErr *mmmodel.AppError) { normBody, searchText, err := validateAndNormalizeContent(body) if err != nil { return "", "", mmmodel.NewAppError(where, "app.page.invalid_content.app_error", nil, "", http.StatusBadRequest).Wrap(err) @@ -50,7 +50,7 @@ func normalizePatchContent(where string, patch *model.PagePatch) *mmmodel.AppErr // validateAndNormalizeContent validates and normalizes TipTap/plain-text page content. // Returns (normalizedBody, searchText, error). An empty content string is returned as-is (no-op). -func validateAndNormalizeContent(content string) (string, string, error) { +func validateAndNormalizeContent(content string) (normBody, searchText string, err error) { if content == "" { return content, "", nil } @@ -59,10 +59,11 @@ func validateAndNormalizeContent(content string) (string, string, error) { // valid JSON but not a "doc" is a genuine content error and ParseTipTapDocument rejects it. idx := strings.IndexFunc(content, func(r rune) bool { return !unicode.IsSpace(r) }) if idx >= 0 && content[idx] == '{' { - doc, err := model.ParseTipTapDocument(content) - if err == nil { + doc, parseErr := model.ParseTipTapDocument(content) + if parseErr == nil { return marshalTipTapDoc(doc) } + err = parseErr var syntaxErr *json.SyntaxError if !errors.As(err, &syntaxErr) { return "", "", err diff --git a/server/app/page_draft.go b/server/app/page_draft.go index eff50cf..f309250 100644 --- a/server/app/page_draft.go +++ b/server/app/page_draft.go @@ -105,8 +105,11 @@ func (s *Service) UpdatePageDraft(draft *model.Draft, parentID *string, fileIDs // Existing draft belongs to a different space: reject to prevent cross-space drift. return nil, mmmodel.NewAppError("UpdatePageDraft", "app.page_draft.update.page_not_found.app_error", nil, "", http.StatusNotFound) case store.IsErrNotFound(existingDraftErr): - // No draft for this user+page. Allow only if the page ID is "known" — either another - // user already reserved it via CreateSpaceDraft, or it is a published page in this space. + // No draft for THIS user+page. Allow only if the page is already published/live in the + // space — PageExistsInSpace checks DOCS_Page, not drafts. A page id reserved via + // CreateSpaceDraft (the id is allocated, but no DOCS_Page row exists yet) has only a + // DOCS_Draft row, so its author reaches this method through the "existing draft" branch + // above (user-scoped GetDraft), never here. // This prevents PATCH /spaces/X/pages//draft from ghost-drafting a non-existent page. var existsErr error pageIsLive, existsErr = s.store.PageExistsInSpace(draft.PageId, draft.SpaceId) @@ -317,10 +320,15 @@ func (s *Service) DeletePageDraft(userID, spaceID, pageID, channelID string) *mm return appErr } - // Discard is unconditional. A concurrent autosave in flight can briefly re-insert the draft after - // this commits — an unpublished new-page draft has no page row for UpsertDraft's staleness guard - // to key on. It is per-user and clears on a repeat discard, so a tombstone isn't warranted. - // + // Discard is unconditional. Autosave and discard are separate HTTP requests in separate + // transactions, so an autosave request the same user dispatched just before the discard can + // still be in flight when the discard commits and then re-insert (resurrect) this draft — the + // server does not guarantee the autosave commits before the later-issued delete. An unpublished + // new-page draft has no page row for UpsertDraft's staleness guard to key on, so the guard + // cannot tell that a discard happened (a published page is protected; only this case is not). + // The resulting zombie draft is harmless — it is visible only to its owning user (drafts are + // keyed by (UserId, PageId)), and that user removes it by discarding again — so a deletion + // tombstone to permanently block re-insertion is not warranted. pageWasLive, delErr := s.store.DeleteDraftReparenting(userID, spaceID, pageID) if delErr != nil { // A concurrent publish/delete may have removed the draft between the check above and here; @@ -466,12 +474,13 @@ func (s *Service) PublishPageDraft(userID, spaceID, pageID string, force bool) ( return nil, false, mmmodel.NewAppError("PublishPageDraft", "app.page_draft.publish.baseline_required.app_error", nil, "", http.StatusBadRequest) } - // Carry only the fields the draft actually set; leave the rest empty. The store preserves the - // live page's current value for any empty field, so an omitted field is never sourced from the - // pre-lock `existing` snapshot — otherwise a force-publish could revert a concurrent edit to a - // field this draft never touched. - // An empty draft body means "unset" (a cleared document is EmptyTipTapJSON, not ""), so an - // empty body leaves the live page's content intact rather than wiping it. + // Build pageForWrite with only the fields this draft changed; leave every other field at its + // zero value. The store treats a zero/empty field as "keep the live page's current value" and + // writes just the non-empty ones. Omitted fields are deliberately NOT copied from the pre-lock + // `existing` snapshot: that snapshot can be stale, so on a force-publish copying it back would + // overwrite a field this draft never touched and revert a concurrent edit to it. + // Body uses "" as its unset marker — a document the user cleared is stored as EmptyTipTapJSON, + // never "" — so treating an empty ("") body as unset preserves the live content instead of wiping it. pageForWrite = &model.Page{ Id: pageID, SpaceId: spaceID, @@ -495,10 +504,13 @@ func (s *Service) PublishPageDraft(userID, spaceID, pageID string, force bool) ( pageForWrite.EditAt = baseEditAt } - // A draft that carries only an optimistic-lock baseline — no Title, no Body, no Props — has no - // page change to write. Publishing it would bump EditAt and emit page_updated with no actual - // change, invalidating other editors' baselines for nothing. Treat it as a discard instead: - // delete the draft and return the page as-is. + // "Baseline-only" draft: it carries an optimistic-lock baseline but no content in any field — + // Title, Body, and Props were never populated (all empty). This is NOT a user who cleared the + // document: a cleared doc is EmptyTipTapJSON, a non-empty Body that publishes normally; empty + // here means "never sent". With every field empty there is no page change to write. Publishing + // would still bump EditAt and emit page_updated with no actual change, invalidating other + // editors' baselines for nothing. Treat it as a discard instead: delete the draft and return + // the page as-is. if pageForWrite.Title == "" && pageForWrite.Body == "" && len(pageForWrite.Props) == 0 { deleted, delErr := s.store.DeleteDraftVersion(userID, pageID, draft.UpdateAt) if delErr != nil { diff --git a/server/app/pagination.go b/server/app/pagination.go index afd9cb3..22f8c50 100644 --- a/server/app/pagination.go +++ b/server/app/pagination.go @@ -9,13 +9,7 @@ const PageMaximum = 1 << 20 // ClampPage normalizes a requested page index into [0, PageMaximum]. func ClampPage(page int) int { - if page < 0 { - return 0 - } - if page > PageMaximum { - return PageMaximum - } - return page + return min(max(page, 0), PageMaximum) } // PerPageDefault is the page size used when perPage is not a positive value, matching diff --git a/server/app/ws_events.go b/server/app/ws_events.go index 45a5d02..baa824d 100644 --- a/server/app/ws_events.go +++ b/server/app/ws_events.go @@ -34,9 +34,10 @@ const ( wsEventPageMoved = "page_moved" wsEventPageDuplicated = "page_duplicated" wsEventPageMovedToSpace = "page_moved_to_space" - // wsEventPagePresenceUpdated carries a presence snapshot ({page_id, space_id, active_editors, - // as_of, active_timeout_ms}), not the {page_id, space_id} mutation shape; it is rate-limited on - // autosave but always fires on discard and publish. + // Unlike the other page_* events above — which carry only {page_id, space_id} as a + // "something changed, refetch" signal — wsEventPagePresenceUpdated carries the full presence + // snapshot inline ({page_id, space_id, active_editors, as_of, active_timeout_ms}), so clients + // need no follow-up fetch. It is rate-limited on autosave but always fires on discard and publish. wsEventPagePresenceUpdated = "page_presence_updated" wsEventSpaceCreated = "space_created" diff --git a/server/model/page.go b/server/model/page.go index 9c0e584..827d4fd 100644 --- a/server/model/page.go +++ b/server/model/page.go @@ -113,9 +113,7 @@ func MaxDepthOfPages(pages []*Page, rootID string) int { } cur = parent } - if depth > maxDepth { - maxDepth = depth - } + maxDepth = max(maxDepth, depth) } return maxDepth } diff --git a/server/store/draft_store.go b/server/store/draft_store.go index 47a3bcd..d88ec9e 100644 --- a/server/store/draft_store.go +++ b/server/store/draft_store.go @@ -381,6 +381,8 @@ func (s *Store) UpsertDraft(draft *model.Draft, parentID *string, fileIDs *mmmod } } + // The INSERT ... VALUES is squirrel-built; squirrel cannot express ON CONFLICT ... DO UPDATE or + // RETURNING, so those are carried raw in the Suffix below. // The COALESCE in VALUES ensures NOT NULL is satisfied on INSERT; the CASE in the ON CONFLICT // clause reads the original bound parameter (not EXCLUDED.ParentId) to distinguish nil // ("omit, preserve") from "" ("explicit clear to root"). diff --git a/server/store/page_hierarchy.go b/server/store/page_hierarchy.go index ada1f86..263a634 100644 --- a/server/store/page_hierarchy.go +++ b/server/store/page_hierarchy.go @@ -27,6 +27,8 @@ var pageColListP = strings.Join(pageColumnsP, ", ") // These CTEs are built once at package init (inputs are compile-time constants) rather than on // every query. +// The hierarchy walks below use WITH RECURSIVE, which squirrel cannot express, so each CTE is built +// as a raw (parameterized) SQL string and consumed by callers that append their own SELECT. var ( pageDescendantsCTE = computeDescendantsCTE() @@ -63,7 +65,8 @@ var ( )`, MaxPageHierarchyDepth) ) -// computeDescendantsCTE generates the recursive CTE that walks the live subtree below a page, +// computeDescendantsCTE generates the recursive (WITH RECURSIVE) CTE — which squirrel cannot +// express, hence raw SQL — that walks the live subtree below a page, // excluding snapshot rows (OriginalId != "") like the ancestry and subtree CTEs above, and // excluding the root node, returning full page columns plus the node's depth. depth counts // edges below the requested page: the root is seeded at 0, so a direct child is depth 1. The diff --git a/server/store/page_move.go b/server/store/page_move.go index b403317..7167f9e 100644 --- a/server/store/page_move.go +++ b/server/store/page_move.go @@ -421,9 +421,7 @@ func (s *Store) collectLiveSubtreeIDs(tx *sqlx.Tx, pageID string) ([]string, int if row.Depth > MaxPageHierarchyDepth { return nil, 0, &ErrLimitExceeded{Resource: "Page subtree for page_id=" + pageID + " (depth)", Limit: MaxPageHierarchyDepth} } - if row.Depth > maxRelDepth { - maxRelDepth = row.Depth - } + maxRelDepth = max(maxRelDepth, row.Depth) ids = append(ids, row.ID) } return ids, maxRelDepth, nil diff --git a/server/store/space_store.go b/server/store/space_store.go index 3dcb9ff..421bf7e 100644 --- a/server/store/space_store.go +++ b/server/store/space_store.go @@ -381,6 +381,8 @@ func (s *Store) withSpaceMembershipLock(spaceID string, acquireTimeout time.Dura }() key := "space_members:" + spaceID + // The pg_try_advisory_lock / pg_advisory_unlock calls here are bare function-call SELECTs that + // squirrel does not model, so they are issued raw. // Poll with pg_try_advisory_lock rather than blocking in pg_advisory_lock: a blocking wait // canceled by the deadline races against the server granting the lock in the same instant, // which would strand a granted lock on a connection headed back to the pool. Each try diff --git a/server/store/store.go b/server/store/store.go index 1ebdf0a..0d4793a 100644 --- a/server/store/store.go +++ b/server/store/store.go @@ -220,6 +220,8 @@ func (s *Store) execBuilder(e sqlx.ExtContext, b sq.Sqlizer) (sql.Result, error) } // advisoryXactLock takes the transaction-scoped advisory lock for key, held until tx ends. +// It is a bare pg_advisory_xact_lock function-call SELECT, which squirrel does not model, so +// it is issued raw. // hashtextextended maps the key to a single bigint, so a hash collision only over-serializes // the two colliding keys' operations — added contention, never corruption, with negligible // probability. Must be called inside tx. From 94a4f54c685972073742f7a73ed30e7dcadd68d6 Mon Sep 17 00:00:00 2001 From: "Catalin I. Tomai" Date: Thu, 23 Jul 2026 11:21:19 +0200 Subject: [PATCH 29/36] address review, update comments, renaming --- server/api.go | 17 ++ server/api_page_drafts.go | 11 + server/api_page_drafts_test.go | 20 +- server/api_page_presence.go | 2 +- server/app/page_content.go | 68 ++++-- server/app/page_draft.go | 68 +++--- server/app/page_draft_test.go | 15 +- server/app/page_presence.go | 54 ++--- server/app/page_presence_test.go | 22 +- server/app/service.go | 17 +- server/app/ws_events.go | 2 +- server/app/ws_events_test.go | 14 +- server/model/page_content.go | 200 +++++++++--------- server/model/page_content_test.go | 91 +++++++- ...5_add_draft_lastactiveat_baseeditat.up.sql | 6 +- server/store/page_hierarchy.go | 3 +- server/store/page_move_test.go | 4 +- server/store/page_store.go | 4 +- 18 files changed, 400 insertions(+), 218 deletions(-) diff --git a/server/api.go b/server/api.go index 17b84a1..46d715c 100644 --- a/server/api.go +++ b/server/api.go @@ -134,6 +134,23 @@ func (p *Plugin) writeAppError(w http.ResponseWriter, appErr *mmmodel.AppError) writeJSON(w, appErr.StatusCode, &safe) } +// conflictResponse is the 409 body for an edit conflict on publish: the scrubbed AppError plus the +// current server page. It lets a client diff and re-baseline against the live page (its EditAt) in +// one round-trip instead of following up with a GET. The whole page is returned rather than a +// curated snapshot — it is the complete source of truth, and the client renders whatever it needs. +type conflictResponse struct { + Error *mmmodel.AppError `json:"error"` + CurrentPage *model.Page `json:"current_page"` +} + +// writeConflictWithPage writes a conflictResponse using the AppError's own StatusCode (409) as the +// HTTP status. DetailedError is scrubbed first, matching writeAppError. +func (p *Plugin) writeConflictWithPage(w http.ResponseWriter, appErr *mmmodel.AppError, current *model.Page) { + safe := *appErr + safe.WipeDetailed() + writeJSON(w, appErr.StatusCode, conflictResponse{Error: &safe, CurrentPage: current}) +} + // writeJSON serialises v as a JSON body with the given status. func writeJSON(w http.ResponseWriter, status int, v any) { w.Header().Set("Content-Type", "application/json") diff --git a/server/api_page_drafts.go b/server/api_page_drafts.go index 9293a1e..b66dcb2 100644 --- a/server/api_page_drafts.go +++ b/server/api_page_drafts.go @@ -129,6 +129,11 @@ func (p *Plugin) handleDeletePageDraft(w http.ResponseWriter, r *http.Request) { // handleCreateSpaceDraft handles POST /api/v1/spaces/{space_id}/drafts // It creates a new-page draft (no Pages row) with a server-generated page id, reserving the id // before the page is published so a new page has a stable link from the start. +// +// The page id is generated server-side and returned only in the response body, so the caller +// cannot address the page until this call completes: subsequent requests (autosave to +// .../pages/{page_id}/draft, publish) all key off the returned id. A new page therefore costs +// one round-trip before it is editable; the id is not caller-supplied. func (p *Plugin) handleCreateSpaceDraft(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) spaceID := vars["space_id"] @@ -186,6 +191,12 @@ func (p *Plugin) handlePublishPageDraft(w http.ResponseWriter, r *http.Request) page, wasCreated, appErr := p.service.PublishPageDraft(userID, spaceID, pageID, req.Force) if appErr != nil { + // An edit conflict returns the current server page alongside the error so the client can + // diff and re-baseline without a follow-up read; every other error carries no page. + if page != nil { + p.writeConflictWithPage(w, appErr, page) + return + } p.writeAppError(w, appErr) return } diff --git a/server/api_page_drafts_test.go b/server/api_page_drafts_test.go index 5001585..032753e 100644 --- a/server/api_page_drafts_test.go +++ b/server/api_page_drafts_test.go @@ -278,9 +278,21 @@ func TestHandler_PublishConflict409(t *testing.T) { // User A publishes against the now-stale baseline — must get 409. rec = h.do(t, http.MethodPost, base+"/pages/"+pageID+"/draft/publish", userA, nil) require.Equal(t, http.StatusConflict, rec.Code, "stale baseline must return 409 Conflict") + + // The 409 body carries the current server page so the client can diff and re-baseline without a + // follow-up read: it reflects user B's winning edit and the advanced EditAt. + var conflict struct { + Error *mmmodel.AppError `json:"error"` + CurrentPage *model.Page `json:"current_page"` + } + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &conflict)) + require.NotNil(t, conflict.Error, "conflict body must include the error") + require.NotNil(t, conflict.CurrentPage, "conflict body must include the current server page") + require.Equal(t, "Edit by B", conflict.CurrentPage.Title, "current page must reflect the winning edit") + require.Greater(t, conflict.CurrentPage.EditAt, editAt, "current page must carry the advanced baseline") } -// TestHandler_DeletePageDraft verifies the DELETE endpoint: 204 on success, draft is gone +// TestHandler_DeletePageDraft verifies the DELETE endpoint: 200 on success, draft is gone // afterwards, and 404 when no draft exists. func TestHandler_DeletePageDraft(t *testing.T) { h := openTestPlugin(t, nil) @@ -345,19 +357,19 @@ func TestHandler_ActiveEditorsResponseBody(t *testing.T) { require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &page)) // No edit draft open yet — active_editors must be an empty list, not null. The response also - // carries as_of and active_timeout_ms, mirroring the page_presence_updated WS payload so a client + // carries snapshot_at and active_timeout_ms, mirroring the page_presence_updated WS payload so a client // resyncing over REST can reason about snapshot staleness the same way. rec = h.do(t, http.MethodGet, base+"/pages/"+pageID+"/active-editors", userID, nil) require.Equal(t, http.StatusOK, rec.Code) var resp struct { ActiveEditors []string `json:"active_editors"` - AsOf int64 `json:"as_of"` + SnapshotAt int64 `json:"snapshot_at"` ActiveTimeoutMs int64 `json:"active_timeout_ms"` } require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) require.NotNil(t, resp.ActiveEditors) require.Empty(t, resp.ActiveEditors) - require.Positive(t, resp.AsOf, "response must carry the snapshot timestamp") + require.Positive(t, resp.SnapshotAt, "response must carry the snapshot timestamp") require.Equal(t, int64(5*60*1000), resp.ActiveTimeoutMs, "response must carry the active-editor window") // Open an edit draft — the user must now appear as an active editor. diff --git a/server/api_page_presence.go b/server/api_page_presence.go index b67edb2..a04c050 100644 --- a/server/api_page_presence.go +++ b/server/api_page_presence.go @@ -11,7 +11,7 @@ import ( // handleGetPageActiveEditors handles // GET /api/v1/spaces/{space_id}/pages/{page_id}/active-editors -// Returns the active-editors snapshot for the page (active_editors, as_of, active_timeout_ms). +// Returns the active-editors snapshot for the page (active_editors, snapshot_at, active_timeout_ms). func (p *Plugin) handleGetPageActiveEditors(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) spaceID := vars["space_id"] diff --git a/server/app/page_content.go b/server/app/page_content.go index a505b6d..eacd5cd 100644 --- a/server/app/page_content.go +++ b/server/app/page_content.go @@ -25,11 +25,17 @@ import ( func normalizePageContent(where, body string) (normBody, searchText string, appErr *mmmodel.AppError) { normBody, searchText, err := validateAndNormalizeContent(body) if err != nil { - return "", "", mmmodel.NewAppError(where, "app.page.invalid_content.app_error", nil, "", http.StatusBadRequest).Wrap(err) + return "", "", wrapContentError(where, err) } return normBody, searchText, nil } +// wrapContentError renders a content validation/normalization failure as the shared invalid-content +// AppError, so the error key and status stay defined in one place across content callers. +func wrapContentError(where string, err error) *mmmodel.AppError { + return mmmodel.NewAppError(where, "app.page.invalid_content.app_error", nil, "", http.StatusBadRequest).Wrap(err) +} + // normalizePatchContent normalizes a page patch's Body in place when present, recomputing // SearchText from it (SearchText is the body's server-derived projection). A patch that sets Body // has its caller-supplied SearchText overwritten by the derived value; a patch that does not touch @@ -48,43 +54,77 @@ func normalizePatchContent(where string, patch *model.PagePatch) *mmmodel.AppErr return nil } +// sanitizeContentBody validates and normalizes a body without deriving SearchText, for callers +// (draft autosave) that store only the body. It skips the full-text walk BuildSearchText performs +// on every call — a waste on the highest-frequency write path when the result is discarded. +func sanitizeContentBody(where, body string) (string, *mmmodel.AppError) { + doc, empty, err := normalizeContentToDoc(body) + if err != nil { + return "", wrapContentError(where, err) + } + if empty { + return body, nil + } + normBody, err := marshalTipTapDoc(doc) + if err != nil { + return "", wrapContentError(where, err) + } + return normBody, nil +} + // validateAndNormalizeContent validates and normalizes TipTap/plain-text page content. // Returns (normalizedBody, searchText, error). An empty content string is returned as-is (no-op). func validateAndNormalizeContent(content string) (normBody, searchText string, err error) { - if content == "" { + doc, empty, err := normalizeContentToDoc(content) + if err != nil { + return "", "", err + } + if empty { return content, "", nil } + normBody, err = marshalTipTapDoc(doc) + if err != nil { + return "", "", err + } + return normBody, model.BuildSearchText(doc), nil +} + +// normalizeContentToDoc validates content and returns its normalized TipTap document. empty is true +// for an empty content string ("") — a no-op the caller returns as-is. +func normalizeContentToDoc(content string) (doc model.TipTapDocument, empty bool, err error) { + if content == "" { + return model.TipTapDocument{}, true, nil + } // Treat the body as TipTap only when it is actually valid JSON: a plain-text body that merely // starts with "{" (e.g. "{shrug}") is not JSON and must be wrapped, not rejected. A body that is // valid JSON but not a "doc" is a genuine content error and ParseTipTapDocument rejects it. idx := strings.IndexFunc(content, func(r rune) bool { return !unicode.IsSpace(r) }) if idx >= 0 && content[idx] == '{' { - doc, parseErr := model.ParseTipTapDocument(content) + parsed, parseErr := model.ParseTipTapDocument(content) if parseErr == nil { - return marshalTipTapDoc(doc) + return parsed, false, nil } - err = parseErr var syntaxErr *json.SyntaxError - if !errors.As(err, &syntaxErr) { - return "", "", err + if !errors.As(parseErr, &syntaxErr) { + return model.TipTapDocument{}, false, parseErr } // SyntaxError → not valid JSON → fall through to plain-text wrapping. } // Non-JSON content: wrap in a minimal TipTap doc. - doc, err := convertPlainTextToTipTap(content) + wrapped, err := convertPlainTextToTipTap(content) if err != nil { - return "", "", err + return model.TipTapDocument{}, false, err } - return marshalTipTapDoc(doc) + return wrapped, false, nil } -// marshalTipTapDoc serializes a TipTapDocument and derives its search text. -func marshalTipTapDoc(doc model.TipTapDocument) (string, string, error) { +// marshalTipTapDoc serializes a TipTapDocument to its stored JSON form. +func marshalTipTapDoc(doc model.TipTapDocument) (string, error) { sanitized, err := json.Marshal(doc) if err != nil { - return "", "", err + return "", err } - return string(sanitized), model.BuildSearchText(doc), nil + return string(sanitized), nil } // maxPlainTextParagraphs caps the number of paragraph nodes produced when converting plain text diff --git a/server/app/page_draft.go b/server/app/page_draft.go index f309250..1d24282 100644 --- a/server/app/page_draft.go +++ b/server/app/page_draft.go @@ -21,14 +21,15 @@ func presenceBroadcastKey(pageID, userID string) string { // UpdatePageDraft upserts the calling user's autosave draft for a page in a space. channelID is // the space's backing channel, used to scope the presence broadcast. // -// PageId is the unified page id stable across the draft → publish lifecycle, so a draft may exist -// before the page is published. The space must exist and be live. The caller owns the draft: userID -// is always sourced from the request, never the request body. +// draft.PageId is the unified page id, allocated up front and stable across the draft → publish +// lifecycle. It is reserved before any published page row exists, so carrying a valid page id does +// not imply the page is published — a draft may exist first. The space must exist and be live. The +// caller owns the draft: draft.UserId is always sourced from the request, never the request body. // // An autosave may omit fields the editor didn't change; omitted fields are preserved, so concurrent // heartbeats cannot clobber each other's changes. // parentID encodes the write intent for ParentId: nil preserves the stored value, a pointer to "" -// clears to root, and a pointer to a valid ID sets the parent. See store.UpsertDraft for details. +// clears to root, and a pointer to a valid ID sets the parent. // props encodes the write intent for Props: nil preserves the stored map, a non-nil pointer replaces // it wholesale (an empty map clears all keys); its serialized size is validated here. func (s *Service) UpdatePageDraft(draft *model.Draft, parentID *string, fileIDs *mmmodel.StringArray, props *mmmodel.StringInterface, channelID string) (*model.Draft, *mmmodel.AppError) { @@ -60,7 +61,7 @@ func (s *Service) UpdatePageDraft(draft *model.Draft, parentID *string, fileIDs // unsanitized markup. Defense-in-depth: only the author can read a draft back today, but any // future reader of Draft.Body inherits a sanitized value. if draft.Body != "" { - sanitizedBody, _, contentErr := normalizePageContent("UpdatePageDraft", draft.Body) + sanitizedBody, contentErr := sanitizeContentBody("UpdatePageDraft", draft.Body) if contentErr != nil { return nil, contentErr } @@ -91,12 +92,6 @@ func (s *Service) UpdatePageDraft(draft *model.Draft, parentID *string, fileIDs } } - // pageIsLiveResolved is true when the not-found branch below has already - // checked whether a live page exists in the space. The post-upsert check - // reads this flag to skip calling PageExistsInSpace a second time. - pageIsLive := false - pageIsLiveResolved := false - existingDraft, existingDraftErr := s.store.GetDraft(draft.UserId, draft.PageId) switch { case existingDraftErr != nil && !store.IsErrNotFound(existingDraftErr): @@ -111,12 +106,10 @@ func (s *Service) UpdatePageDraft(draft *model.Draft, parentID *string, fileIDs // DOCS_Draft row, so its author reaches this method through the "existing draft" branch // above (user-scoped GetDraft), never here. // This prevents PATCH /spaces/X/pages//draft from ghost-drafting a non-existent page. - var existsErr error - pageIsLive, existsErr = s.store.PageExistsInSpace(draft.PageId, draft.SpaceId) + pageIsLive, existsErr := s.store.PageExistsInSpace(draft.PageId, draft.SpaceId) if existsErr != nil { return nil, storeAppError("UpdatePageDraft", existsErr) } - pageIsLiveResolved = true if !pageIsLive { return nil, mmmodel.NewAppError("UpdatePageDraft", "app.page_draft.update.page_not_found.app_error", nil, "", http.StatusNotFound) } @@ -138,29 +131,25 @@ func (s *Service) UpdatePageDraft(draft *model.Draft, parentID *string, fileIDs // New-page drafts (no published page row yet) must not broadcast presence to the space channel: // that would expose the reserved page ID and the author's identity to all space members before // the page exists. Send the event only to the author so their own UI can track the session. - // UpsertDraft already determined liveness as part of the same call, so reuse its result here; - // only the no-existing-draft branch above resolved it independently. - if !pageIsLiveResolved { - pageIsLive = savedPageWasLive - } - if !pageIsLive { + // UpsertDraft determined liveness as part of the same call, so trust its result here. + if !savedPageWasLive { s.publishSelfPresence(saved) return saved, nil } // Existing published page: rate-limited channel-wide broadcast so other viewers see this user - // in the active-editors indicator. Key by page+user so concurrent editors don't suppress each - // other's first broadcast. + // in the active-editors indicator. The rate-limit bucket is keyed per (page, user) so each editor + // gets an independent limit — one editor's broadcast can't rate-limit another editor on the same page. presenceKey := presenceBroadcastKey(saved.PageId, saved.UserId) now := mmmodel.GetMillis() - s.sweepPresenceBroadcastLast(now) - existing, loaded := s.presenceBroadcastLast.LoadOrStore(presenceKey, now) + s.sweepPresenceBroadcastTimes(now) + existing, loaded := s.presenceBroadcastTimes.LoadOrStore(presenceKey, now) if loaded { lastTime, ok := existing.(int64) if !ok || now-lastTime < presenceBroadcastMinIntervalMs { return saved, nil } - if !s.presenceBroadcastLast.CompareAndSwap(presenceKey, existing, now) { + if !s.presenceBroadcastTimes.CompareAndSwap(presenceKey, existing, now) { return saved, nil } } @@ -230,9 +219,6 @@ func (s *Service) CreateSpaceDraft(userID, spaceID, title, pageParentID string) return nil, storeAppError("CreateSpaceDraft", err) } - // Broadcast only to the author: the page is not yet published, so broadcasting channel-wide - // would expose the reserved page ID and the author's identity to all space members. - s.publishSelfPresence(saved) return saved, nil } @@ -371,12 +357,17 @@ func (s *Service) GetPageDraftsForSpace(userID, spaceID string, page, perPage in return drafts, hasMore, nil } -// PublishPageDraft publishes the calling user's draft for pageID in spaceID as a page. The draft -// is validated, new-vs-existing state is re-derived from the database (no client trust), and the -// page write + draft delete are committed in a single store transaction. +// PublishPageDraft publishes the calling user's draft for pageID in spaceID as a page, creating the +// page on first publish or updating it if it already exists. pageID is the id reserved when editing +// began (see CreateSpaceDraft) and is stable across the draft → publish lifecycle, so its presence +// does not imply a published page yet: whether this is a create or an edit is re-derived from the +// database (no client trust). The draft is validated, and the page write + draft delete are +// committed in a single store transaction. // Returns (page, wasCreated, appErr): // - wasCreated=true → a new page was inserted by this call (handler should return 201) // - wasCreated=false → an existing page was updated, or a concurrent create was adopted (return 200) +// - appErr is a 409 edit conflict → page is the current server page (or nil if the re-read failed), +// so the caller can surface a diff without a follow-up read; on every other error page is nil. func (s *Service) PublishPageDraft(userID, spaceID, pageID string, force bool) (*model.Page, bool, *mmmodel.AppError) { if !mmmodel.IsValidId(userID) { return nil, false, mmmodel.NewAppError("PublishPageDraft", "app.page_draft.publish.invalid_user_id.app_error", nil, "", http.StatusBadRequest) @@ -541,10 +532,21 @@ func (s *Service) PublishPageDraft(userID, spaceID, pageID string, force bool) ( nil, "", http.StatusConflict).Wrap(storeErr) // Someone else edited the page since the baseline was captured. The client must re-read the page - // and publish against a fresh baseline (or force). + // and publish against a fresh baseline (or force). Return the current server page alongside the + // conflict so the client can diff and re-baseline in one round-trip rather than a follow-up GET. + // The pre-lock `existing` snapshot is stale by definition here, so re-read the live page. case store.ConflictReason(storeErr) == store.ReasonConcurrentEdit: - return nil, false, mmmodel.NewAppError("PublishPageDraft", "app.page_draft.publish.edit_conflict.app_error", + editConflictErr := mmmodel.NewAppError("PublishPageDraft", "app.page_draft.publish.edit_conflict.app_error", nil, "", http.StatusConflict).Wrap(storeErr) + current, getErr := s.GetPage(pageID) + if getErr != nil { + // A concurrent delete can remove the page between the conflict and this re-read; fall + // back to a bare conflict and let the client GET the page itself. + s.log.Warn("failed to re-read page for edit-conflict body", + "page_id", pageID, "user_id", userID, "err", getErr) + return nil, false, editConflictErr + } + return current, false, editConflictErr case store.IsErrConflict(storeErr): if isNewPage { diff --git a/server/app/page_draft_test.go b/server/app/page_draft_test.go index 8d99b11..cc2f552 100644 --- a/server/app/page_draft_test.go +++ b/server/app/page_draft_test.go @@ -195,10 +195,15 @@ func TestPublishStaleBaselineConflicts(t *testing.T) { _, appErr = h.svc.UpdatePage(page.Id, space.Id, &model.PagePatch{Body: &concurrent}, new(staleEditAt), false, userID) require.Nil(t, appErr) - // Publishing the draft against the now-stale baseline must 409. - _, _, appErr = h.svc.PublishPageDraft(userID, space.Id, page.Id, false) + // Publishing the draft against the now-stale baseline must 409, and return the current server + // page (the concurrent edit's content + advanced baseline) so the caller can diff without a + // follow-up read. + current, _, appErr := h.svc.PublishPageDraft(userID, space.Id, page.Id, false) require.NotNil(t, appErr) require.Equal(t, http.StatusConflict, appErr.StatusCode) + require.NotNil(t, current, "edit conflict must return the current server page") + require.Contains(t, current.Body, "concurrent", "current page must reflect the concurrent edit") + require.Greater(t, current.EditAt, staleEditAt, "current page must carry the advanced baseline") } // TestPublishAfterPageDeleteReturns404 verifies that deleting a page cascade-deletes its drafts, @@ -277,7 +282,7 @@ func TestActiveEditorsSurfacesHeartbeat(t *testing.T) { snapshot, appErr := h.svc.GetPageActiveEditors(page.Id, space.Id) require.Nil(t, appErr) require.Contains(t, snapshot.ActiveEditors, userID) - require.Positive(t, snapshot.AsOf) + require.Positive(t, snapshot.SnapshotAt) require.Equal(t, int64(5*60*1000), snapshot.ActiveTimeoutMs) } @@ -720,9 +725,9 @@ func TestUpdatePageDraftRejectsDraftHierarchyTooDeep(t *testing.T) { space := mustCreateSpace(t, h.store, mmmodel.NewId()) userID := mmmodel.NewId() - // Build a chain of draftCycleCheckMaxDepth+1 drafts. The first draft is the root + // Build a chain of model.MaxPageDepth+1 drafts. The first draft is the root // (no parent). Each subsequent draft sets its parent to the previous one. - // draftCycleCheckMaxDepth is 10; a chain of 10 drafts fills the limit, so + // model.MaxPageDepth is 10; a chain of 10 drafts fills the limit, so // adding one more child is the first rejection. const chainLen = 10 drafts := make([]*model.Draft, chainLen) diff --git a/server/app/page_presence.go b/server/app/page_presence.go index edbd50c..15bbc6d 100644 --- a/server/app/page_presence.go +++ b/server/app/page_presence.go @@ -22,30 +22,36 @@ const activeEditorTimeoutMs int64 = 5 * 60 * 1000 const presenceBroadcastMinIntervalMs int64 = 30 * 1000 // presenceBroadcastSweepIntervalMs is the minimum time between sweeps of the broadcast rate-limit -// map. Sweeping is opportunistic — it runs on an autosave that finds the interval elapsed — so this -// bounds how often an autosave pays for a full scan of the map. +// map. Sweeping is opportunistic — it runs on the first autosave after the interval has elapsed — so +// an autosave performs a full scan of the map at most once per interval. const presenceBroadcastSweepIntervalMs int64 = 5 * 60 * 1000 func activeEditorSince() int64 { return mmmodel.GetMillis() - activeEditorTimeoutMs } -// sweepPresenceBroadcastLast drops rate-limit entries older than the active-editor window. An entry -// that old suppresses nothing — the next autosave is past presenceBroadcastMinIntervalMs and -// broadcasts either way — so dropping it preserves behavior while bounding the map for sessions -// abandoned without a discard or publish. CompareAndDelete leaves concurrently refreshed entries be. -func (s *Service) sweepPresenceBroadcastLast(now int64) { - last := s.presenceSweepLast.Load() +// sweepPresenceBroadcastTimes removes stale entries from the broadcast rate-limit map to bound its +// size. An entry is normally removed when its session ends (discard or publish); this sweep is the +// fallback for sessions abandoned without either. +// +// It removes entries older than the active-editor window (activeEditorTimeoutMs). Removing such an +// entry cannot change behavior: the map only suppresses a broadcast within +// presenceBroadcastMinIntervalMs of the stored time, and an entry this old is already far past that +// window, so the next autosave broadcasts whether or not the entry is still present. CompareAndDelete +// removes only an entry whose value is unchanged, so one a concurrent autosave just refreshed is +// left in place. +func (s *Service) sweepPresenceBroadcastTimes(now int64) { + last := s.lastPresenceSweepAt.Load() if now-last < presenceBroadcastSweepIntervalMs { return } - if !s.presenceSweepLast.CompareAndSwap(last, now) { + if !s.lastPresenceSweepAt.CompareAndSwap(last, now) { return } - s.presenceBroadcastLast.Range(func(key, value any) bool { + s.presenceBroadcastTimes.Range(func(key, value any) bool { if ts, ok := value.(int64); ok && now-ts >= activeEditorTimeoutMs { - s.presenceBroadcastLast.CompareAndDelete(key, value) + s.presenceBroadcastTimes.CompareAndDelete(key, value) } return true }) @@ -73,26 +79,26 @@ func (s *Service) publishSelfPresence(draft *model.Draft) { "page_id": draft.PageId, "space_id": draft.SpaceId, "active_editors": []string{draft.UserId}, - "as_of": mmmodel.GetMillis(), + "snapshot_at": mmmodel.GetMillis(), "active_timeout_ms": activeEditorTimeoutMs, }, draft.UserId) } // broadcastPagePresence fans a page_presence_updated event out to the space audience on channelID -// (the space's backing channel), carrying the current active-editor set, as_of, and active_timeout_ms. +// (the space's backing channel), carrying the current active-editor set, snapshot_at, and active_timeout_ms. // Best-effort: failures are swallowed. // // Broadcasts fire only on user actions (autosave, discard, publish), never periodically, so a client // that receives no newer snapshot cannot distinguish a still-active editor from one whose session // ended abnormally. active_timeout_ms lets it expire the snapshot's editors on its own once -// as_of + active_timeout_ms has passed. +// snapshot_at + active_timeout_ms has passed. func (s *Service) broadcastPagePresence(pageID, spaceID, channelID string) { if s.client == nil { return } - // Stamp as_of before the editors query so it marks when the snapshot was taken, not when the + // Stamp snapshot_at before the editors query so it marks when the snapshot was taken, not when the // broadcast finished assembling — clients use it to discard out-of-order snapshots. - asOf := mmmodel.GetMillis() + snapshotAt := mmmodel.GetMillis() editors, ok := s.getActiveEditors(pageID, spaceID) if !ok { return @@ -101,7 +107,7 @@ func (s *Service) broadcastPagePresence(pageID, spaceID, channelID string) { "page_id": pageID, "space_id": spaceID, "active_editors": editors, - "as_of": asOf, + "snapshot_at": snapshotAt, "active_timeout_ms": activeEditorTimeoutMs, }, channelID) } @@ -110,16 +116,16 @@ func (s *Service) broadcastPagePresence(pageID, spaceID, channelID string) { // following broadcast is not suppressed, then broadcasts channel-wide. Used whenever a draft session // ends (discard, publish, race-loss cleanup) and the active-editors indicator must drop this user. func (s *Service) clearThrottleAndBroadcastPagePresence(pageID, userID, spaceID, channelID string) { - s.presenceBroadcastLast.Delete(presenceBroadcastKey(pageID, userID)) + s.presenceBroadcastTimes.Delete(presenceBroadcastKey(pageID, userID)) s.broadcastPagePresence(pageID, spaceID, channelID) } // PageActiveEditors is the editor-presence snapshot returned by the REST active-editors endpoint. Its -// fields mirror the page_presence_updated WebSocket payload (active_editors, as_of, active_timeout_ms) +// fields mirror the page_presence_updated WebSocket payload (active_editors, snapshot_at, active_timeout_ms) // so a client sees the same presence contract whether it resyncs over REST or receives a live event. type PageActiveEditors struct { ActiveEditors []string `json:"active_editors"` - AsOf int64 `json:"as_of"` + SnapshotAt int64 `json:"snapshot_at"` ActiveTimeoutMs int64 `json:"active_timeout_ms"` } @@ -141,15 +147,15 @@ func (s *Service) GetPageActiveEditors(pageID, spaceID string) (*PageActiveEdito if !exists { return nil, mmmodel.NewAppError("GetPageActiveEditors", "app.page.not_found.app_error", nil, "", http.StatusNotFound) } - // Stamp as_of before the query so it marks when the snapshot was taken, matching the WS event. - asOf := mmmodel.GetMillis() - editors, storeErr := s.store.GetPageActiveEditors(pageID, spaceID, asOf-activeEditorTimeoutMs) + // Stamp snapshot_at before the query so it marks when the snapshot was taken, matching the WS event. + snapshotAt := mmmodel.GetMillis() + editors, storeErr := s.store.GetPageActiveEditors(pageID, spaceID, snapshotAt-activeEditorTimeoutMs) if storeErr != nil { return nil, storeAppError("GetPageActiveEditors", storeErr) } return &PageActiveEditors{ ActiveEditors: editors, - AsOf: asOf, + SnapshotAt: snapshotAt, ActiveTimeoutMs: activeEditorTimeoutMs, }, nil } diff --git a/server/app/page_presence_test.go b/server/app/page_presence_test.go index 83ea64c..f84dfd1 100644 --- a/server/app/page_presence_test.go +++ b/server/app/page_presence_test.go @@ -9,34 +9,34 @@ import ( "github.com/stretchr/testify/require" ) -// TestSweepPresenceBroadcastLastEvictsStaleEntriesOncePerWindow verifies sweepPresenceBroadcastLast's +// TestSweepPresenceBroadcastTimesEvictsStaleEntriesOncePerWindow verifies sweepPresenceBroadcastTimes's // two behaviors: it evicts entries older than activeEditorTimeoutMs while leaving fresh entries in // place, and it runs at most once per presenceBroadcastSweepIntervalMs — a second call with the same // `now` must be a no-op even if a new stale entry was added in between. -func TestSweepPresenceBroadcastLastEvictsStaleEntriesOncePerWindow(t *testing.T) { +func TestSweepPresenceBroadcastTimesEvictsStaleEntriesOncePerWindow(t *testing.T) { svc := &Service{} now := int64(1_000_000_000) staleKey := "stale-page:stale-user" freshKey := "fresh-page:fresh-user" - svc.presenceBroadcastLast.Store(staleKey, now-2*activeEditorTimeoutMs) - svc.presenceBroadcastLast.Store(freshKey, now) + svc.presenceBroadcastTimes.Store(staleKey, now-2*activeEditorTimeoutMs) + svc.presenceBroadcastTimes.Store(freshKey, now) // Open the gate: make the sweep consider itself overdue. - svc.presenceSweepLast.Store(now - presenceBroadcastSweepIntervalMs) + svc.lastPresenceSweepAt.Store(now - presenceBroadcastSweepIntervalMs) - svc.sweepPresenceBroadcastLast(now) + svc.sweepPresenceBroadcastTimes(now) - _, staleStillPresent := svc.presenceBroadcastLast.Load(staleKey) + _, staleStillPresent := svc.presenceBroadcastTimes.Load(staleKey) require.False(t, staleStillPresent, "a stale entry must be evicted by the sweep") - _, freshStillPresent := svc.presenceBroadcastLast.Load(freshKey) + _, freshStillPresent := svc.presenceBroadcastTimes.Load(freshKey) require.True(t, freshStillPresent, "a fresh entry must survive the sweep") // Re-seed a stale entry and call again with the same `now`: the gate must be closed (at most one // sweep per activeEditorTimeoutMs), so this entry must NOT be evicted. - svc.presenceBroadcastLast.Store(staleKey, now-2*activeEditorTimeoutMs) - svc.sweepPresenceBroadcastLast(now) + svc.presenceBroadcastTimes.Store(staleKey, now-2*activeEditorTimeoutMs) + svc.sweepPresenceBroadcastTimes(now) - _, stillThere := svc.presenceBroadcastLast.Load(staleKey) + _, stillThere := svc.presenceBroadcastTimes.Load(staleKey) require.True(t, stillThere, "a second call within the sweep interval must be a no-op") } diff --git a/server/app/service.go b/server/app/service.go index b33afbf..d2e69be 100644 --- a/server/app/service.go +++ b/server/app/service.go @@ -40,19 +40,22 @@ type Service struct { log Logger client *pluginapi.Client - // presenceBroadcastLast records the last autosave-triggered presence broadcast time (ms) per - // (pageID, userID), used to rate-limit high-frequency autosave broadcasts. Delete and publish paths bypass - // this and always broadcast. + // presenceBroadcastTimes records the last channel-wide presence broadcast time (ms) per + // (pageID, userID). Autosave cadence is client-driven and unbounded server-side, and every autosave + // on a live page would otherwise fan a presence event out to the whole channel, so this caps those + // broadcasts to at most one per presenceBroadcastMinIntervalMs per (page, user). Delete and publish + // paths bypass this and always broadcast. // // The map is per-process, so each node throttles independently: a user whose autosaves are // spread across nodes can broadcast more often than the interval implies. That is acceptable — // the payload is queried fresh from the shared DB on every broadcast, so the throttle only // trades broadcast volume, never correctness. Entries are dropped on discard and publish, and - // swept by age via sweepPresenceBroadcastLast for sessions abandoned without either. - presenceBroadcastLast sync.Map + // swept by age via sweepPresenceBroadcastTimes for sessions abandoned without either. + presenceBroadcastTimes sync.Map - // presenceSweepLast is the last time presenceBroadcastLast was swept (ms). - presenceSweepLast atomic.Int64 + // lastPresenceSweepAt is the timestamp (ms) of the most recent presenceBroadcastTimes sweep, used + // to rate-limit the sweep itself to once per presenceBroadcastSweepIntervalMs. + lastPresenceSweepAt atomic.Int64 } // New creates a Service wired to the given store, logger, and optional pluginapi client. diff --git a/server/app/ws_events.go b/server/app/ws_events.go index baa824d..e9569a4 100644 --- a/server/app/ws_events.go +++ b/server/app/ws_events.go @@ -36,7 +36,7 @@ const ( wsEventPageMovedToSpace = "page_moved_to_space" // Unlike the other page_* events above — which carry only {page_id, space_id} as a // "something changed, refetch" signal — wsEventPagePresenceUpdated carries the full presence - // snapshot inline ({page_id, space_id, active_editors, as_of, active_timeout_ms}), so clients + // snapshot inline ({page_id, space_id, active_editors, snapshot_at, active_timeout_ms}), so clients // need no follow-up fetch. It is rate-limited on autosave but always fires on discard and publish. wsEventPagePresenceUpdated = "page_presence_updated" diff --git a/server/app/ws_events_test.go b/server/app/ws_events_test.go index 2824b18..f1fe2e9 100644 --- a/server/app/ws_events_test.go +++ b/server/app/ws_events_test.go @@ -175,9 +175,9 @@ func TestServiceMovePageToSpace_NoOpPublishesNothing(t *testing.T) { } // TestServiceUpdatePageDraft_PublishesPresenceEvent pins page_presence_updated: the presence-snapshot -// payload ({page_id, space_id, active_editors, as_of}) — distinct from the {page_id, space_id} -// mutation shape — broadcast to the space's backing channel. An autosave is the heartbeat, so the -// saving user appears in active_editors. +// payload ({page_id, space_id, active_editors, snapshot_at}) — unlike the other page_* events, which carry +// only {page_id, space_id} as a change signal — broadcast to the space's backing channel. An autosave +// is the heartbeat, so the saving user appears in active_editors. func TestServiceUpdatePageDraft_PublishesPresenceEvent(t *testing.T) { mockAPI := &plugintest.API{} h := openTestServiceWithAPI(t, mockAPI) @@ -201,7 +201,7 @@ func TestServiceUpdatePageDraft_PublishesPresenceEvent(t *testing.T) { return ok && payload["page_id"] == page.Id && payload["space_id"] == space.Id && - payload["as_of"] != nil && + payload["snapshot_at"] != nil && payload["active_timeout_ms"] == int64(5*60*1000) && slices.Contains(editors, userID) }), @@ -232,7 +232,7 @@ func TestServicePublishPageDraft_PublishesCreatedEvent(t *testing.T) { return ok && payload["page_id"] == page.Id && payload["space_id"] == space.Id && - payload["as_of"] != nil && + payload["snapshot_at"] != nil && len(editors) == 0 }), &mmmodel.WebsocketBroadcast{ChannelId: channelID}) @@ -272,7 +272,7 @@ func TestServicePublishPageDraft_PublishesUpdatedEvent(t *testing.T) { return ok && payload["page_id"] == republished.Id && payload["space_id"] == space.Id && - payload["as_of"] != nil && + payload["snapshot_at"] != nil && payload["active_timeout_ms"] == int64(5*60*1000) && len(editors) == 0 }), @@ -307,7 +307,7 @@ func TestServiceDeletePageDraft_PublishesPresenceEvent(t *testing.T) { return ok && payload["page_id"] == page.Id && payload["space_id"] == space.Id && - payload["as_of"] != nil && + payload["snapshot_at"] != nil && payload["active_timeout_ms"] == int64(5*60*1000) && len(editors) == 0 }), diff --git a/server/model/page_content.go b/server/model/page_content.go index f67d702..331a8e2 100644 --- a/server/model/page_content.go +++ b/server/model/page_content.go @@ -20,9 +20,13 @@ const ( ) // TipTapDocument is the parsed form of a TipTap editor document. Content is left as an untyped node -// tree because the TipTap schema is open (editor extensions add node/mark types). Instances must be -// produced by ParseTipTapDocument for the sanitization invariant to hold — a value built any other -// way has not passed sanitizeTipTapDocument and must not be stored or rendered. +// tree because node attributes and nesting vary per editor extension; the permitted node and mark +// type names are constrained by allowedNodeTypes/allowedMarkTypes during sanitization. Client-supplied +// content must be produced by ParseTipTapDocument for the sanitization invariant to hold — a value +// decoded from client JSON any other way has not passed sanitizeTipTapDocument and must not be +// stored or rendered. The one exception is a document assembled internally from trusted parts (e.g. +// convertPlainTextToTipTap, which emits only paragraph and text nodes with no attrs or marks): it is +// safe by construction and needs no sanitization pass. type TipTapDocument struct { Type string `json:"type"` Content []map[string]any `json:"content"` @@ -123,53 +127,22 @@ const maxTipTapDepth = 100 // maxTipTapNodes caps the total number of content nodes in a TipTap document. A 2 MiB JSON payload // can contain hundreds of thousands of tiny nodes; unmarshaling them before sanitization causes // significant allocation and CPU amplification. The plain-text path is capped at maxPlainTextParagraphs -// (10 000 paragraphs → ~10 000 nodes); rich documents with ~5 inline nodes per paragraph stay well -// under 50 000 for any sane document. +// (10 000 paragraphs → ~20 000 nodes, since each non-empty line is a paragraph plus a text child); +// rich documents with ~5 inline nodes per paragraph stay well under 50 000 for any sane document. const maxTipTapNodes = 50_000 var errAttrDepthExceeded = errors.New("content attribute nesting exceeds the maximum depth") -// countTipTapNodes returns the total number of nodes in the subtree rooted at node, bounding -// recursion at maxTipTapDepth. It stops counting once the running total exceeds the limit so that -// a document with millions of nodes does not incur a full traversal just to fail the check. -func countTipTapNodes(node map[string]any, depth, runningTotal, limit int) int { - if depth > maxTipTapDepth || runningTotal > limit { - return runningTotal - } - runningTotal++ - if contentVal, ok := node["content"]; ok { - if children, ok := contentVal.([]any); ok { - for _, child := range children { - if childNode, ok := child.(map[string]any); ok { - runningTotal = countTipTapNodes(childNode, depth+1, runningTotal, limit) - if runningTotal > limit { - return runningTotal - } - } - } - } - } - return runningTotal -} - func sanitizeTipTapDocument(doc *TipTapDocument) error { - // Reject documents with pathologically many nodes before the recursive sanitization walk, - // which would otherwise allocate without bound on a crafted payload. - total := 0 - for _, node := range doc.Content { - if node != nil { - total = countTipTapNodes(node, 0, total, maxTipTapNodes) - } - if total > maxTipTapNodes { - return errors.Errorf("content exceeds the maximum of %d nodes", maxTipTapNodes) - } - } - + // count is a running node budget shared across the walk: sanitizeTipTapNode increments it per + // node and bails once it crosses maxTipTapNodes, so a crafted payload is rejected before the + // walk allocates without bound — without a separate counting pass over the whole tree. + count := 0 for i := range doc.Content { if doc.Content[i] == nil { return errors.New("content document nodes must be objects") } - if err := sanitizeTipTapNode(doc.Content[i], 0); err != nil { + if err := sanitizeTipTapNode(doc.Content[i], 0, &count); err != nil { return err } } @@ -186,43 +159,60 @@ var urlAttrKeys = map[string]struct{}{ "xlinkhref": {}, } -// forbiddenNodeTypes are TipTap node type values rejected outright because they map to HTML -// elements that can execute script or embed foreign content. A full allowlist keyed to the editor -// schema would be the stronger posture; this denylist stops the most dangerous types now. -var forbiddenNodeTypes = map[string]struct{}{ - "script": {}, - "iframe": {}, - "embed": {}, - "object": {}, - "noscript": {}, - "template": {}, - "style": {}, - "link": {}, - "svg": {}, - "math": {}, - "animate": {}, - "animatetransform": {}, - "foreignobject": {}, - "maction": {}, +// allowedNodeTypes is the allowlist of TipTap node type values permitted in stored content, keyed to +// the core WysiwygEditor schema plus the extensions the page editor augments it with. A node type not +// listed here is rejected outright: this pins the server's accepted node set to the client's schema so +// the two cannot drift silently, and it fails closed for any script- or embed-bearing type the client +// never emits. Matched case-sensitively, since TipTap schema names are case-sensitive camelCase. +// +// This is a hand-maintained mirror of the editor schema: when the page editor gains a node type — a +// core WysiwygEditor/StarterKit change or a Docs-specific extension — add it here in the same change, +// or the new content is rejected on save. +var allowedNodeTypes = map[string]struct{}{ + // Core WysiwygEditor schema (StarterKit + Link + CodeBlockLowlight + Table). + "doc": {}, + "paragraph": {}, + "text": {}, + "heading": {}, + "hardBreak": {}, + "horizontalRule": {}, + "blockquote": {}, + "codeBlock": {}, + "bulletList": {}, + "orderedList": {}, + "listItem": {}, + "table": {}, + "tableRow": {}, + "tableCell": {}, + "tableHeader": {}, + // Page editor extensions layered on the core schema. + "taskList": {}, + "taskItem": {}, + "mention": {}, + "channelMention": {}, + "callout": {}, + "image": {}, + "imageResize": {}, + "imagePlaceholder": {}, + "video": {}, + "fileAttachment": {}, } -// forbiddenMarkTypes are TipTap mark type values rejected outright. This mirrors forbiddenNodeTypes -// except that "link" is valid as a mark (TipTap's inline hyperlink) — its href is sanitized by -// sanitizeURL rather than being blocked outright. -var forbiddenMarkTypes = map[string]struct{}{ - "script": {}, - "iframe": {}, - "embed": {}, - "object": {}, - "noscript": {}, - "template": {}, - "style": {}, - "svg": {}, - "math": {}, - "animate": {}, - "animatetransform": {}, - "foreignobject": {}, - "maction": {}, +// allowedMarkTypes is the allowlist of TipTap mark type values, keyed to the core WysiwygEditor +// schema plus the page editor's extensions. Like allowedNodeTypes, a mark type not listed here is +// rejected rather than passed through. "link" is a mark (TipTap's inline hyperlink); its href is +// sanitized by sanitizeURL. +var allowedMarkTypes = map[string]struct{}{ + // Core WysiwygEditor marks. + "bold": {}, + "italic": {}, + "strike": {}, + "code": {}, + "underline": {}, + "link": {}, + // Page editor extensions. + "textStyle": {}, + "commentAnchor": {}, } // dangerousAttrKeys are attribute keys (matched case-insensitively) stripped outright regardless of @@ -255,16 +245,23 @@ func stripDangerousKeys(m map[string]any) { delete(m, key) continue } - // data-* attributes may carry URL values (data-href, data-src, data-url, etc.) that a - // lenient client renderer can treat as navigation targets; sanitize them as URLs. - isURL := strings.HasPrefix(lower, "data-") - if !isURL { - _, isURL = urlAttrKeys[lower] + // A data-* attribute may carry a URL a lenient client renderer treats as a navigation + // target, but it is not necessarily one: an editor extension may store a ratio ("16:9"), a + // timestamp ("12:30"), or any other colon-bearing value under a data-* key. The strict URL + // allowlist would read the leading token as a scheme and blank every such value, so + // neutralize only the unambiguously dangerous schemes here (javascript:/vbscript:/non-image + // data:), matching the bare-array-string path. A non-string value (number, nested map/array) + // carries no scheme to strip and is left for the recursive attr walk to handle. + if strings.HasPrefix(lower, "data-") { + if v, ok := val.(string); ok { + m[key] = neutralizeAmbiguousURLScheme(v) + } + continue } - if isURL { - // A URL-valued attribute must be a string. A non-string value (e.g. a JSON array) can - // be coerced back into a dangerous string by a client renderer, so drop it rather than - // leave it untouched. + // A designated URL key (href, src, ...) genuinely holds a URL, so apply the strict scheme + // allowlist. A non-string value (e.g. a JSON array) can be coerced back into a dangerous + // string by a client renderer, so drop it rather than leave it untouched. + if _, isURL := urlAttrKeys[lower]; isURL { v, ok := val.(string) if !ok { delete(m, key) @@ -309,7 +306,7 @@ func sanitizeAttrValue(val any, depth int) error { case []any: for i, item := range v { if s, ok := item.(string); ok { - v[i] = neutralizeBareArrayURLString(s) + v[i] = neutralizeAmbiguousURLScheme(s) continue } if err := sanitizeAttrValue(item, depth+1); err != nil { @@ -350,13 +347,17 @@ func sanitizeObjAttrsAndFlatKeys(obj map[string]any, attrsErrMsg string, skipKey return nil } -func sanitizeTipTapNode(node map[string]any, depth int) error { +func sanitizeTipTapNode(node map[string]any, depth int, count *int) error { if node == nil { return errors.New("content node must not be null") } if depth > maxTipTapDepth { return errors.New("content nesting exceeds the maximum depth") } + *count++ + if *count > maxTipTapNodes { + return errors.Errorf("content exceeds the maximum of %d nodes", maxTipTapNodes) + } // Strip dangerous/URL keys placed directly on the node object, then sanitize its attrs. The // node's own keys need the same treatment as a mark's: "content" and "marks" are walked below, @@ -367,7 +368,7 @@ func sanitizeTipTapNode(node map[string]any, depth int) error { if !ok || nodeType == "" { return errors.New("content node must have a non-empty type field") } - if _, forbidden := forbiddenNodeTypes[strings.ToLower(nodeType)]; forbidden { + if _, allowed := allowedNodeTypes[nodeType]; !allowed { return errors.Errorf("content node type %q is not allowed", nodeType) } @@ -392,7 +393,7 @@ func sanitizeTipTapNode(node map[string]any, depth int) error { if !ok || markType == "" { return errors.New("content mark must have a non-empty type field") } - if _, forbidden := forbiddenMarkTypes[strings.ToLower(markType)]; forbidden { + if _, allowed := allowedMarkTypes[markType]; !allowed { return errors.Errorf("content mark type %q is not allowed", markType) } if err := sanitizeObjAttrsAndFlatKeys(markNode, "content mark attrs must be an object", markSkipKeys, depth); err != nil { @@ -411,7 +412,7 @@ func sanitizeTipTapNode(node map[string]any, depth int) error { if !ok { return errors.New("content child must be an object") } - if err := sanitizeTipTapNode(childNode, depth+1); err != nil { + if err := sanitizeTipTapNode(childNode, depth+1, count); err != nil { return err } } @@ -527,7 +528,8 @@ func isSafeImageDataURL(url, lower string) bool { // sanitizeURL returns the URL unchanged if its scheme is on the allowlist (or it is a relative // reference), and "" otherwise. It defends against control-character, leading-whitespace, and // HTML-entity obfuscation of dangerous schemes (e.g. "java script:alert(1)"). Applied to a -// value under a URL-designated attribute key (href, src, data-*, …). +// value under a URL-designated attribute key (href, src, poster, xlink:href). data-* keys are not +// necessarily URLs and take the lenient neutralizeAmbiguousURLScheme path instead. func sanitizeURL(url string) string { scheme, lower, hasScheme := decodeURLScheme(url) if !hasScheme { @@ -546,14 +548,14 @@ func sanitizeURL(url string) string { } } -// neutralizeBareArrayURLString blanks a string reached as a bare element of an array attribute value -// if it carries a script-executing or foreign-content URL scheme. Unlike sanitizeURL (a strict -// allowlist applied where an attribute key marks the value a URL), a bare array element has no key to -// mark it, so only the unambiguously dangerous schemes are neutralized: a plain colon-bearing string -// an extension may legitimately store in an array — a "12:30" timestamp, a "16:9" ratio — is -// preserved, while a javascript:/vbscript:/non-image data: payload smuggled through a non-URL array -// key is dropped. -func neutralizeBareArrayURLString(s string) string { +// neutralizeAmbiguousURLScheme blanks a string that carries a script-executing or foreign-content +// URL scheme, for values that are not unambiguously URLs. It is reached two ways: a bare element of +// an array attribute value (no key marks it a URL), and a data-* attribute value (whose key may mark +// a URL but need not — an extension may store a "12:30" timestamp or "16:9" ratio there). Unlike +// sanitizeURL (a strict allowlist applied where an attribute key designates the value a URL), only +// the unambiguously dangerous schemes are neutralized: a plain colon-bearing string is preserved, +// while a javascript:/vbscript:/non-image data: payload is dropped. +func neutralizeAmbiguousURLScheme(s string) string { scheme, lower, hasScheme := decodeURLScheme(s) if !hasScheme { return s diff --git a/server/model/page_content_test.go b/server/model/page_content_test.go index 47a140a..89f48cf 100644 --- a/server/model/page_content_test.go +++ b/server/model/page_content_test.go @@ -454,7 +454,7 @@ func TestParseTipTapDocumentNeutralizesBareArrayURLStrings(t *testing.T) { "type": "doc", "content": []any{ map[string]any{ - "type": "customEmbed", + "type": "fileAttachment", "attrs": map[string]any{ "sources": []any{"javascript:alert(1)", "vbscript:msgbox(1)", "http://ok.example", "12:30", "16:9"}, "nested": []any{[]any{"javascript:alert(2)"}}, @@ -483,21 +483,106 @@ func TestParseTipTapDocumentNeutralizesBareArrayURLStrings(t *testing.T) { require.Equal(t, "http://ok.example", sources[2]) } +func TestParseTipTapDocumentSanitizesDataAttrs(t *testing.T) { + // A data-* attribute is not necessarily a URL: an extension may store a ratio or timestamp + // under a data-* key. Such colon-bearing values must be preserved (the strict URL allowlist + // would parse the leading token as a scheme and blank them), while a dangerous scheme carried + // under a data-* key is still neutralized, and a non-string data-* value is left untouched. + raw := map[string]any{ + "type": "doc", + "content": []any{ + map[string]any{ + "type": "fileAttachment", + "attrs": map[string]any{ + "data-ratio": "16:9", + "data-time": "12:30", + "data-src": "https://ok.example/img.png", + "data-onnav": "javascript:alert(1)", + "data-vb": "vbscript:msgbox(1)", + "data-index": float64(5), + "data-payload": "data:text/html,", + // Mixed-case key: the data- prefix is matched case-insensitively (the key is + // lowercased before the prefix test), so a dangerous scheme here is still blanked + // and a colon-bearing non-URL value is still preserved. + "DATA-Nav": "javascript:alert(2)", + "Data-Ok": "21:9", + // Non-string container under a data-* key: stripDangerousKeys skips the non-string + // value, and the recursive attr walk sanitizes the nested map's designated URL key. + "data-config": map[string]any{"href": "javascript:alert(3)", "keep": "plain"}, + }, + }, + }, + } + b, err := json.Marshal(raw) + require.NoError(t, err) + doc, err := model.ParseTipTapDocument(string(b)) + require.NoError(t, err) + + var parsed map[string]any + require.NoError(t, json.Unmarshal([]byte(marshal(t, doc)), &parsed)) + attrs := parsed["content"].([]any)[0].(map[string]any)["attrs"].(map[string]any) + + require.Equal(t, "16:9", attrs["data-ratio"], "a non-URL ratio under a data-* key must be preserved") + require.Equal(t, "12:30", attrs["data-time"], "a non-URL timestamp under a data-* key must be preserved") + require.Equal(t, "https://ok.example/img.png", attrs["data-src"], "a safe URL under a data-* key must be preserved") + require.Equal(t, "", attrs["data-onnav"], "a javascript: scheme under a data-* key must be neutralized") + require.Equal(t, "", attrs["data-vb"], "a vbscript: scheme under a data-* key must be neutralized") + require.Equal(t, float64(5), attrs["data-index"], "a non-string data-* value must be left untouched") + require.Equal(t, "", attrs["data-payload"], "a non-image data: URI under a data-* key must be neutralized") + require.Equal(t, "", attrs["DATA-Nav"], "a dangerous scheme under a mixed-case data-* key must be neutralized") + require.Equal(t, "21:9", attrs["Data-Ok"], "a non-URL value under a mixed-case data-* key must be preserved") + + config, ok := attrs["data-config"].(map[string]any) + require.True(t, ok, "a nested map under a data-* key must be preserved as a map") + require.Equal(t, "", config["href"], "a dangerous URL nested under a data-* map must be sanitized recursively") + require.Equal(t, "plain", config["keep"], "a non-URL sibling in the nested map must be preserved") +} + func TestParseTipTapDocumentRejectsTooDeep(t *testing.T) { // A pathologically deep document is rejected rather than walked. depth := 200 - deep := `{"type":"doc","content":` + strings.Repeat(`[{"type":"x","content":`, depth) + `[]` + strings.Repeat(`}]`, depth) + `}` + deep := `{"type":"doc","content":` + strings.Repeat(`[{"type":"blockquote","content":`, depth) + `[]` + strings.Repeat(`}]`, depth) + `}` _, err := model.ParseTipTapDocument(deep) require.Error(t, err, "content nested beyond the limit must be rejected") } +func TestParseTipTapDocumentRejectsOffSchemaNodeType(t *testing.T) { + // A node type outside the allowlist is rejected outright, so a client node type the server does + // not know about surfaces as a loud failure rather than passing through unrecognized. + _, err := model.ParseTipTapDocument(`{"type":"doc","content":[{"type":"widget","content":[]}]}`) + require.Error(t, err, "an off-schema node type must be rejected") +} + +func TestParseTipTapDocumentRejectsOffSchemaMarkType(t *testing.T) { + // Mark types are allowlisted the same as node types: a mark outside the schema is rejected. + _, err := model.ParseTipTapDocument(`{"type":"doc","content":[{"type":"text","text":"x","marks":[{"type":"blink"}]}]}`) + require.Error(t, err, "an off-schema mark type must be rejected") +} + +func TestParseTipTapDocumentRejectsAllowlistedTypeInWrongCase(t *testing.T) { + // The allowlist matches case-sensitively (TipTap schema names are camelCase). A case variant of an + // allowed type is rejected — this also guards the denylist->allowlist switch, since the old + // case-insensitive denylist would have lowercased "Paragraph" and let it through. + _, err := model.ParseTipTapDocument(`{"type":"doc","content":[{"type":"Paragraph"}]}`) + require.Error(t, err, "a wrong-case node type must be rejected under the case-sensitive allowlist") +} + +func TestParseTipTapDocumentAllowsSchemaNodeTypes(t *testing.T) { + // Representative core-schema and extension node types must pass sanitization unchanged. + for _, nodeType := range []string{"heading", "table", "callout", "taskList", "taskItem", "channelMention", "video", "fileAttachment", "imagePlaceholder", "imageResize"} { + doc := `{"type":"doc","content":[{"type":"` + nodeType + `","content":[]}]}` + _, err := model.ParseTipTapDocument(doc) + require.NoError(t, err, "schema node type %q must be allowed", nodeType) + } +} + func TestParseTipTapDocumentStripsAdditionalDangerousAttrs(t *testing.T) { // formaction, dynsrc, and lowsrc are in the denylist but not covered by the main dangerous-attrs test. raw := map[string]any{ "type": "doc", "content": []any{ map[string]any{ - "type": "form", + "type": "image", "attrs": map[string]any{ "formaction": "https://evil.example", "dynsrc": "javascript:alert(1)", diff --git a/server/store/migrations/000005_add_draft_lastactiveat_baseeditat.up.sql b/server/store/migrations/000005_add_draft_lastactiveat_baseeditat.up.sql index cb5b749..4890412 100644 --- a/server/store/migrations/000005_add_draft_lastactiveat_baseeditat.up.sql +++ b/server/store/migrations/000005_add_draft_lastactiveat_baseeditat.up.sql @@ -4,7 +4,7 @@ ALTER TABLE DOCS_Draft ADD COLUMN IF NOT EXISTS LastActiveAt BIGINT NOT NULL DEFAULT 0; -- BaseEditAt is the optimistic-lock baseline: the page EditAt the client saw at edit-open, compared --- against the page's current EditAt on publish to reject concurrent-edit conflicts. It is write-once --- (never listed in UpsertDraft's ON CONFLICT ... DO UPDATE SET). Existing rows default to 0 (no --- baseline), which fails closed to requiring a forced publish. +-- against the page's current EditAt on publish to reject concurrent-edit conflicts. It is write-once: +-- set when the draft row is first inserted and never overwritten by later autosaves. Existing rows +-- default to 0 (no baseline), which fails closed to requiring a forced publish. ALTER TABLE DOCS_Draft ADD COLUMN IF NOT EXISTS BaseEditAt BIGINT NOT NULL DEFAULT 0; diff --git a/server/store/page_hierarchy.go b/server/store/page_hierarchy.go index 263a634..bfbe635 100644 --- a/server/store/page_hierarchy.go +++ b/server/store/page_hierarchy.go @@ -65,8 +65,7 @@ var ( )`, MaxPageHierarchyDepth) ) -// computeDescendantsCTE generates the recursive (WITH RECURSIVE) CTE — which squirrel cannot -// express, hence raw SQL — that walks the live subtree below a page, +// computeDescendantsCTE generates the recursive CTE that walks the live subtree below a page, // excluding snapshot rows (OriginalId != "") like the ancestry and subtree CTEs above, and // excluding the root node, returning full page columns plus the node's depth. depth counts // edges below the requested page: the root is seeded at 0, so a direct child is depth 1. The diff --git a/server/store/page_move_test.go b/server/store/page_move_test.go index 880c993..c45ef3d 100644 --- a/server/store/page_move_test.go +++ b/server/store/page_move_test.go @@ -568,8 +568,8 @@ func TestPageMutations_ScopedToSpace(t *testing.T) { // TestMovePageToSpace_ConcurrentAutosaveInvariants exercises the space-row FOR UPDATE // serialization that guards a cross-space move against a simultaneous autosave on a draft of the -// moving page. MovePageToSpace and UpsertDraft both take lockLiveSpace on the source space -// (page_move.go:52, draft_store.go:257), so the two transactions serialize on the same row. The +// moving page. MovePageToSpace and UpsertDraft both take lockLiveSpace on the source space, so the +// two transactions serialize on the same row. The // test does not assert which one wins — it asserts that whichever ordering the lock produces, the // committed state is one of the legal outcomes: the page reaches the target space, and its draft is // re-homed there exactly once, never duplicated across spaces, orphaned in the source, or left diff --git a/server/store/page_store.go b/server/store/page_store.go index 89135e5..6b60de1 100644 --- a/server/store/page_store.go +++ b/server/store/page_store.go @@ -717,8 +717,8 @@ func (s *Store) GetSpacePages(spaceID string, offset, limit int) ([]*model.PageS // mismatch (stale optimistic-lock baseline) returns ErrConflict. In both conflict cases the whole // transaction is rolled back. // -// draftUpdateAt pins the draft delete to the version the caller read; a concurrent autosave rolls -// the publish back as a ReasonConcurrentAutosave conflict rather than committing older content. +// draftUpdateAt pins the draft delete to the version the caller read (see the delete query below +// for how a version mismatch rolls the whole publish back). func (s *Store) PublishDraft(isNewPage bool, page *model.Page, userID, spaceID string, force bool, maxDepth int, draftUpdateAt int64) (_ *model.Page, err error) { if page == nil { return nil, &ErrInvalidInput{Entity: "Page", Field: "page", Value: nil} From 256687a9012d35f2a4db737b55f02ded6ce4b3a9 Mon Sep 17 00:00:00 2001 From: "Catalin I. Tomai" Date: Thu, 23 Jul 2026 16:32:42 +0200 Subject: [PATCH 30/36] address review, simplify --- server/api.go | 6 + server/api_page_drafts.go | 10 +- server/api_page_drafts_test.go | 3 +- server/app/page_draft.go | 263 +++++++++++++++++-------------- server/app/page_draft_test.go | 3 +- server/app/page_presence.go | 18 +-- server/app/page_presence_test.go | 15 +- server/app/ws_events_test.go | 8 +- server/store/draft_store.go | 6 +- server/store/page_store.go | 21 +-- server/store/store.go | 14 ++ 11 files changed, 218 insertions(+), 149 deletions(-) diff --git a/server/api.go b/server/api.go index 46d715c..2c21ac1 100644 --- a/server/api.go +++ b/server/api.go @@ -138,6 +138,12 @@ func (p *Plugin) writeAppError(w http.ResponseWriter, appErr *mmmodel.AppError) // current server page. It lets a client diff and re-baseline against the live page (its EditAt) in // one round-trip instead of following up with a GET. The whole page is returned rather than a // curated snapshot — it is the complete source of truth, and the client renders whatever it needs. +// +// This is intentionally richer than the other optimistic-lock 409s (handleUpdatePage, handleMovePage, +// handleMovePageToSpace), which return a bare AppError and expect the client to re-read via GET. +// Publish embeds the page because it is the one conflict where the client needs the full current +// content immediately to diff its pending draft against; the edit/move conflicts only need the caller +// to retry against a fresh baseline. New page-mutation 409s should align with one of these two shapes. type conflictResponse struct { Error *mmmodel.AppError `json:"error"` CurrentPage *model.Page `json:"current_page"` diff --git a/server/api_page_drafts.go b/server/api_page_drafts.go index b66dcb2..c3856a2 100644 --- a/server/api_page_drafts.go +++ b/server/api_page_drafts.go @@ -18,7 +18,7 @@ const ( // quotes, backslashes, and control characters are escaped (worst case ~6x for all-control-char // input). Size the transport cap for that worst case plus headroom for the title/props/file-ids // and JSON envelope; the decoded body stays capped at model.PageBodyMaxBytes during normalization. - draftBodyHeadroomBytes = 64 * 1024 // headroom for the title/props/file-ids and JSON envelope + draftBodyHeadroomBytes = 64 * 1024 maxDraftBodyBytes = 6*model.PageBodyMaxBytes + draftBodyHeadroomBytes ) @@ -58,9 +58,11 @@ func (p *Plugin) handleUpdatePageDraft(w http.ResponseWriter, r *http.Request) { return } - // base_edit_at nil → 0 (no baseline: a new-page draft, or an existing-page edit that omitted it, - // which fails closed to a forced publish). Props flows via the pointer below (like file_ids), so it - // is not set on the struct here. + // base_edit_at nil → 0 (no baseline). A new-page draft legitimately has no baseline. For an + // existing published page the client must send base_edit_at on every autosave (see the handler doc + // above): omitting it on the first autosave of an edit session is rejected with 409, because the + // store will not open an edit-session draft against a live page without a baseline. Props flows via + // the pointer below (like file_ids), so it is not set on the struct here. var baseEditAt int64 if req.BaseEditAt != nil { baseEditAt = *req.BaseEditAt diff --git a/server/api_page_drafts_test.go b/server/api_page_drafts_test.go index 032753e..694b6c5 100644 --- a/server/api_page_drafts_test.go +++ b/server/api_page_drafts_test.go @@ -16,6 +16,7 @@ import ( mmmodel "github.com/mattermost/mattermost/server/public/model" + "github.com/mattermost/mattermost-plugin-docs/server/app" "github.com/mattermost/mattermost-plugin-docs/server/model" ) @@ -370,7 +371,7 @@ func TestHandler_ActiveEditorsResponseBody(t *testing.T) { require.NotNil(t, resp.ActiveEditors) require.Empty(t, resp.ActiveEditors) require.Positive(t, resp.SnapshotAt, "response must carry the snapshot timestamp") - require.Equal(t, int64(5*60*1000), resp.ActiveTimeoutMs, "response must carry the active-editor window") + require.Equal(t, app.ActiveEditorTimeoutMs, resp.ActiveTimeoutMs, "response must carry the active-editor window") // Open an edit draft — the user must now appear as an active editor. rec = h.do(t, http.MethodPatch, base+"/pages/"+pageID+"/draft", userID, map[string]any{ diff --git a/server/app/page_draft.go b/server/app/page_draft.go index 1d24282..a866457 100644 --- a/server/app/page_draft.go +++ b/server/app/page_draft.go @@ -5,6 +5,7 @@ package app import ( "errors" + "maps" "net/http" "unicode/utf8" @@ -48,7 +49,6 @@ func (s *Service) UpdatePageDraft(draft *model.Draft, parentID *string, fileIDs if parentID != nil && *parentID != "" && !mmmodel.IsValidId(*parentID) { return nil, mmmodel.NewAppError("UpdatePageDraft", "app.page_draft.update.invalid_parent_id.app_error", nil, "", http.StatusBadRequest) } - s.log.Debug("Updating page draft", "space_id", draft.SpaceId, "page_id", draft.PageId, "user_id", draft.UserId) if draft.Title != "" { title, titleErr := validateTitle("UpdatePageDraft", draft.Title, model.PageTitleMaxRunes) if titleErr != nil { @@ -125,6 +125,12 @@ func (s *Service) UpdatePageDraft(draft *model.Draft, parentID *string, fileIDs return nil, mmmodel.NewAppError("UpdatePageDraft", "app.page_draft.update.draft_changed.app_error", nil, "", http.StatusConflict).Wrap(err) } + if store.InvalidInputReason(err) == store.ReasonPageNotLive { + // The target page was deleted, snapshotted, or moved out of this space between the + // pre-check above and the store's locked read. That is a concurrent state change, not + // bad input, so mirror the 404 the pre-check returns rather than a generic 400. + return nil, mmmodel.NewAppError("UpdatePageDraft", "app.page_draft.update.page_not_found.app_error", nil, "", http.StatusNotFound).Wrap(err) + } return nil, storeAppError("UpdatePageDraft", err) } @@ -425,97 +431,17 @@ func (s *Service) PublishPageDraft(userID, spaceID, pageID string, force bool) ( } } - // 4. Validate and normalise draft body for the page write. - body, searchText, contentErr := normalizePageContent("PublishPageDraft", draft.Body) - if contentErr != nil { - return nil, false, contentErr + // 4/5. Validate & normalise the draft body and build the *model.Page for the store call + // (new-page vs edit-path field rules; see helper). + pageForWrite, buildErr := s.buildPageForPublish(isNewPage, pageID, spaceID, userID, draft, force) + if buildErr != nil { + return nil, false, buildErr } - // 5. Build the *model.Page for the store call. Props follow the same write intent as - // PagePatch.Props: a new page adopts the draft's props outright, while an edit replaces the - // live page's props only when the draft carries a non-empty map and preserves them otherwise. - // (A bare Draft.Props map cannot express "clear to empty" distinctly from "unset", so an empty - // draft map means preserve, consistent with how Title/Body are carried below.) No client sets - // page props through drafts today; this keeps the publish path ready for when one does. - var pageForWrite *model.Page - if isNewPage { - title, titleErr := validateTitle("PublishPageDraft", draft.Title, model.PageTitleMaxRunes) - if titleErr != nil { - return nil, false, titleErr - } - // ChannelId is derived by the store from the space, matching CreatePage, so it is - // intentionally left unset here. - pageForWrite = &model.Page{ - Id: pageID, - SpaceId: spaceID, - ParentId: draft.ParentId, - Title: title, - Body: body, - SearchText: searchText, - Props: draft.Props, - UserId: userID, - LastModifiedBy: userID, - } - } else { - // Edit path: require an optimistic-lock baseline unless force, so a client that never - // captured the page's EditAt cannot silently overwrite a concurrent edit. - baseEditAt := draft.BaseEditAt - haveBaseline := baseEditAt != 0 - if !force && !haveBaseline { - return nil, false, mmmodel.NewAppError("PublishPageDraft", "app.page_draft.publish.baseline_required.app_error", - nil, "", http.StatusBadRequest) - } - // Build pageForWrite with only the fields this draft changed; leave every other field at its - // zero value. The store treats a zero/empty field as "keep the live page's current value" and - // writes just the non-empty ones. Omitted fields are deliberately NOT copied from the pre-lock - // `existing` snapshot: that snapshot can be stale, so on a force-publish copying it back would - // overwrite a field this draft never touched and revert a concurrent edit to it. - // Body uses "" as its unset marker — a document the user cleared is stored as EmptyTipTapJSON, - // never "" — so treating an empty ("") body as unset preserves the live content instead of wiping it. - pageForWrite = &model.Page{ - Id: pageID, - SpaceId: spaceID, - LastModifiedBy: userID, - } - if draft.Title != "" { - title, titleErr := validateTitle("PublishPageDraft", draft.Title, model.PageTitleMaxRunes) - if titleErr != nil { - return nil, false, titleErr - } - pageForWrite.Title = title - } - if draft.Body != "" { - pageForWrite.Body = body - pageForWrite.SearchText = searchText - } - if len(draft.Props) > 0 { - pageForWrite.Props = draft.Props - } - if haveBaseline { - pageForWrite.EditAt = baseEditAt - } - - // "Baseline-only" draft: it carries an optimistic-lock baseline but no content in any field — - // Title, Body, and Props were never populated (all empty). This is NOT a user who cleared the - // document: a cleared doc is EmptyTipTapJSON, a non-empty Body that publishes normally; empty - // here means "never sent". With every field empty there is no page change to write. Publishing - // would still bump EditAt and emit page_updated with no actual change, invalidating other - // editors' baselines for nothing. Treat it as a discard instead: delete the draft and return - // the page as-is. - if pageForWrite.Title == "" && pageForWrite.Body == "" && len(pageForWrite.Props) == 0 { - deleted, delErr := s.store.DeleteDraftVersion(userID, pageID, draft.UpdateAt) - if delErr != nil { - return nil, false, storeAppError("PublishPageDraft", delErr) - } - if !deleted { - // A concurrent autosave advanced the draft version between the read and the delete. - // The newer draft may have real content — the client must re-read and re-publish. - return nil, false, mmmodel.NewAppError("PublishPageDraft", "app.page_draft.publish.draft_changed.app_error", - nil, "", http.StatusConflict) - } - s.clearThrottleAndBroadcastPagePresence(pageID, userID, spaceID, existing.ChannelId) - return existing, false, nil - } + // A "baseline-only" edit draft carries an optimistic-lock baseline but no populated field, so + // there is no page change to write; discard it rather than bumping EditAt for nothing (see helper). + if !isNewPage && pageForWrite.Title == "" && pageForWrite.Body == "" && len(pageForWrite.Props) == 0 { + return s.discardBaselineOnlyDraft(userID, pageID, spaceID, existing, draft.UpdateAt) } // 6. Atomic write: page + draft-delete in one transaction. draft.UpdateAt is passed through so a @@ -549,34 +475,13 @@ func (s *Service) PublishPageDraft(userID, spaceID, pageID string, force bool) ( return current, false, editConflictErr case store.IsErrConflict(storeErr): + // PK collision on the new-page path: a concurrent publish won this page id. Adopt the + // winner and return 200 without broadcasting; a winner that is not this caller's to read + // falls through to a plain conflict (see adoptPublishRaceWinner). if isNewPage { - // PK collision: a concurrent publish won this page id. Adopt the winner's page and - // return 200 without broadcasting (the winner already broadcast wsEventPageCreated). - // The winner must be a live page in this space — a different-space or already-deleted - // winner is not this caller's to read, so fall through to a plain conflict. - raced, rErr := s.GetPageWithDeleted(pageID) - if rErr == nil && raced != nil && raced.SpaceId == spaceID && raced.DeleteAt == 0 { - // Discard this caller's now-orphaned draft so it does not linger pointing at a - // published page — but only if it still holds the version this publish read. A fresh - // autosave after the race winner committed bumps UpdateAt, so the CAS matches no row - // and that newer draft is left intact rather than dropped. - // - // Best-effort: the page is already published by the winner, so a failure here is - // logged (a stray draft the user can discard), never surfaced as a publish failure. - if _, delErr := s.store.DeleteDraftVersion(userID, pageID, draft.UpdateAt); delErr != nil { - s.log.Warn("failed to delete orphaned draft after adopting race winner", - "page_id", pageID, "user_id", userID, "err", delErr) - } - // The draft is consumed; clear the rate-limit entry and broadcast presence so - // the active-editors indicator drops this user, matching the non-conflict path. - s.clearThrottleAndBroadcastPagePresence(pageID, userID, spaceID, raced.ChannelId) + if raced, adopted := s.adoptPublishRaceWinner(userID, pageID, spaceID, draft.UpdateAt); adopted { return raced, false, nil } - if rErr != nil { - // Log this: a real store failure here would otherwise look identical to losing the race. - s.log.Warn("failed to read the page that won the publish race", - "page_id", pageID, "user_id", userID, "err", rErr) - } } return nil, false, mmmodel.NewAppError("PublishPageDraft", "app.page_draft.publish.conflict.app_error", nil, "", http.StatusConflict).Wrap(storeErr) @@ -611,3 +516,131 @@ func (s *Service) PublishPageDraft(userID, spaceID, pageID string, force bool) ( return page, isNewPage, nil } + +// buildPageForPublish normalises the draft body and constructs the *model.Page passed to +// store.PublishDraft. Props follow the same write intent as PagePatch.Props: a new page adopts the +// draft's props outright, while an edit replaces the live page's props only when the draft carries a +// non-empty map and preserves them otherwise. (A bare Draft.Props map cannot express "clear to empty" +// distinctly from "unset", so an empty draft map means preserve, consistent with how Title/Body are +// carried below.) No client sets page props through drafts today; this keeps the publish path ready +// for when one does. The caller detects a baseline-only edit (every field empty) after the build. +func (s *Service) buildPageForPublish(isNewPage bool, pageID, spaceID, userID string, draft *model.Draft, force bool) (*model.Page, *mmmodel.AppError) { + body, searchText, contentErr := normalizePageContent("PublishPageDraft", draft.Body) + if contentErr != nil { + return nil, contentErr + } + + if isNewPage { + title, titleErr := validateTitle("PublishPageDraft", draft.Title, model.PageTitleMaxRunes) + if titleErr != nil { + return nil, titleErr + } + // ChannelId is derived by the store from the space, matching CreatePage, so it is + // intentionally left unset here. + return &model.Page{ + Id: pageID, + SpaceId: spaceID, + ParentId: draft.ParentId, + Title: title, + Body: body, + SearchText: searchText, + Props: maps.Clone(draft.Props), + UserId: userID, + LastModifiedBy: userID, + }, nil + } + + // Edit path: require an optimistic-lock baseline unless force, so a client that never + // captured the page's EditAt cannot silently overwrite a concurrent edit. + baseEditAt := draft.BaseEditAt + haveBaseline := baseEditAt != 0 + if !force && !haveBaseline { + return nil, mmmodel.NewAppError("PublishPageDraft", "app.page_draft.publish.baseline_required.app_error", + nil, "", http.StatusBadRequest) + } + // Build pageForWrite with only the fields this draft changed; leave every other field at its + // zero value. The store treats a zero/empty field as "keep the live page's current value" and + // writes just the non-empty ones. Omitted fields are deliberately NOT copied from the pre-lock + // `existing` snapshot: that snapshot can be stale, so on a force-publish copying it back would + // overwrite a field this draft never touched and revert a concurrent edit to it. + // Body uses "" as its unset marker — a document the user cleared is stored as EmptyTipTapJSON, + // never "" — so treating an empty ("") body as unset preserves the live content instead of wiping it. + pageForWrite := &model.Page{ + Id: pageID, + SpaceId: spaceID, + LastModifiedBy: userID, + } + if draft.Title != "" { + title, titleErr := validateTitle("PublishPageDraft", draft.Title, model.PageTitleMaxRunes) + if titleErr != nil { + return nil, titleErr + } + pageForWrite.Title = title + } + if draft.Body != "" { + pageForWrite.Body = body + pageForWrite.SearchText = searchText + } + if len(draft.Props) > 0 { + // No clone here: the store's edit path merges via model.Page.Patch, which clones Props + // itself. (The new-page path above clones because it inserts pageForWrite directly.) + pageForWrite.Props = draft.Props + } + if haveBaseline { + pageForWrite.EditAt = baseEditAt + } + return pageForWrite, nil +} + +// discardBaselineOnlyDraft handles an edit-path publish whose draft carries an optimistic-lock +// baseline but no content in any field — Title, Body, and Props were never populated (all empty). +// This is NOT a user who cleared the document: a cleared doc is EmptyTipTapJSON, a non-empty Body +// that publishes normally; empty here means "never sent". With every field empty there is no page +// change to write. Publishing would still bump EditAt and emit page_updated with no actual change, +// invalidating other editors' baselines for nothing. So delete the draft and return the page as-is. +func (s *Service) discardBaselineOnlyDraft(userID, pageID, spaceID string, existing *model.Page, draftUpdateAt int64) (*model.Page, bool, *mmmodel.AppError) { + deleted, delErr := s.store.DeleteDraftVersion(userID, pageID, draftUpdateAt) + if delErr != nil { + return nil, false, storeAppError("PublishPageDraft", delErr) + } + if !deleted { + // A concurrent autosave advanced the draft version between the read and the delete. + // The newer draft may have real content — the client must re-read and re-publish. + return nil, false, mmmodel.NewAppError("PublishPageDraft", "app.page_draft.publish.draft_changed.app_error", + nil, "", http.StatusConflict) + } + s.clearThrottleAndBroadcastPagePresence(pageID, userID, spaceID, existing.ChannelId) + return existing, false, nil +} + +// adoptPublishRaceWinner handles the PK-collision case on the new-page publish path: a concurrent +// publish already created this page id. It returns the winner and true when this caller should adopt +// it (return 200 without broadcasting — the winner already broadcast wsEventPageCreated). The winner +// must be a live page in this space; a different-space or already-deleted winner is not this caller's +// to read, so it returns (nil, false) and the caller surfaces a plain conflict. +func (s *Service) adoptPublishRaceWinner(userID, pageID, spaceID string, draftUpdateAt int64) (*model.Page, bool) { + raced, rErr := s.GetPageWithDeleted(pageID) + if rErr == nil && raced != nil && raced.SpaceId == spaceID && raced.DeleteAt == 0 { + // Discard this caller's now-orphaned draft so it does not linger pointing at a + // published page — but only if it still holds the version this publish read. A fresh + // autosave after the race winner committed bumps UpdateAt, so the CAS matches no row + // and that newer draft is left intact rather than dropped. + // + // Best-effort: the page is already published by the winner, so a failure here is + // logged (a stray draft the user can discard), never surfaced as a publish failure. + if _, delErr := s.store.DeleteDraftVersion(userID, pageID, draftUpdateAt); delErr != nil { + s.log.Warn("failed to delete orphaned draft after adopting race winner", + "page_id", pageID, "user_id", userID, "err", delErr) + } + // The draft is consumed; clear the rate-limit entry and broadcast presence so + // the active-editors indicator drops this user, matching the non-conflict path. + s.clearThrottleAndBroadcastPagePresence(pageID, userID, spaceID, raced.ChannelId) + return raced, true + } + if rErr != nil { + // Log this: a real store failure here would otherwise look identical to losing the race. + s.log.Warn("failed to read the page that won the publish race", + "page_id", pageID, "user_id", userID, "err", rErr) + } + return nil, false +} diff --git a/server/app/page_draft_test.go b/server/app/page_draft_test.go index cc2f552..bd93c7b 100644 --- a/server/app/page_draft_test.go +++ b/server/app/page_draft_test.go @@ -13,6 +13,7 @@ import ( mmmodel "github.com/mattermost/mattermost/server/public/model" + "github.com/mattermost/mattermost-plugin-docs/server/app" "github.com/mattermost/mattermost-plugin-docs/server/model" "github.com/mattermost/mattermost-plugin-docs/server/store" ) @@ -283,7 +284,7 @@ func TestActiveEditorsSurfacesHeartbeat(t *testing.T) { require.Nil(t, appErr) require.Contains(t, snapshot.ActiveEditors, userID) require.Positive(t, snapshot.SnapshotAt) - require.Equal(t, int64(5*60*1000), snapshot.ActiveTimeoutMs) + require.Equal(t, app.ActiveEditorTimeoutMs, snapshot.ActiveTimeoutMs) } func TestPublishSetsLastModifiedBy(t *testing.T) { diff --git a/server/app/page_presence.go b/server/app/page_presence.go index 15bbc6d..1469b70 100644 --- a/server/app/page_presence.go +++ b/server/app/page_presence.go @@ -11,11 +11,11 @@ import ( "github.com/mattermost/mattermost-plugin-docs/server/model" ) -// activeEditorTimeoutMs is the window within which a draft autosave keeps a user counted as an +// ActiveEditorTimeoutMs is the window within which a draft autosave keeps a user counted as an // active editor. Presence is derived from the shared DOCS_Draft table: the editor's autosave is // the heartbeat, so an editor with a draft updated inside this window is "active". Because the // list comes from the master DB, it is correct across an HA cluster. -const activeEditorTimeoutMs int64 = 5 * 60 * 1000 +const ActiveEditorTimeoutMs int64 = 5 * 60 * 1000 // presenceBroadcastMinIntervalMs is the minimum time between autosave-triggered presence broadcasts // for the same page. Delete and publish paths always broadcast regardless of this interval. @@ -27,14 +27,14 @@ const presenceBroadcastMinIntervalMs int64 = 30 * 1000 const presenceBroadcastSweepIntervalMs int64 = 5 * 60 * 1000 func activeEditorSince() int64 { - return mmmodel.GetMillis() - activeEditorTimeoutMs + return mmmodel.GetMillis() - ActiveEditorTimeoutMs } // sweepPresenceBroadcastTimes removes stale entries from the broadcast rate-limit map to bound its // size. An entry is normally removed when its session ends (discard or publish); this sweep is the // fallback for sessions abandoned without either. // -// It removes entries older than the active-editor window (activeEditorTimeoutMs). Removing such an +// It removes entries older than the active-editor window (ActiveEditorTimeoutMs). Removing such an // entry cannot change behavior: the map only suppresses a broadcast within // presenceBroadcastMinIntervalMs of the stored time, and an entry this old is already far past that // window, so the next autosave broadcasts whether or not the entry is still present. CompareAndDelete @@ -50,7 +50,7 @@ func (s *Service) sweepPresenceBroadcastTimes(now int64) { } s.presenceBroadcastTimes.Range(func(key, value any) bool { - if ts, ok := value.(int64); ok && now-ts >= activeEditorTimeoutMs { + if ts, ok := value.(int64); ok && now-ts >= ActiveEditorTimeoutMs { s.presenceBroadcastTimes.CompareAndDelete(key, value) } return true @@ -80,7 +80,7 @@ func (s *Service) publishSelfPresence(draft *model.Draft) { "space_id": draft.SpaceId, "active_editors": []string{draft.UserId}, "snapshot_at": mmmodel.GetMillis(), - "active_timeout_ms": activeEditorTimeoutMs, + "active_timeout_ms": ActiveEditorTimeoutMs, }, draft.UserId) } @@ -108,7 +108,7 @@ func (s *Service) broadcastPagePresence(pageID, spaceID, channelID string) { "space_id": spaceID, "active_editors": editors, "snapshot_at": snapshotAt, - "active_timeout_ms": activeEditorTimeoutMs, + "active_timeout_ms": ActiveEditorTimeoutMs, }, channelID) } @@ -149,13 +149,13 @@ func (s *Service) GetPageActiveEditors(pageID, spaceID string) (*PageActiveEdito } // Stamp snapshot_at before the query so it marks when the snapshot was taken, matching the WS event. snapshotAt := mmmodel.GetMillis() - editors, storeErr := s.store.GetPageActiveEditors(pageID, spaceID, snapshotAt-activeEditorTimeoutMs) + editors, storeErr := s.store.GetPageActiveEditors(pageID, spaceID, snapshotAt-ActiveEditorTimeoutMs) if storeErr != nil { return nil, storeAppError("GetPageActiveEditors", storeErr) } return &PageActiveEditors{ ActiveEditors: editors, SnapshotAt: snapshotAt, - ActiveTimeoutMs: activeEditorTimeoutMs, + ActiveTimeoutMs: ActiveEditorTimeoutMs, }, nil } diff --git a/server/app/page_presence_test.go b/server/app/page_presence_test.go index f84dfd1..4426d5b 100644 --- a/server/app/page_presence_test.go +++ b/server/app/page_presence_test.go @@ -9,8 +9,15 @@ import ( "github.com/stretchr/testify/require" ) +// TestActiveEditorTimeoutMsValue pins the active-editor window to its contract value. The other +// presence tests assert response fields equal this const (verifying wiring), so this is the one +// place that guards the value itself. +func TestActiveEditorTimeoutMsValue(t *testing.T) { + require.Equal(t, int64(5*60*1000), ActiveEditorTimeoutMs) +} + // TestSweepPresenceBroadcastTimesEvictsStaleEntriesOncePerWindow verifies sweepPresenceBroadcastTimes's -// two behaviors: it evicts entries older than activeEditorTimeoutMs while leaving fresh entries in +// two behaviors: it evicts entries older than ActiveEditorTimeoutMs while leaving fresh entries in // place, and it runs at most once per presenceBroadcastSweepIntervalMs — a second call with the same // `now` must be a no-op even if a new stale entry was added in between. func TestSweepPresenceBroadcastTimesEvictsStaleEntriesOncePerWindow(t *testing.T) { @@ -19,7 +26,7 @@ func TestSweepPresenceBroadcastTimesEvictsStaleEntriesOncePerWindow(t *testing.T now := int64(1_000_000_000) staleKey := "stale-page:stale-user" freshKey := "fresh-page:fresh-user" - svc.presenceBroadcastTimes.Store(staleKey, now-2*activeEditorTimeoutMs) + svc.presenceBroadcastTimes.Store(staleKey, now-2*ActiveEditorTimeoutMs) svc.presenceBroadcastTimes.Store(freshKey, now) // Open the gate: make the sweep consider itself overdue. @@ -33,8 +40,8 @@ func TestSweepPresenceBroadcastTimesEvictsStaleEntriesOncePerWindow(t *testing.T require.True(t, freshStillPresent, "a fresh entry must survive the sweep") // Re-seed a stale entry and call again with the same `now`: the gate must be closed (at most one - // sweep per activeEditorTimeoutMs), so this entry must NOT be evicted. - svc.presenceBroadcastTimes.Store(staleKey, now-2*activeEditorTimeoutMs) + // sweep per ActiveEditorTimeoutMs), so this entry must NOT be evicted. + svc.presenceBroadcastTimes.Store(staleKey, now-2*ActiveEditorTimeoutMs) svc.sweepPresenceBroadcastTimes(now) _, stillThere := svc.presenceBroadcastTimes.Load(staleKey) diff --git a/server/app/ws_events_test.go b/server/app/ws_events_test.go index f1fe2e9..b6e7ec9 100644 --- a/server/app/ws_events_test.go +++ b/server/app/ws_events_test.go @@ -202,7 +202,7 @@ func TestServiceUpdatePageDraft_PublishesPresenceEvent(t *testing.T) { payload["page_id"] == page.Id && payload["space_id"] == space.Id && payload["snapshot_at"] != nil && - payload["active_timeout_ms"] == int64(5*60*1000) && + payload["active_timeout_ms"] == app.ActiveEditorTimeoutMs && slices.Contains(editors, userID) }), &mmmodel.WebsocketBroadcast{ChannelId: channelID}) @@ -273,7 +273,7 @@ func TestServicePublishPageDraft_PublishesUpdatedEvent(t *testing.T) { payload["page_id"] == republished.Id && payload["space_id"] == space.Id && payload["snapshot_at"] != nil && - payload["active_timeout_ms"] == int64(5*60*1000) && + payload["active_timeout_ms"] == app.ActiveEditorTimeoutMs && len(editors) == 0 }), &mmmodel.WebsocketBroadcast{ChannelId: channelID}) @@ -308,7 +308,7 @@ func TestServiceDeletePageDraft_PublishesPresenceEvent(t *testing.T) { payload["page_id"] == page.Id && payload["space_id"] == space.Id && payload["snapshot_at"] != nil && - payload["active_timeout_ms"] == int64(5*60*1000) && + payload["active_timeout_ms"] == app.ActiveEditorTimeoutMs && len(editors) == 0 }), &mmmodel.WebsocketBroadcast{ChannelId: channelID}) @@ -341,7 +341,7 @@ func TestServiceUpdatePageDraft_NewPageDraftPublishesToUserOnly(t *testing.T) { mockAPI.AssertCalled(t, "PublishWebSocketEvent", "page_presence_updated", mock.MatchedBy(func(payload map[string]any) bool { return payload["page_id"] == draft.PageId && payload["space_id"] == space.Id && - payload["active_timeout_ms"] == int64(5*60*1000) + payload["active_timeout_ms"] == app.ActiveEditorTimeoutMs }), &mmmodel.WebsocketBroadcast{UserId: userID}) diff --git a/server/store/draft_store.go b/server/store/draft_store.go index d88ec9e..9278b2b 100644 --- a/server/store/draft_store.go +++ b/server/store/draft_store.go @@ -188,7 +188,9 @@ FROM chain`, model.MaxPageDepth, model.MaxPageDepth) if err != nil { return errors.Wrap(err, "cycle check: failed to read live ancestor depth") } - // liveDepth counts the ancestor itself; +1 for the new leaf being validated. + // Total depth = liveDepth + ChainDepth + 1: liveDepth is the live ancestor's own absolute + // depth (counts the ancestor itself), ChainDepth is the number of draft hops from that + // ancestor down to the new parent, and +1 places the new leaf one level below its parent. if liveDepth+result.ChainDepth+1 > model.MaxPageDepth { return &ErrInvalidInput{Entity: "Draft", Field: "ParentId", Value: startParentID, Reason: ReasonDraftTooDeep} } @@ -308,7 +310,7 @@ func (s *Store) UpsertDraft(draft *model.Draft, parentID *string, fileIDs *mmmod // scoping. pageWasLive = true if page.DeleteAt != 0 || page.OriginalId != "" || page.SpaceID != draft.SpaceId { - return nil, false, &ErrInvalidInput{Entity: "Draft", Field: "PageId", Value: draft.PageId} + return nil, false, &ErrInvalidInput{Entity: "Draft", Field: "PageId", Value: draft.PageId, Reason: ReasonPageNotLive} } // Establish-time baseline sanity check: on the establishing INSERT (no draft row yet), a // baseline ahead of the live page is impossible — the client cannot have seen a version newer diff --git a/server/store/page_store.go b/server/store/page_store.go index 6b60de1..822ef62 100644 --- a/server/store/page_store.go +++ b/server/store/page_store.go @@ -813,21 +813,24 @@ func (s *Store) PublishDraft(isNewPage bool, page *model.Page, userID, spaceID s return nil, &ErrConflict{Resource: "Page id=" + page.Id, Reason: ReasonConcurrentEdit} } - // Apply only the fields the draft carried against the locked row, preserving current's value - // for any empty (unset) field. An empty Title/Body means "not sent" (a cleared document is - // EmptyTipTapJSON, not ""), so a partial autosave never wipes an untouched field — and a - // force-publish cannot revert a concurrent edit to a field this draft did not change. Props - // follow the same rule: a non-empty map replaces, an empty/nil map preserves current's props. + // Apply only the fields the draft carried against the locked row via the shared Page.Patch + // merge, so the "which fields, how they merge" logic lives in exactly one place (model.Page.Patch + // / PagePatch) rather than being re-implemented here. Empty is the unset marker (a cleared + // document is EmptyTipTapJSON, not ""), so a partial autosave never wipes an untouched field and + // a force-publish cannot revert a concurrent edit to a field this draft did not change. Body and + // SearchText are patched together, as PagePatch requires. + patch := &model.PagePatch{} if page.Title != "" { - current.Title = page.Title + patch.Title = &page.Title } if page.Body != "" { - current.Body = page.Body - current.SearchText = page.SearchText + patch.Body = &page.Body + patch.SearchText = &page.SearchText } if len(page.Props) > 0 { - current.Props = page.Props + patch.Props = &page.Props } + current.Patch(patch) current.LastModifiedBy = page.LastModifiedBy current.PreUpdate() if validErr := current.IsValid(); validErr != nil { diff --git a/server/store/store.go b/server/store/store.go index 0d4793a..e4c1749 100644 --- a/server/store/store.go +++ b/server/store/store.go @@ -316,6 +316,10 @@ const ( ReasonDraftCycle = "draft_cycle" ReasonDraftTooDeep = "draft_too_deep" ReasonDraftQuotaExceeded = "draft_quota_exceeded" + // ReasonPageNotLive marks an autosave whose target page was deleted, snapshotted, or moved out + // of the draft's space between the app-layer pre-check and the locked read — a concurrent state + // change, not bad input, so the caller maps it to 404 rather than a generic 400. + ReasonPageNotLive = "page_not_live" ) func (e *ErrInvalidInput) Error() string { @@ -328,6 +332,16 @@ func IsErrInvalidInput(err error) bool { return errors.As(err, &e) } +// InvalidInputReason returns the Reason of the ErrInvalidInput in err's chain, or "" if err is not +// an ErrInvalidInput or carries no reason. +func InvalidInputReason(err error) string { + var e *ErrInvalidInput + if errors.As(err, &e) { + return e.Reason + } + return "" +} + // Conflict reasons let a caller tell one CAS failure from another without parsing Resource. An // ErrConflict with no Reason is an unqualified conflict (e.g. a primary-key collision). const ( From e85493b64d95a13e14f86c85df42a12ee123fab5 Mon Sep 17 00:00:00 2001 From: "Catalin I. Tomai" Date: Thu, 23 Jul 2026 16:57:12 +0200 Subject: [PATCH 31/36] simplify --- server/app/page_content.go | 50 ++++++++++++++++----------------- server/app/page_content_test.go | 34 +++++++++++----------- server/app/page_draft.go | 11 ++++---- 3 files changed, 48 insertions(+), 47 deletions(-) diff --git a/server/app/page_content.go b/server/app/page_content.go index eacd5cd..028d63e 100644 --- a/server/app/page_content.go +++ b/server/app/page_content.go @@ -15,18 +15,21 @@ import ( "github.com/mattermost/mattermost-plugin-docs/server/model" ) -// normalizePageContent validates and normalizes a page body, deriving SearchText from it. Returns a -// 400 AppError when the body is not valid TipTap content. +// normalizePageContent normalizes a page body — validating it as TipTap content and deriving +// SearchText from it. Returns a 400 AppError when the body is not valid TipTap content. // // An empty body ("") is returned as-is, representing "no content"; the draft path instead seeds new // pages with model.EmptyTipTapJSON (a rendered-empty document). These are two DISTINCT empty // representations, and consumers may assign them different meaning: the publish path treats "" as // "field not sent, preserve the existing page body" and EmptyTipTapJSON as "explicitly cleared". func normalizePageContent(where, body string) (normBody, searchText string, appErr *mmmodel.AppError) { - normBody, searchText, err := validateAndNormalizeContent(body) + normBody, doc, empty, err := normalizeContent(body) if err != nil { return "", "", wrapContentError(where, err) } + if !empty { + searchText = model.BuildSearchText(doc) + } return normBody, searchText, nil } @@ -54,43 +57,40 @@ func normalizePatchContent(where string, patch *model.PagePatch) *mmmodel.AppErr return nil } -// sanitizeContentBody validates and normalizes a body without deriving SearchText, for callers -// (draft autosave) that store only the body. It skips the full-text walk BuildSearchText performs -// on every call — a waste on the highest-frequency write path when the result is discarded. -func sanitizeContentBody(where, body string) (string, *mmmodel.AppError) { - doc, empty, err := normalizeContentToDoc(body) - if err != nil { - return "", wrapContentError(where, err) - } - if empty { - return body, nil - } - normBody, err := marshalTipTapDoc(doc) +// normalizeContentBody normalizes a body without deriving SearchText, for callers (draft autosave) +// that store only the body. It shares normalizeContent with normalizePageContent but discards the +// parsed doc, so it skips the full-text BuildSearchText walk that normalizeContent's caller would +// otherwise run on every call — a waste on the highest-frequency write path. +func normalizeContentBody(where, body string) (string, *mmmodel.AppError) { + normBody, _, _, err := normalizeContent(body) if err != nil { return "", wrapContentError(where, err) } return normBody, nil } -// validateAndNormalizeContent validates and normalizes TipTap/plain-text page content. -// Returns (normalizedBody, searchText, error). An empty content string is returned as-is (no-op). -func validateAndNormalizeContent(content string) (normBody, searchText string, err error) { - doc, empty, err := normalizeContentToDoc(content) +// normalizeContent normalizes TipTap/plain-text page content to its stored body form, returning the +// parsed doc and empty flag so callers decide whether to derive SearchText (normalizePageContent +// does; normalizeContentBody skips it). Returns a raw error; callers wrap it via wrapContentError. +// An empty content string ("") is returned as-is (no-op), with a zero-value doc. +func normalizeContent(content string) (normBody string, doc model.TipTapDocument, empty bool, err error) { + doc, empty, err = normalizeContentToDoc(content) if err != nil { - return "", "", err + return "", model.TipTapDocument{}, false, err } if empty { - return content, "", nil + return content, doc, true, nil } normBody, err = marshalTipTapDoc(doc) if err != nil { - return "", "", err + return "", model.TipTapDocument{}, false, err } - return normBody, model.BuildSearchText(doc), nil + return normBody, doc, false, nil } -// normalizeContentToDoc validates content and returns its normalized TipTap document. empty is true -// for an empty content string ("") — a no-op the caller returns as-is. +// normalizeContentToDoc normalizes content and returns its TipTap document, validating it and +// rejecting invalid TipTap. empty is true for an empty content string ("") — a no-op the caller +// returns as-is. func normalizeContentToDoc(content string) (doc model.TipTapDocument, empty bool, err error) { if content == "" { return model.TipTapDocument{}, true, nil diff --git a/server/app/page_content_test.go b/server/app/page_content_test.go index 7544604..c19e43f 100644 --- a/server/app/page_content_test.go +++ b/server/app/page_content_test.go @@ -14,57 +14,57 @@ import ( "github.com/mattermost/mattermost-plugin-docs/server/model" ) -func TestValidateAndNormalizeContent(t *testing.T) { +func TestNormalizePageContent(t *testing.T) { t.Run("empty content is a no-op", func(t *testing.T) { - body, search, err := validateAndNormalizeContent("") - require.NoError(t, err) + body, search, appErr := normalizePageContent("Test", "") + require.Nil(t, appErr) require.Equal(t, "", body) require.Equal(t, "", search) }) t.Run("TipTap JSON is normalized and search text derived", func(t *testing.T) { - body, search, err := validateAndNormalizeContent(`{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"hello world"}]}]}`) - require.NoError(t, err) + body, search, appErr := normalizePageContent("Test", `{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"hello world"}]}]}`) + require.Nil(t, appErr) require.Contains(t, body, "hello world") require.Equal(t, "hello world", search) }) t.Run("plain text is wrapped into a TipTap doc", func(t *testing.T) { - body, search, err := validateAndNormalizeContent("just text") - require.NoError(t, err) + body, search, appErr := normalizePageContent("Test", "just text") + require.Nil(t, appErr) require.True(t, strings.HasPrefix(body, `{"type":"doc"`), "plain text should be wrapped: %s", body) require.Contains(t, body, "just text") require.Equal(t, "just text", search) }) t.Run("malformed TipTap JSON is rejected", func(t *testing.T) { - _, _, err := validateAndNormalizeContent(`{"type":"bogus"}`) - require.Error(t, err) + _, _, appErr := normalizePageContent("Test", `{"type":"bogus"}`) + require.NotNil(t, appErr) }) t.Run("javascript URL is stripped on normalization", func(t *testing.T) { - body, _, err := validateAndNormalizeContent(`{"type":"doc","content":[{"type":"text","text":"x","marks":[{"type":"link","attrs":{"href":"javascript:alert(1)"}}]}]}`) - require.NoError(t, err) + body, _, appErr := normalizePageContent("Test", `{"type":"doc","content":[{"type":"text","text":"x","marks":[{"type":"link","attrs":{"href":"javascript:alert(1)"}}]}]}`) + require.Nil(t, appErr) require.NotContains(t, body, "javascript:alert") }) t.Run("string starting with { but not valid JSON is wrapped as plain text", func(t *testing.T) { - body, search, err := validateAndNormalizeContent("{shrug}") - require.NoError(t, err) + body, search, appErr := normalizePageContent("Test", "{shrug}") + require.Nil(t, appErr) require.True(t, strings.HasPrefix(body, `{"type":"doc"`), "brace-leading non-JSON must be wrapped as plain text: %s", body) require.Equal(t, "{shrug}", search) }) t.Run("multiline plain text becomes multiple paragraphs", func(t *testing.T) { - body, _, err := validateAndNormalizeContent("line one\nline two") - require.NoError(t, err) + body, _, appErr := normalizePageContent("Test", "line one\nline two") + require.Nil(t, appErr) require.Contains(t, body, "line one") require.Contains(t, body, "line two") }) t.Run("plain text preserves leading whitespace within lines", func(t *testing.T) { - body, _, err := validateAndNormalizeContent(" indented line") - require.NoError(t, err) + body, _, appErr := normalizePageContent("Test", " indented line") + require.Nil(t, appErr) require.Contains(t, body, `" indented line"`, "leading spaces must not be stripped from paragraph text") }) } diff --git a/server/app/page_draft.go b/server/app/page_draft.go index a866457..8d26235 100644 --- a/server/app/page_draft.go +++ b/server/app/page_draft.go @@ -57,15 +57,16 @@ func (s *Service) UpdatePageDraft(draft *model.Draft, parentID *string, fileIDs draft.Title = title } - // Sanitize the draft body on the same content path as publish, so a stored draft never holds - // unsanitized markup. Defense-in-depth: only the author can read a draft back today, but any - // future reader of Draft.Body inherits a sanitized value. + // Normalize the draft body on the same content path as publish, so a stored draft never holds + // unsanitized markup (normalization parses the body through the TipTap sanitizer). Defense-in-depth: + // only the author can read a draft back today, but any future reader of Draft.Body inherits a + // sanitized value. if draft.Body != "" { - sanitizedBody, contentErr := sanitizeContentBody("UpdatePageDraft", draft.Body) + normalizedBody, contentErr := normalizeContentBody("UpdatePageDraft", draft.Body) if contentErr != nil { return nil, contentErr } - draft.Body = sanitizedBody + draft.Body = normalizedBody } // Validate the written Props size here: props is passed to the store separately (pointer intent), From 3e2aaa136d5d9d91f4f0c928d8ce22484d7ea222 Mon Sep 17 00:00:00 2001 From: "Catalin I. Tomai" Date: Sat, 25 Jul 2026 00:12:36 +0200 Subject: [PATCH 32/36] address comments --- server/app/page_draft.go | 25 ++++++++++++++-------- server/app/page_draft_test.go | 9 ++++++-- server/app/page_presence.go | 17 +++++++-------- server/app/ws_events_test.go | 39 +++++++++++++++++++++++++++++++++++ 4 files changed, 70 insertions(+), 20 deletions(-) diff --git a/server/app/page_draft.go b/server/app/page_draft.go index 8d26235..061a8bc 100644 --- a/server/app/page_draft.go +++ b/server/app/page_draft.go @@ -140,7 +140,7 @@ func (s *Service) UpdatePageDraft(draft *model.Draft, parentID *string, fileIDs // the page exists. Send the event only to the author so their own UI can track the session. // UpsertDraft determined liveness as part of the same call, so trust its result here. if !savedPageWasLive { - s.publishSelfPresence(saved) + s.publishSelfPresence(saved.UserId, saved.PageId, saved.SpaceId, []string{saved.UserId}) return saved, nil } @@ -286,7 +286,8 @@ func (s *Service) GetPageDraft(userID, spaceID, pageID string) (*model.Draft, *m return draft, nil } -// DeletePageDraft removes the calling user's draft for the given page (on publish or discard). +// DeletePageDraft removes the calling user's draft for the given page — the discard path. (A +// publish deletes the draft inside the store.PublishDraft transaction without calling here.) // Returns not-found when no draft exists. channelID is the space's backing channel, used to scope // the presence broadcast. func (s *Service) DeletePageDraft(userID, spaceID, pageID, channelID string) *mmmodel.AppError { @@ -334,9 +335,10 @@ func (s *Service) DeletePageDraft(userID, spaceID, pageID, channelID string) *mm } // Presence cleanup: only broadcast channel-wide if the page is published. A new-page draft - // discard was never visible to the channel (no channel broadcast on create), so no cleanup - // broadcast is needed. + // discard was never visible to the channel (no channel broadcast on create) — its session was + // announced to the author alone (publishSelfPresence), so clear it the same way. if !pageWasLive { + s.publishSelfPresence(userID, pageID, spaceID, []string{}) return nil } s.clearThrottleAndBroadcastPagePresence(pageID, userID, spaceID, channelID) @@ -442,7 +444,7 @@ func (s *Service) PublishPageDraft(userID, spaceID, pageID string, force bool) ( // A "baseline-only" edit draft carries an optimistic-lock baseline but no populated field, so // there is no page change to write; discard it rather than bumping EditAt for nothing (see helper). if !isNewPage && pageForWrite.Title == "" && pageForWrite.Body == "" && len(pageForWrite.Props) == 0 { - return s.discardBaselineOnlyDraft(userID, pageID, spaceID, existing, draft.UpdateAt) + return s.discardBaselineOnlyDraft(userID, pageID, spaceID, draft.UpdateAt) } // 6. Atomic write: page + draft-delete in one transaction. draft.UpdateAt is passed through so a @@ -598,8 +600,9 @@ func (s *Service) buildPageForPublish(isNewPage bool, pageID, spaceID, userID st // This is NOT a user who cleared the document: a cleared doc is EmptyTipTapJSON, a non-empty Body // that publishes normally; empty here means "never sent". With every field empty there is no page // change to write. Publishing would still bump EditAt and emit page_updated with no actual change, -// invalidating other editors' baselines for nothing. So delete the draft and return the page as-is. -func (s *Service) discardBaselineOnlyDraft(userID, pageID, spaceID string, existing *model.Page, draftUpdateAt int64) (*model.Page, bool, *mmmodel.AppError) { +// invalidating other editors' baselines for nothing. So delete the draft and return the page from a +// fresh read — not the caller's pre-lock snapshot, which a concurrent edit may have outdated. +func (s *Service) discardBaselineOnlyDraft(userID, pageID, spaceID string, draftUpdateAt int64) (*model.Page, bool, *mmmodel.AppError) { deleted, delErr := s.store.DeleteDraftVersion(userID, pageID, draftUpdateAt) if delErr != nil { return nil, false, storeAppError("PublishPageDraft", delErr) @@ -610,8 +613,12 @@ func (s *Service) discardBaselineOnlyDraft(userID, pageID, spaceID string, exist return nil, false, mmmodel.NewAppError("PublishPageDraft", "app.page_draft.publish.draft_changed.app_error", nil, "", http.StatusConflict) } - s.clearThrottleAndBroadcastPagePresence(pageID, userID, spaceID, existing.ChannelId) - return existing, false, nil + current, getErr := s.GetPage(pageID) + if getErr != nil { + return nil, false, getErr + } + s.clearThrottleAndBroadcastPagePresence(pageID, userID, spaceID, current.ChannelId) + return current, false, nil } // adoptPublishRaceWinner handles the PK-collision case on the new-page publish path: a concurrent diff --git a/server/app/page_draft_test.go b/server/app/page_draft_test.go index bd93c7b..570097f 100644 --- a/server/app/page_draft_test.go +++ b/server/app/page_draft_test.go @@ -9,9 +9,11 @@ import ( "strings" "testing" + "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" mmmodel "github.com/mattermost/mattermost/server/public/model" + "github.com/mattermost/mattermost/server/public/plugin/plugintest" "github.com/mattermost/mattermost-plugin-docs/server/app" "github.com/mattermost/mattermost-plugin-docs/server/model" @@ -84,9 +86,10 @@ func TestPublishEmptyDraftBodyDoesNotWipePage(t *testing.T) { // TestPublishNoOpDraftDiscardsAndReturnsExistingPage verifies that publishing a draft that carries // no content change — only the optimistic-lock baseline, no Title, no Body — is treated as a // discard rather than a no-op page write: the draft is deleted, the existing page comes back -// unchanged, and wasCreated is false. +// unchanged with its EditAt intact, no page_updated event fires, and wasCreated is false. func TestPublishNoOpDraftDiscardsAndReturnsExistingPage(t *testing.T) { - h := openTestService(t) + mockAPI := &plugintest.API{} + h := openTestServiceWithAPI(t, mockAPI) space := mustCreateSpace(t, h.store, mmmodel.NewId()) userID := mmmodel.NewId() @@ -104,6 +107,8 @@ func TestPublishNoOpDraftDiscardsAndReturnsExistingPage(t *testing.T) { require.False(t, wasCreated, "a no-content publish must not report a creation") require.Equal(t, page.Title, result.Title, "the page's title must be unchanged") require.Contains(t, result.Body, "original", "the page's body must be unchanged") + require.Equal(t, page.EditAt, result.EditAt, "a no-content publish must not bump EditAt") + mockAPI.AssertNotCalled(t, "PublishWebSocketEvent", "page_updated", mock.Anything, mock.Anything) // The draft was consumed: it was converted into a discard rather than left in place. _, appErr = h.svc.GetPageDraft(userID, space.Id, page.Id) diff --git a/server/app/page_presence.go b/server/app/page_presence.go index 1469b70..44c3837 100644 --- a/server/app/page_presence.go +++ b/server/app/page_presence.go @@ -7,8 +7,6 @@ import ( "net/http" mmmodel "github.com/mattermost/mattermost/server/public/model" - - "github.com/mattermost/mattermost-plugin-docs/server/model" ) // ActiveEditorTimeoutMs is the window within which a draft autosave keeps a user counted as an @@ -72,16 +70,17 @@ func (s *Service) getActiveEditors(pageID, spaceID string) ([]string, bool) { return editors, true } -// publishSelfPresence sends a presence snapshot to the draft's author only. Used when the page is -// not yet published (no channel to broadcast to), so only the author's own UI learns of the session. -func (s *Service) publishSelfPresence(draft *model.Draft) { +// publishSelfPresence sends a presence snapshot to userID only. Used when the page is not yet +// published (no channel to broadcast to), so only the author's own UI learns of the session: +// editors is the author's own ID while the session is active, and empty when it ends. +func (s *Service) publishSelfPresence(userID, pageID, spaceID string, editors []string) { s.publishToUser(wsEventPagePresenceUpdated, map[string]any{ - "page_id": draft.PageId, - "space_id": draft.SpaceId, - "active_editors": []string{draft.UserId}, + "page_id": pageID, + "space_id": spaceID, + "active_editors": editors, "snapshot_at": mmmodel.GetMillis(), "active_timeout_ms": ActiveEditorTimeoutMs, - }, draft.UserId) + }, userID) } // broadcastPagePresence fans a page_presence_updated event out to the space audience on channelID diff --git a/server/app/ws_events_test.go b/server/app/ws_events_test.go index b6e7ec9..e5447bf 100644 --- a/server/app/ws_events_test.go +++ b/server/app/ws_events_test.go @@ -351,6 +351,45 @@ func TestServiceUpdatePageDraft_NewPageDraftPublishesToUserOnly(t *testing.T) { &mmmodel.WebsocketBroadcast{ChannelId: channelID}) } +// TestServiceDeletePageDraft_NewPageDraftClearsSelfPresenceOnly pins that discarding a draft for a +// not-yet-published page clears presence the same way the session was announced: an empty snapshot +// sent to the author only, never to the space channel (the session was never visible there). +func TestServiceDeletePageDraft_NewPageDraftClearsSelfPresenceOnly(t *testing.T) { + mockAPI := &plugintest.API{} + h := openTestServiceWithAPI(t, mockAPI) + + channelID := mmmodel.NewId() + userID := mmmodel.NewId() + space := mustCreateSpace(t, h.store, channelID) + + // Create a new-page draft — no published page row exists yet. + draft, appErr := h.svc.CreateSpaceDraft(userID, space.Id, "Unpublished", "") + require.Nil(t, appErr) + + // Reset call log so only the discard's broadcast is observed. + mockAPI.Calls = nil + + require.Nil(t, h.svc.DeletePageDraft(userID, space.Id, draft.PageId, channelID)) + + // The clear must be user-scoped and carry the empty editor set ([] not null). + mockAPI.AssertCalled(t, "PublishWebSocketEvent", "page_presence_updated", + mock.MatchedBy(func(payload map[string]any) bool { + editors, ok := payload["active_editors"].([]string) + return ok && + payload["page_id"] == draft.PageId && + payload["space_id"] == space.Id && + payload["snapshot_at"] != nil && + payload["active_timeout_ms"] == app.ActiveEditorTimeoutMs && + len(editors) == 0 + }), + &mmmodel.WebsocketBroadcast{UserId: userID}) + + // Must not broadcast to the channel: the draft was never visible there. + mockAPI.AssertNotCalled(t, "PublishWebSocketEvent", "page_presence_updated", + mock.Anything, + &mmmodel.WebsocketBroadcast{ChannelId: channelID}) +} + // TestServiceUpdatePageDraft_PresenceRateLimitSuppressesSecondBroadcast verifies that a second // autosave within presenceBroadcastMinIntervalMs does not trigger a second channel broadcast. // The rate-limit prevents flooding the channel on every keystroke. From 4951f3f729aea1c92226b7e1bc71cf648b8a26e7 Mon Sep 17 00:00:00 2001 From: "Catalin I. Tomai" Date: Sat, 25 Jul 2026 08:23:57 +0200 Subject: [PATCH 33/36] address comments --- server/app/page_draft.go | 4 +- server/model/page_content.go | 14 +- server/store/draft_store_test.go | 1247 +++++++++++++++++++++++++ server/store/store_test.go | 1501 +++--------------------------- 4 files changed, 1393 insertions(+), 1373 deletions(-) create mode 100644 server/store/draft_store_test.go diff --git a/server/app/page_draft.go b/server/app/page_draft.go index 061a8bc..ba23df8 100644 --- a/server/app/page_draft.go +++ b/server/app/page_draft.go @@ -467,9 +467,9 @@ func (s *Service) PublishPageDraft(userID, spaceID, pageID string, force bool) ( case store.ConflictReason(storeErr) == store.ReasonConcurrentEdit: editConflictErr := mmmodel.NewAppError("PublishPageDraft", "app.page_draft.publish.edit_conflict.app_error", nil, "", http.StatusConflict).Wrap(storeErr) - current, getErr := s.GetPage(pageID) + current, getErr := s.GetPageInSpace("PublishPageDraft", pageID, spaceID, false) if getErr != nil { - // A concurrent delete can remove the page between the conflict and this re-read; fall + // A concurrent delete or cross-space move can make the page unreadable here; fall // back to a bare conflict and let the client GET the page itself. s.log.Warn("failed to re-read page for edit-conflict body", "page_id", pageID, "user_id", userID, "err", getErr) diff --git a/server/model/page_content.go b/server/model/page_content.go index 331a8e2..8326b19 100644 --- a/server/model/page_content.go +++ b/server/model/page_content.go @@ -469,16 +469,18 @@ func isBase64ImagePayload(url string) bool { return slices.Contains(safeImageMIMETypes, contentType) } -// urlScheme returns the lowercased scheme of a URL and whether one is present. A scheme must sit at -// the very start and precede any '/', '?', or '#', matching how browsers parse schemes; a relative -// reference (no scheme) returns ("", false). -func urlScheme(s string) (string, bool) { - for i, r := range s { +// urlScheme returns the scheme of a URL and whether one is present. lower must already be +// lowercased (decodeURLScheme passes its lowered form): the character check below accepts only a-z, +// so an uppercase scheme would be reported as absent. A scheme must sit at the very start and +// precede any '/', '?', or '#', matching how browsers parse schemes; a relative reference (no +// scheme) returns ("", false). +func urlScheme(lower string) (string, bool) { + for i, r := range lower { if r == ':' { if i == 0 { return "", false } - return s[:i], true + return lower[:i], true } if r == '/' || r == '?' || r == '#' { return "", false diff --git a/server/store/draft_store_test.go b/server/store/draft_store_test.go new file mode 100644 index 0000000..3d24143 --- /dev/null +++ b/server/store/draft_store_test.go @@ -0,0 +1,1247 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package store_test + +import ( + "errors" + "strings" + "sync" + "testing" + + sq "github.com/mattermost/squirrel" + "github.com/stretchr/testify/require" + + mmmodel "github.com/mattermost/mattermost/server/public/model" + + "github.com/mattermost/mattermost-plugin-docs/server/model" + "github.com/mattermost/mattermost-plugin-docs/server/store" +) + +// --- Draft tests --- + +func newDraft(userID, spaceID, pageID, parentID string) *model.Draft { + return &model.Draft{ + UserId: userID, + SpaceId: spaceID, + PageId: pageID, + ParentId: parentID, + Title: "Test Draft", + Body: `{"type":"doc","content":[]}`, + } +} + +func TestDraft(t *testing.T) { + t.Run("upsert then get returns the stored draft", func(t *testing.T) { + s := openTestDB(t) + userID, pageID := mmmodel.NewId(), mmmodel.NewId() + space, spaceErr := s.CreateSpace(newSpace(mmmodel.NewId())) + require.NoError(t, spaceErr) + spaceID := space.Id + + saved, _, err := s.UpsertDraft(newDraft(userID, spaceID, pageID, ""), nil, nil, nil) + require.NoError(t, err) + require.NotZero(t, saved.CreateAt) + + got, err := s.GetDraft(userID, pageID) + require.NoError(t, err) + require.Equal(t, pageID, got.PageId) + require.Equal(t, "Test Draft", got.Title) + require.Equal(t, spaceID, got.SpaceId) + }) + + t.Run("upsert replaces existing row and preserves CreateAt", func(t *testing.T) { + s := openTestDB(t) + userID, pageID := mmmodel.NewId(), mmmodel.NewId() + space, spaceErr := s.CreateSpace(newSpace(mmmodel.NewId())) + require.NoError(t, spaceErr) + spaceID := space.Id + + first, _, err := s.UpsertDraft(newDraft(userID, spaceID, pageID, ""), nil, nil, nil) + require.NoError(t, err) + + second := newDraft(userID, spaceID, pageID, "") + second.CreateAt = first.CreateAt + second.Title = "Updated" + _, _, err = s.UpsertDraft(second, nil, nil, nil) + require.NoError(t, err) + + got, err := s.GetDraft(userID, pageID) + require.NoError(t, err) + require.Equal(t, "Updated", got.Title) + require.Equal(t, first.CreateAt, got.CreateAt, "CreateAt preserved across upsert") + }) + + t.Run("an autosave that omits a field keeps the stored value", func(t *testing.T) { + s := openTestDB(t) + userID, pageID := mmmodel.NewId(), mmmodel.NewId() + space, spaceErr := s.CreateSpace(newSpace(mmmodel.NewId())) + require.NoError(t, spaceErr) + + full := newDraft(userID, space.Id, pageID, "") + full.Title = "Original title" + full.Body = `{"type":"doc","content":[{"type":"paragraph"}]}` + full.BaseEditAt = 1234 + stored, _, err := s.UpsertDraft(full, nil, nil, nil) + require.NoError(t, err) + + // A body-only heartbeat: no title, no props. Neither may be wiped. + bodyOnly := newDraft(userID, space.Id, pageID, "") + bodyOnly.Title = "" + bodyOnly.Body = `{"type":"doc","content":[{"type":"paragraph"},{"type":"paragraph"}]}` + bodyOnly.Props = nil + saved, _, err := s.UpsertDraft(bodyOnly, nil, nil, nil) + require.NoError(t, err) + require.Equal(t, "Original title", saved.Title, "an omitted title must not wipe the stored one") + require.Equal(t, bodyOnly.Body, saved.Body, "the sent body must be written") + require.Equal(t, int64(1234), saved.BaseEditAt, + "an omitted baseline must not drop the stored optimistic-lock baseline") + require.Equal(t, stored.CreateAt, saved.CreateAt, "CreateAt preserved across upsert") + + // A title-only heartbeat: no body. The body just written must survive. + titleOnly := newDraft(userID, space.Id, pageID, "") + titleOnly.Title = "Renamed" + titleOnly.Body = "" + saved, _, err = s.UpsertDraft(titleOnly, nil, nil, nil) + require.NoError(t, err) + require.Equal(t, "Renamed", saved.Title) + require.Equal(t, bodyOnly.Body, saved.Body, "an omitted body must not wipe the stored one") + }) + + t.Run("two users can draft the same page id", func(t *testing.T) { + s := openTestDB(t) + pageID := mmmodel.NewId() + space, spaceErr := s.CreateSpace(newSpace(mmmodel.NewId())) + require.NoError(t, spaceErr) + spaceID := space.Id + userA, userB := mmmodel.NewId(), mmmodel.NewId() + + _, _, err := s.UpsertDraft(newDraft(userA, spaceID, pageID, ""), nil, nil, nil) + require.NoError(t, err) + _, _, err = s.UpsertDraft(newDraft(userB, spaceID, pageID, ""), nil, nil, nil) + require.NoError(t, err) + + gotA, err := s.GetDraft(userA, pageID) + require.NoError(t, err) + require.Equal(t, userA, gotA.UserId) + gotB, err := s.GetDraft(userB, pageID) + require.NoError(t, err) + require.Equal(t, userB, gotB.UserId) + }) + + t.Run("delete makes draft not found", func(t *testing.T) { + s := openTestDB(t) + userID, pageID := mmmodel.NewId(), mmmodel.NewId() + space, spaceErr := s.CreateSpace(newSpace(mmmodel.NewId())) + require.NoError(t, spaceErr) + spaceID := space.Id + + _, _, err := s.UpsertDraft(newDraft(userID, spaceID, pageID, ""), nil, nil, nil) + require.NoError(t, err) + + require.NoError(t, s.DeleteDraft(userID, pageID)) + + _, err = s.GetDraft(userID, pageID) + require.True(t, store.IsErrNotFound(err)) + }) + + t.Run("delete nonexistent draft returns not-found", func(t *testing.T) { + s := openTestDB(t) + err := s.DeleteDraft(mmmodel.NewId(), mmmodel.NewId()) + require.True(t, store.IsErrNotFound(err)) + }) + + t.Run("get nonexistent draft returns not-found", func(t *testing.T) { + s := openTestDB(t) + _, err := s.GetDraft(mmmodel.NewId(), mmmodel.NewId()) + require.True(t, store.IsErrNotFound(err)) + }) + + t.Run("drafts for space lists new-page drafts most-recent-first", func(t *testing.T) { + s := openTestDB(t) + userID := mmmodel.NewId() + space, err := s.CreateSpace(newSpace(mmmodel.NewId())) + require.NoError(t, err) + + _, _, err = s.UpsertDraft(newDraft(userID, space.Id, mmmodel.NewId(), ""), nil, nil, nil) + require.NoError(t, err) + second, _, err := s.UpsertDraft(newDraft(userID, space.Id, mmmodel.NewId(), ""), nil, nil, nil) + require.NoError(t, err) + + drafts, err := s.GetDraftsForSpace(userID, space.Id, 0, testDraftListLimit) + require.NoError(t, err) + require.Len(t, drafts, 2) + require.Equal(t, second.PageId, drafts[0].PageId, "most-recently-updated first") + }) + + t.Run("drafts for soft-deleted space are not listed but survive for restore", func(t *testing.T) { + s := openTestDB(t) + userID := mmmodel.NewId() + space, err := s.CreateSpace(newSpace(mmmodel.NewId())) + require.NoError(t, err) + + draft, _, err := s.UpsertDraft(newDraft(userID, space.Id, mmmodel.NewId(), ""), nil, nil, nil) + require.NoError(t, err) + + require.NoError(t, s.DeleteSpace(space.Id)) + + // While the space is soft-deleted both reads are gated to nothing... + drafts, err := s.GetDraftsForSpace(userID, space.Id, 0, testDraftListLimit) + require.NoError(t, err) + require.Empty(t, drafts, "a soft-deleted space lists no drafts") + + _, err = s.GetDraft(userID, draft.PageId) + require.True(t, store.IsErrNotFound(err), "a soft-deleted space gates GetDraft too") + + // ...but the draft row is kept (not purged), so it reappears once the space is restored. + require.NoError(t, s.RestoreSpace(space.Id)) + drafts, err = s.GetDraftsForSpace(userID, space.Id, 0, testDraftListLimit) + require.NoError(t, err) + require.Len(t, drafts, 1, "restoring the space brings its drafts back") + + kept, err := s.GetDraft(userID, draft.PageId) + require.NoError(t, err, "draft is readable again after restore") + require.Equal(t, draft.PageId, kept.PageId) + }) + + t.Run("drafts for space excludes a draft whose page lives in another space", func(t *testing.T) { + s := openTestDB(t) + userID := mmmodel.NewId() + + spaceA, err := s.CreateSpace(newSpace(mmmodel.NewId())) + require.NoError(t, err) + spaceB, err := s.CreateSpace(newSpace(mmmodel.NewId())) + require.NoError(t, err) + + // A live page in space B. UpsertDraft refuses to attach a space-A draft to it (see the + // write-path test below), so insert the cross-space row directly to exercise the + // read-path guard against a corrupt or legacy row. + pageInB, err := s.CreatePage(newPage(spaceB.Id, spaceB.ChannelId, userID, ""), testDefaultMaxDepth) + require.NoError(t, err) + now := mmmodel.GetMillis() + _, rawErr := s.RawExecForTest( + "INSERT INTO DOCS_Draft (UserId, SpaceId, PageId, ParentId, Title, Body, FileIds, Props, CreateAt, UpdateAt) VALUES ($1, $2, $3, '', '', '', '[]', '{}', $4, $4)", + userID, spaceA.Id, pageInB.Id, now) + require.NoError(t, rawErr) + + drafts, err := s.GetDraftsForSpace(userID, spaceA.Id, 0, testDraftListLimit) + require.NoError(t, err) + require.Empty(t, drafts, "a draft whose page belongs to another space must not be listed") + }) + + t.Run("upsert rejects a draft whose page lives in another space", func(t *testing.T) { + s := openTestDB(t) + userID := mmmodel.NewId() + + spaceA, err := s.CreateSpace(newSpace(mmmodel.NewId())) + require.NoError(t, err) + spaceB, err := s.CreateSpace(newSpace(mmmodel.NewId())) + require.NoError(t, err) + + pageInB, err := s.CreatePage(newPage(spaceB.Id, spaceB.ChannelId, userID, ""), testDefaultMaxDepth) + require.NoError(t, err) + + _, _, err = s.UpsertDraft(newDraft(userID, spaceA.Id, pageInB.Id, ""), nil, nil, nil) + require.True(t, store.IsErrInvalidInput(err), "expected invalid input for a cross-space page, got %v", err) + }) + + t.Run("drafts for space excludes drafts on soft-deleted pages", func(t *testing.T) { + s := openTestDB(t) + channelID := mmmodel.NewId() + space, err := s.CreateSpace(newSpace(channelID)) + require.NoError(t, err) + userID := mmmodel.NewId() + + // A draft editing a live page is included. + live, err := s.CreatePage(newPage(space.Id, channelID, userID, ""), testDefaultMaxDepth) + require.NoError(t, err) + dLive := newDraft(userID, space.Id, live.Id, "") + dLive.BaseEditAt = live.EditAt + _, _, err = s.UpsertDraft(dLive, nil, nil, nil) + require.NoError(t, err) + + // A draft whose page is soft-deleted is excluded. + deleted, err := s.CreatePage(newPage(space.Id, channelID, userID, ""), testDefaultMaxDepth) + require.NoError(t, err) + dDeleted := newDraft(userID, space.Id, deleted.Id, "") + dDeleted.BaseEditAt = deleted.EditAt + _, _, err = s.UpsertDraft(dDeleted, nil, nil, nil) + require.NoError(t, err) + require.NoError(t, deletePageErr(s, deleted.Id, deleted.SpaceId, userID)) + + drafts, err := s.GetDraftsForSpace(userID, space.Id, 0, testDraftListLimit) + require.NoError(t, err) + require.Len(t, drafts, 1) + require.Equal(t, live.Id, drafts[0].PageId) + }) + + t.Run("drafts for space excludes drafts on version snapshots", func(t *testing.T) { + s := openTestDB(t) + channelID := mmmodel.NewId() + space, err := s.CreateSpace(newSpace(channelID)) + require.NoError(t, err) + userID := mmmodel.NewId() + + // Draft a live page, then turn that page into a version snapshot (OriginalId set, + // soft-deleted) directly. UpsertDraft refuses to attach to a snapshot, so the draft is + // written while the page is still live; the read path must then exclude it: the LEFT + // JOIN matches the snapshot row, OriginalId != '' fails the live-page predicate, and + // p.Id IS NULL is false. + snap, err := s.CreatePage(newPage(space.Id, channelID, userID, ""), testDefaultMaxDepth) + require.NoError(t, err) + dSnap := newDraft(userID, space.Id, snap.Id, "") + dSnap.BaseEditAt = snap.EditAt + _, _, err = s.UpsertDraft(dSnap, nil, nil, nil) + require.NoError(t, err) + _, rawErr := s.ExecBuilderForTest(s.QueryBuilderForTest(). + Update("DOCS_Page"). + Set("OriginalId", mmmodel.NewId()). + Set("DeleteAt", mmmodel.GetMillis()). + Where(sq.Eq{"Id": snap.Id})) + require.NoError(t, rawErr) + + drafts, err := s.GetDraftsForSpace(userID, space.Id, 0, testDraftListLimit) + require.NoError(t, err) + require.Empty(t, drafts, "a draft on a version snapshot must be excluded") + }) + + t.Run("upsert rejects a draft for a soft-deleted page", func(t *testing.T) { + s := openTestDB(t) + channelID := mmmodel.NewId() + space, err := s.CreateSpace(newSpace(channelID)) + require.NoError(t, err) + userID := mmmodel.NewId() + + page, err := s.CreatePage(newPage(space.Id, channelID, userID, ""), testDefaultMaxDepth) + require.NoError(t, err) + require.NoError(t, deletePageErr(s, page.Id, page.SpaceId, userID)) + + // An autosave landing after the page was deleted must not recreate a draft for it. + _, _, err = s.UpsertDraft(newDraft(userID, space.Id, page.Id, ""), nil, nil, nil) + require.True(t, store.IsErrInvalidInput(err), "expected invalid input for a deleted page, got %v", err) + }) + + t.Run("upsert rejects a draft in a soft-deleted space", func(t *testing.T) { + s := openTestDB(t) + space, err := s.CreateSpace(newSpace(mmmodel.NewId())) + require.NoError(t, err) + require.NoError(t, s.DeleteSpace(space.Id)) + + _, _, err = s.UpsertDraft(newDraft(mmmodel.NewId(), space.Id, mmmodel.NewId(), ""), nil, nil, nil) + require.True(t, store.IsErrNotFound(err), "expected not-found for a deleted space, got %v", err) + }) + + t.Run("upsert accepts a new-page draft under a live parent", func(t *testing.T) { + s := openTestDB(t) + channelID := mmmodel.NewId() + space, err := s.CreateSpace(newSpace(channelID)) + require.NoError(t, err) + userID := mmmodel.NewId() + + parent, err := s.CreatePage(newPage(space.Id, channelID, userID, ""), testDefaultMaxDepth) + require.NoError(t, err) + + parentID := parent.Id + saved, _, err := s.UpsertDraft(newDraft(userID, space.Id, mmmodel.NewId(), parentID), &parentID, nil, nil) + require.NoError(t, err) + require.Equal(t, parent.Id, saved.ParentId) + }) + + t.Run("upsert rejects a draft whose parent does not exist", func(t *testing.T) { + s := openTestDB(t) + space, err := s.CreateSpace(newSpace(mmmodel.NewId())) + require.NoError(t, err) + + missingParentID := mmmodel.NewId() + _, _, err = s.UpsertDraft(newDraft(mmmodel.NewId(), space.Id, mmmodel.NewId(), missingParentID), &missingParentID, nil, nil) + require.True(t, store.IsErrInvalidInput(err), "expected invalid input for a missing parent, got %v", err) + }) + + t.Run("upsert rejects a draft whose parent is soft-deleted", func(t *testing.T) { + s := openTestDB(t) + channelID := mmmodel.NewId() + space, err := s.CreateSpace(newSpace(channelID)) + require.NoError(t, err) + userID := mmmodel.NewId() + + parent, err := s.CreatePage(newPage(space.Id, channelID, userID, ""), testDefaultMaxDepth) + require.NoError(t, err) + require.NoError(t, deletePageErr(s, parent.Id, parent.SpaceId, userID)) + + parentID := parent.Id + _, _, err = s.UpsertDraft(newDraft(userID, space.Id, mmmodel.NewId(), parentID), &parentID, nil, nil) + require.True(t, store.IsErrInvalidInput(err), "expected invalid input for a deleted parent, got %v", err) + }) + + t.Run("upsert rejects a draft whose parent lives in another space", func(t *testing.T) { + s := openTestDB(t) + userID := mmmodel.NewId() + + spaceA, err := s.CreateSpace(newSpace(mmmodel.NewId())) + require.NoError(t, err) + spaceB, err := s.CreateSpace(newSpace(mmmodel.NewId())) + require.NoError(t, err) + + parentInB, err := s.CreatePage(newPage(spaceB.Id, spaceB.ChannelId, userID, ""), testDefaultMaxDepth) + require.NoError(t, err) + + parentID := parentInB.Id + _, _, err = s.UpsertDraft(newDraft(userID, spaceA.Id, mmmodel.NewId(), parentID), &parentID, nil, nil) + require.True(t, store.IsErrInvalidInput(err), "expected invalid input for a cross-space parent, got %v", err) + }) + + t.Run("upsert accepts a parent that is the user's own draft in the same space", func(t *testing.T) { + s := openTestDB(t) + space, err := s.CreateSpace(newSpace(mmmodel.NewId())) + require.NoError(t, err) + userID := mmmodel.NewId() + + parentDraft, _, err := s.UpsertDraft(newDraft(userID, space.Id, mmmodel.NewId(), ""), nil, nil, nil) + require.NoError(t, err) + + parentPageID := parentDraft.PageId + saved, _, err := s.UpsertDraft(newDraft(userID, space.Id, mmmodel.NewId(), parentPageID), &parentPageID, nil, nil) + require.NoError(t, err) + require.Equal(t, parentDraft.PageId, saved.ParentId) + }) + + t.Run("upsert rejects a parent that is another user's draft", func(t *testing.T) { + s := openTestDB(t) + space, err := s.CreateSpace(newSpace(mmmodel.NewId())) + require.NoError(t, err) + userA, userB := mmmodel.NewId(), mmmodel.NewId() + + otherDraft, _, err := s.UpsertDraft(newDraft(userA, space.Id, mmmodel.NewId(), ""), nil, nil, nil) + require.NoError(t, err) + + otherPageID := otherDraft.PageId + _, _, err = s.UpsertDraft(newDraft(userB, space.Id, mmmodel.NewId(), otherPageID), &otherPageID, nil, nil) + require.True(t, store.IsErrInvalidInput(err), "expected invalid input for another user's draft parent, got %v", err) + }) + + // TestDraft/"upsert rejects a draft whose parent chain cycles back to itself" exercises + // checkNoDraftCycle's cycle branch: a root new-page draft, a second draft parented under it, + // then re-parenting the root under the second draft closes the loop root -> child -> root. + t.Run("upsert rejects a draft whose parent chain cycles back to itself", func(t *testing.T) { + s := openTestDB(t) + space, err := s.CreateSpace(newSpace(mmmodel.NewId())) + require.NoError(t, err) + userID := mmmodel.NewId() + + rootDraft, _, err := s.UpsertDraft(newDraft(userID, space.Id, mmmodel.NewId(), ""), nil, nil, nil) + require.NoError(t, err) + + rootPageID := rootDraft.PageId + childDraft, _, err := s.UpsertDraft(newDraft(userID, space.Id, mmmodel.NewId(), rootPageID), &rootPageID, nil, nil) + require.NoError(t, err) + + childPageID := childDraft.PageId + _, _, err = s.UpsertDraft(newDraft(userID, space.Id, rootDraft.PageId, childPageID), &childPageID, nil, nil) + require.Error(t, err) + var inv *store.ErrInvalidInput + require.True(t, errors.As(err, &inv), "expected ErrInvalidInput, got %T: %v", err, err) + require.Equal(t, store.ReasonDraftCycle, inv.Reason) + }) + + // TestDraft/"upsert rejects a draft whose parent chain exceeds the max depth" exercises + // checkNoDraftCycle's too-deep branch. Each draft added to the chain is itself parent-chain + // validated, so a chain of exactly model.MaxPageDepth new-page drafts is the deepest one + // that can be built without tripping the cap; a further draft parented under the deepest one + // is rejected as too deep. + t.Run("upsert rejects a draft whose parent chain exceeds the max depth", func(t *testing.T) { + s := openTestDB(t) + space, err := s.CreateSpace(newSpace(mmmodel.NewId())) + require.NoError(t, err) + userID := mmmodel.NewId() + + parentID := "" + for range model.MaxPageDepth { + pageID := mmmodel.NewId() + var parentParam *string + if parentID != "" { + p := parentID + parentParam = &p + } + _, _, err = s.UpsertDraft(newDraft(userID, space.Id, pageID, parentID), parentParam, nil, nil) + require.NoError(t, err) + parentID = pageID + } + + deepestParentID := parentID + _, _, err = s.UpsertDraft(newDraft(userID, space.Id, mmmodel.NewId(), deepestParentID), &deepestParentID, nil, nil) + require.Error(t, err) + var inv *store.ErrInvalidInput + require.True(t, errors.As(err, &inv), "expected ErrInvalidInput, got %T: %v", err, err) + require.Equal(t, store.ReasonDraftTooDeep, inv.Reason) + }) + + t.Run("drafts for space is scoped to the user", func(t *testing.T) { + s := openTestDB(t) + space, err := s.CreateSpace(newSpace(mmmodel.NewId())) + require.NoError(t, err) + userA, userB := mmmodel.NewId(), mmmodel.NewId() + + _, _, err = s.UpsertDraft(newDraft(userA, space.Id, mmmodel.NewId(), ""), nil, nil, nil) + require.NoError(t, err) + _, _, err = s.UpsertDraft(newDraft(userB, space.Id, mmmodel.NewId(), ""), nil, nil, nil) + require.NoError(t, err) + + drafts, err := s.GetDraftsForSpace(userA, space.Id, 0, testDraftListLimit) + require.NoError(t, err) + require.Len(t, drafts, 1) + require.Equal(t, userA, drafts[0].UserId) + }) + + t.Run("body, file_ids and props round-trip through the database", func(t *testing.T) { + s := openTestDB(t) + userID, pageID := mmmodel.NewId(), mmmodel.NewId() + space, spaceErr := s.CreateSpace(newSpace(mmmodel.NewId())) + require.NoError(t, spaceErr) + spaceID := space.Id + + d := newDraft(userID, spaceID, pageID, "") + d.Body = `{"type":"doc","content":[{"type":"paragraph"}]}` + d.FileIds = mmmodel.StringArray{mmmodel.NewId(), mmmodel.NewId()} + d.Props = mmmodel.StringInterface{"k": float64(1700000000123)} + _, _, err := s.UpsertDraft(d, nil, &d.FileIds, &d.Props) + require.NoError(t, err) + + got, err := s.GetDraft(userID, pageID) + require.NoError(t, err) + require.Equal(t, d.Body, got.Body) + require.Equal(t, d.FileIds, got.FileIds, "StringArray must round-trip through the TEXT column") + require.Equal(t, float64(1700000000123), got.Props["k"], "Props must round-trip through the jsonb column") + }) + + t.Run("empty props default to an empty map on read", func(t *testing.T) { + s := openTestDB(t) + userID, pageID := mmmodel.NewId(), mmmodel.NewId() + space, spaceErr := s.CreateSpace(newSpace(mmmodel.NewId())) + require.NoError(t, spaceErr) + spaceID := space.Id + + _, _, err := s.UpsertDraft(newDraft(userID, spaceID, pageID, ""), nil, nil, nil) + require.NoError(t, err) + + got, err := s.GetDraft(userID, pageID) + require.NoError(t, err) + require.NotNil(t, got.Props) + require.Empty(t, got.Props) + }) + + t.Run("upsert overwrites parent id", func(t *testing.T) { + s := openTestDB(t) + userID, pageID := mmmodel.NewId(), mmmodel.NewId() + space, spaceErr := s.CreateSpace(newSpace(mmmodel.NewId())) + require.NoError(t, spaceErr) + spaceID := space.Id + + // Parents must be live pages in the space (UpsertDraft validates ParentId liveness). + firstPage, err := s.CreatePage(newPage(spaceID, space.ChannelId, userID, ""), testDefaultMaxDepth) + require.NoError(t, err) + secondPage, err := s.CreatePage(newPage(spaceID, space.ChannelId, userID, ""), testDefaultMaxDepth) + require.NoError(t, err) + firstParent, secondParent := firstPage.Id, secondPage.Id + + _, _, err = s.UpsertDraft(newDraft(userID, spaceID, pageID, firstParent), &firstParent, nil, nil) + require.NoError(t, err) + got, err := s.GetDraft(userID, pageID) + require.NoError(t, err) + require.Equal(t, firstParent, got.ParentId) + + _, _, err = s.UpsertDraft(newDraft(userID, spaceID, pageID, secondParent), &secondParent, nil, nil) + require.NoError(t, err) + got, err = s.GetDraft(userID, pageID) + require.NoError(t, err) + require.Equal(t, secondParent, got.ParentId, "second upsert must overwrite ParentId") + }) + + t.Run("title-only empty body round-trips", func(t *testing.T) { + s := openTestDB(t) + userID, pageID := mmmodel.NewId(), mmmodel.NewId() + space, spaceErr := s.CreateSpace(newSpace(mmmodel.NewId())) + require.NoError(t, spaceErr) + spaceID := space.Id + + d := newDraft(userID, spaceID, pageID, "") + d.Title = "Title Only" + d.Body = "" + _, _, err := s.UpsertDraft(d, nil, nil, nil) + require.NoError(t, err) + + got, err := s.GetDraft(userID, pageID) + require.NoError(t, err) + require.Equal(t, "Title Only", got.Title) + require.Equal(t, "", got.Body, "empty body must round-trip as empty string") + }) + + t.Run("drafts for space excludes other spaces for the same user", func(t *testing.T) { + s := openTestDB(t) + userID := mmmodel.NewId() + spaceA, err := s.CreateSpace(newSpace(mmmodel.NewId())) + require.NoError(t, err) + spaceB, err := s.CreateSpace(newSpace(mmmodel.NewId())) + require.NoError(t, err) + + _, _, err = s.UpsertDraft(newDraft(userID, spaceA.Id, mmmodel.NewId(), ""), nil, nil, nil) + require.NoError(t, err) + _, _, err = s.UpsertDraft(newDraft(userID, spaceA.Id, mmmodel.NewId(), ""), nil, nil, nil) + require.NoError(t, err) + _, _, err = s.UpsertDraft(newDraft(userID, spaceB.Id, mmmodel.NewId(), ""), nil, nil, nil) + require.NoError(t, err) + + drafts, err := s.GetDraftsForSpace(userID, spaceA.Id, 0, testDraftListLimit) + require.NoError(t, err) + require.Len(t, drafts, 2) + for _, d := range drafts { + require.Equal(t, spaceA.Id, d.SpaceId) + } + }) + + t.Run("drafts for space returns empty when user has none", func(t *testing.T) { + s := openTestDB(t) + drafts, err := s.GetDraftsForSpace(mmmodel.NewId(), mmmodel.NewId(), 0, testDraftListLimit) + require.NoError(t, err) + require.Empty(t, drafts) + }) + + t.Run("store rejects invalid ids", func(t *testing.T) { + s := openTestDB(t) + valid := mmmodel.NewId() + + // Upsert runs the full model IsValid, so a malformed (non-empty) id is rejected as + // invalid input. + _, _, err := s.UpsertDraft(newDraft("bad", valid, valid, ""), nil, nil, nil) + require.True(t, store.IsErrInvalidInput(err), "upsert with bad user id, got %v", err) + + // Upsert with nil draft must return ErrInvalidInput. + _, _, err = s.UpsertDraft(nil, nil, nil, nil) + require.True(t, store.IsErrInvalidInput(err), "upsert nil draft, got %v", err) + + // Get/Delete guard only against empty ids (matching the page/space store convention); + // a non-empty but unknown id falls through to the query and returns not-found. + _, err = s.GetDraft("", valid) + require.True(t, store.IsErrInvalidInput(err), "get with empty user id, got %v", err) + + _, err = s.GetDraft(valid, "") + require.True(t, store.IsErrInvalidInput(err), "get with empty page id, got %v", err) + + err = s.DeleteDraft("", valid) + require.True(t, store.IsErrInvalidInput(err), "delete with empty user id, got %v", err) + + err = s.DeleteDraft(valid, "") + require.True(t, store.IsErrInvalidInput(err), "delete with empty page id, got %v", err) + }) + + t.Run("GetDraftsForSpace rejects empty userID", func(t *testing.T) { + s := openTestDB(t) + _, err := s.GetDraftsForSpace("", mmmodel.NewId(), 0, testDraftListLimit) + require.True(t, store.IsErrInvalidInput(err), "got %v", err) + }) + + t.Run("GetDraftsForSpace rejects empty spaceID", func(t *testing.T) { + s := openTestDB(t) + _, err := s.GetDraftsForSpace(mmmodel.NewId(), "", 0, testDraftListLimit) + require.True(t, store.IsErrInvalidInput(err), "got %v", err) + }) + + t.Run("GetDraftsForSpace rejects non-positive limit", func(t *testing.T) { + s := openTestDB(t) + _, err := s.GetDraftsForSpace(mmmodel.NewId(), mmmodel.NewId(), 0, 0) + require.True(t, store.IsErrInvalidInput(err), "zero limit must be rejected, got %v", err) + }) +} + +// TestDeletePageReparentsPendingDrafts verifies that deleting a page reparents the new-page +// drafts pending under it to the deleted page's parent — mirroring live-child promotion — so a +// draft never dangles under a soft-deleted parent and stays publishable. +func TestDeletePageReparentsPendingDrafts(t *testing.T) { + s := openTestDB(t) + + channelID := mmmodel.NewId() + userID := mmmodel.NewId() + space, err := s.CreateSpace(newSpace(channelID)) + require.NoError(t, err) + + grandparent, err := s.CreatePage(newPage(space.Id, channelID, userID, ""), testDefaultMaxDepth) + require.NoError(t, err) + parent, err := s.CreatePage(newPage(space.Id, channelID, userID, grandparent.Id), testDefaultMaxDepth) + require.NoError(t, err) + + // A new-page draft (its own page not yet created) pending as a child of parent. + newPageID := mmmodel.NewId() + parentID := parent.Id + _, _, err = s.UpsertDraft(newDraft(userID, space.Id, newPageID, parentID), &parentID, nil, nil) + require.NoError(t, err) + + require.NoError(t, deletePageErr(s, parent.Id, parent.SpaceId, userID)) + + // The draft survives and is reparented to the deleted page's parent (the grandparent), + // which the invariant guarantees is live. + got, err := s.GetDraft(userID, newPageID) + require.NoError(t, err, "pending draft must survive its parent's deletion") + require.Equal(t, grandparent.Id, got.ParentId, "draft must be reparented to the deleted page's parent") + + // The reparented draft is publishable: CreatePage with its parent now succeeds. + _, err = s.CreatePage(newPage(space.Id, channelID, userID, got.ParentId), testDefaultMaxDepth) + require.NoError(t, err, "draft's reparented parent must be a valid live parent") +} + +// TestGetActiveEditorsForPage covers the presence window predicate: a draft updated at/after the +// cutoff counts its user as active; one before the cutoff, or on another page, does not. +func TestGetActiveEditorsForPage(t *testing.T) { + s := openTestDB(t) + space, err := s.CreateSpace(newSpace(mmmodel.NewId())) + require.NoError(t, err) + + pageID := mmmodel.NewId() + userID := mmmodel.NewId() + _, _, err = s.UpsertDraft(newDraft(userID, space.Id, pageID, ""), nil, nil, nil) + require.NoError(t, err) + now := mmmodel.GetMillis() + + t.Run("within window includes the editor", func(t *testing.T) { + editors, err := s.GetPageActiveEditors(pageID, space.Id, now-5*60*1000) + require.NoError(t, err) + require.Contains(t, editors, userID) + }) + + t.Run("cutoff after the update excludes the editor", func(t *testing.T) { + editors, err := s.GetPageActiveEditors(pageID, space.Id, now+60*1000) + require.NoError(t, err) + require.NotContains(t, editors, userID) + }) + + t.Run("a different page has no editors", func(t *testing.T) { + editors, err := s.GetPageActiveEditors(mmmodel.NewId(), space.Id, 0) + require.NoError(t, err) + require.Empty(t, editors) + }) + + t.Run("a new-page draft at the same reserved id in another space does not leak", func(t *testing.T) { + otherSpace, err := s.CreateSpace(newSpace(mmmodel.NewId())) + require.NoError(t, err) + otherUser := mmmodel.NewId() + // Same (reserved) pageID, different space and user — an unpublished new-page draft. + _, _, err = s.UpsertDraft(newDraft(otherUser, otherSpace.Id, pageID, ""), nil, nil, nil) + require.NoError(t, err) + + editors, err := s.GetPageActiveEditors(pageID, space.Id, mmmodel.GetMillis()-5*60*1000) + require.NoError(t, err) + require.Contains(t, editors, userID) + require.NotContains(t, editors, otherUser, + "presence for a page must not disclose an editor from another space sharing the reserved id") + }) +} + +func TestGetActiveEditorsForPageInputValidation(t *testing.T) { + s := openTestDB(t) + valid := mmmodel.NewId() + + _, err := s.GetPageActiveEditors("", valid, 0) + require.True(t, store.IsErrInvalidInput(err), "empty pageID, got %v", err) + + _, err = s.GetPageActiveEditors(valid, "", 0) + require.True(t, store.IsErrInvalidInput(err), "empty spaceID, got %v", err) +} + +func TestGetActiveEditorsForPageMultipleEditorsOrderedByLastActiveAt(t *testing.T) { + s := openTestDB(t) + space, err := s.CreateSpace(newSpace(mmmodel.NewId())) + require.NoError(t, err) + + pageID := mmmodel.NewId() + userA, userB := mmmodel.NewId(), mmmodel.NewId() + + _, _, err = s.UpsertDraft(newDraft(userA, space.Id, pageID, ""), nil, nil, nil) + require.NoError(t, err) + _, _, err = s.UpsertDraft(newDraft(userB, space.Id, pageID, ""), nil, nil, nil) + require.NoError(t, err) + + // Push userA's LastActiveAt into the past so userB (more recent) should appear first. + past := mmmodel.GetMillis() - 60*1000 + _, err = s.ExecBuilderForTest(s.QueryBuilderForTest(). + Update("DOCS_Draft"). + Set("LastActiveAt", past). + Where(sq.Eq{"UserId": userA, "PageId": pageID})) + require.NoError(t, err) + + editors, err := s.GetPageActiveEditors(pageID, space.Id, 0) + require.NoError(t, err) + require.Len(t, editors, 2) + require.Equal(t, userB, editors[0], "most-recently-active editor must appear first") + require.Equal(t, userA, editors[1]) +} + +// TestGetActiveEditorsForPageIgnoresMaintenanceWrites pins presence to LastActiveAt rather than +// UpdateAt. Deleting a page reparents the drafts pending under it, which stamps their UpdateAt +// without their owner having touched them — that must not report the owner as an active editor. +func TestGetActiveEditorsForPageIgnoresMaintenanceWrites(t *testing.T) { + s := openTestDB(t) + channelID := mmmodel.NewId() + space, err := s.CreateSpace(newSpace(channelID)) + require.NoError(t, err) + + userID := mmmodel.NewId() + parent, err := s.CreatePage(newPage(space.Id, channelID, userID, ""), testDefaultMaxDepth) + require.NoError(t, err) + + // A new-page draft pending under the parent, last actually edited well outside the window. + childPageID := mmmodel.NewId() + parentID := parent.Id + _, _, err = s.UpsertDraft(newDraft(userID, space.Id, childPageID, parentID), &parentID, nil, nil) + require.NoError(t, err) + + stale := mmmodel.GetMillis() - 60*60*1000 + _, err = s.ExecBuilderForTest(s.QueryBuilderForTest(). + Update("DOCS_Draft"). + Set("UpdateAt", stale). + Set("LastActiveAt", stale). + Where(sq.Eq{"UserId": userID, "PageId": childPageID})) + require.NoError(t, err) + + // Someone else deletes the parent, which reparents the pending draft and bumps its UpdateAt. + _, err = s.DeletePage(parent.Id, space.Id, mmmodel.NewId()) + require.NoError(t, err) + + cutoff := mmmodel.GetMillis() - 5*60*1000 + editors, err := s.GetPageActiveEditors(childPageID, space.Id, cutoff) + require.NoError(t, err) + require.NotContains(t, editors, userID, + "reparenting a draft must not report its owner as an active editor") +} + +// TestUpsertDraftBumpsUpdateAtMonotonically guards the draft's UpdateAt version token: it must +// advance strictly past the stored value even when the saving node's wall clock is behind it, so a +// later autosave can never commit an UpdateAt that collides with the value a publish already +// captured (which would let the publish CAS delete the newer draft and ship older content). +func TestUpsertDraftBumpsUpdateAtMonotonically(t *testing.T) { + s := openTestDB(t) + space, err := s.CreateSpace(newSpace(mmmodel.NewId())) + require.NoError(t, err) + userID := mmmodel.NewId() + pageID := mmmodel.NewId() + + _, _, err = s.UpsertDraft(newDraft(userID, space.Id, pageID, ""), nil, nil, nil) + require.NoError(t, err) + + // Force the stored UpdateAt ahead of the next save's wall clock. Without the monotonic bump, + // the next upsert would write a smaller UpdateAt (its own GetMillis()). + future := mmmodel.GetMillis() + 60*60*1000 + _, err = s.ExecBuilderForTest(s.QueryBuilderForTest(). + Update("DOCS_Draft"). + Set("UpdateAt", future). + Where(sq.Eq{"UserId": userID, "PageId": pageID})) + require.NoError(t, err) + + saved, _, err := s.UpsertDraft(newDraft(userID, space.Id, pageID, ""), nil, nil, nil) + require.NoError(t, err) + require.Equal(t, future+1, saved.UpdateAt, + "UpdateAt must advance to stored+1 when the incoming timestamp is not already greater") +} + +func TestDeleteDraftVersion(t *testing.T) { + s := openTestDB(t) + space, err := s.CreateSpace(newSpace(mmmodel.NewId())) + require.NoError(t, err) + userID := mmmodel.NewId() + pageID := mmmodel.NewId() + + saved, _, err := s.UpsertDraft(newDraft(userID, space.Id, pageID, ""), nil, nil, nil) + require.NoError(t, err) + + t.Run("stale version deletes nothing and leaves the draft intact", func(t *testing.T) { + deleted, delErr := s.DeleteDraftVersion(userID, pageID, saved.UpdateAt-1) + require.NoError(t, delErr) + require.False(t, deleted, "a mismatched version must not delete the row") + got, getErr := s.GetDraft(userID, pageID) + require.NoError(t, getErr, "the draft must survive a stale-version delete") + require.Equal(t, saved.UpdateAt, got.UpdateAt) + }) + + t.Run("matching version deletes the draft", func(t *testing.T) { + deleted, delErr := s.DeleteDraftVersion(userID, pageID, saved.UpdateAt) + require.NoError(t, delErr) + require.True(t, deleted, "the matching version must delete the row") + _, getErr := s.GetDraft(userID, pageID) + require.True(t, store.IsErrNotFound(getErr), "the draft must be gone") + }) + + t.Run("missing draft reports false without error", func(t *testing.T) { + deleted, delErr := s.DeleteDraftVersion(userID, mmmodel.NewId(), 1) + require.NoError(t, delErr) + require.False(t, deleted) + }) +} + +// TestPublishDraft covers the atomic publish transaction at the store boundary: the new-page +// insert-and-delete-draft path, and the edit path's optimistic-lock CAS. +func TestPublishDraft(t *testing.T) { + t.Run("new page inserts the page and deletes the draft", func(t *testing.T) { + s := openTestDB(t) + channelID := mmmodel.NewId() + space, err := s.CreateSpace(newSpace(channelID)) + require.NoError(t, err) + userID := mmmodel.NewId() + pageID := mmmodel.NewId() + + draft, _, err := s.UpsertDraft(newDraft(userID, space.Id, pageID, ""), nil, nil, nil) + require.NoError(t, err) + + page := &model.Page{Id: pageID, SpaceId: space.Id, Title: "Published", Body: `{"type":"doc","content":[]}`, UserId: userID} + published, err := s.PublishDraft(true, page, userID, space.Id, false, testDefaultMaxDepth, draft.UpdateAt) + require.NoError(t, err) + require.Equal(t, pageID, published.Id) + + _, getErr := s.GetDraft(userID, pageID) + require.True(t, store.IsErrNotFound(getErr), "draft must be deleted by publish") + + live, err := s.GetPage(pageID, false) + require.NoError(t, err) + require.Equal(t, "Published", live.Title) + }) + + t.Run("edit path conflicts on a stale baseline", func(t *testing.T) { + s := openTestDB(t) + channelID := mmmodel.NewId() + space, err := s.CreateSpace(newSpace(channelID)) + require.NoError(t, err) + userID := mmmodel.NewId() + + created, err := s.CreatePage(newPage(space.Id, channelID, userID, ""), testDefaultMaxDepth) + require.NoError(t, err) + d := newDraft(userID, space.Id, created.Id, "") + d.BaseEditAt = created.EditAt + draft, _, err := s.UpsertDraft(d, nil, nil, nil) + require.NoError(t, err) + + edit := *created + edit.Title = "Edited" + edit.Body = `{"type":"doc","content":[]}` + edit.EditAt = created.EditAt - 1 // stale baseline + + _, err = s.PublishDraft(false, &edit, userID, space.Id, false, testDefaultMaxDepth, draft.UpdateAt) + require.True(t, store.IsErrConflict(err), "a stale baseline must conflict, got %v", err) + }) + + t.Run("edit path succeeds with a matching baseline", func(t *testing.T) { + s := openTestDB(t) + channelID := mmmodel.NewId() + space, err := s.CreateSpace(newSpace(channelID)) + require.NoError(t, err) + userID := mmmodel.NewId() + + created, err := s.CreatePage(newPage(space.Id, channelID, userID, ""), testDefaultMaxDepth) + require.NoError(t, err) + d2 := newDraft(userID, space.Id, created.Id, "") + d2.BaseEditAt = created.EditAt + draft, _, err := s.UpsertDraft(d2, nil, nil, nil) + require.NoError(t, err) + + edit := *created + edit.Title = "Edited" + edit.Body = `{"type":"doc","content":[]}` + edit.EditAt = created.EditAt // matching baseline + + published, err := s.PublishDraft(false, &edit, userID, space.Id, false, testDefaultMaxDepth, draft.UpdateAt) + require.NoError(t, err) + require.Equal(t, "Edited", published.Title) + require.Greater(t, published.EditAt, created.EditAt, "publish advances EditAt") + + _, getErr := s.GetDraft(userID, created.Id) + require.True(t, store.IsErrNotFound(getErr), "draft must be deleted by publish") + }) + + t.Run("an autosave landing after the draft was read rolls the publish back", func(t *testing.T) { + s := openTestDB(t) + channelID := mmmodel.NewId() + space, err := s.CreateSpace(newSpace(channelID)) + require.NoError(t, err) + userID := mmmodel.NewId() + + created, err := s.CreatePage(newPage(space.Id, channelID, userID, ""), testDefaultMaxDepth) + require.NoError(t, err) + d3 := newDraft(userID, space.Id, created.Id, "") + d3.BaseEditAt = created.EditAt + stale, _, err := s.UpsertDraft(d3, nil, nil, nil) + require.NoError(t, err) + + // The user's editor autosaves again after the publish path read the draft. + newer := newDraft(userID, space.Id, created.Id, "") + newer.Body = `{"type":"doc","content":[{"type":"paragraph"}]}` + newer.BaseEditAt = created.EditAt + newer, _, err = s.UpsertDraft(newer, nil, nil, nil) + require.NoError(t, err) + require.Greater(t, newer.UpdateAt, stale.UpdateAt, "the autosave must advance UpdateAt") + + edit := *created + edit.Title = "Published from stale content" + edit.Body = `{"type":"doc","content":[]}` + edit.EditAt = created.EditAt + + _, err = s.PublishDraft(false, &edit, userID, space.Id, false, testDefaultMaxDepth, stale.UpdateAt) + require.True(t, store.IsErrConflict(err), "publishing stale draft content must conflict, got %v", err) + + // The page must be untouched and the newer draft must survive for the client to republish. + live, err := s.GetPage(created.Id, false) + require.NoError(t, err) + require.NotEqual(t, "Published from stale content", live.Title, "the rolled-back publish must not have written the page") + + survived, err := s.GetDraft(userID, created.Id) + require.NoError(t, err, "the newer draft must survive the rolled-back publish") + require.Equal(t, newer.Body, survived.Body) + }) +} + +// TestUpsertDraftBaseEditAtWriteOnce verifies BaseEditAt is frozen at the establishing INSERT: a +// later upsert on the same (UserId, PageId) key carries a different BaseEditAt, but the stored +// (and returned) value never moves off the value the draft was established with. +func TestUpsertDraftBaseEditAtWriteOnce(t *testing.T) { + s := openTestDB(t) + + channelID := mmmodel.NewId() + userID := mmmodel.NewId() + space, err := s.CreateSpace(newSpace(channelID)) + require.NoError(t, err) + page, err := s.CreatePage(newPage(space.Id, channelID, userID, ""), testDefaultMaxDepth) + require.NoError(t, err) + + established := newDraft(userID, space.Id, page.Id, "") + established.BaseEditAt = page.EditAt + saved, _, err := s.UpsertDraft(established, nil, nil, nil) + require.NoError(t, err) + require.Equal(t, page.EditAt, saved.BaseEditAt) + + later := newDraft(userID, space.Id, page.Id, "") + later.BaseEditAt = page.EditAt + 1000 + updated, _, err := s.UpsertDraft(later, nil, nil, nil) + require.NoError(t, err) + require.Equal(t, page.EditAt, updated.BaseEditAt, + "BaseEditAt is write-once: a later upsert must not change the established baseline") + + // The persisted row (not just the returned struct) must reflect the same frozen value. + persisted, err := s.GetDraft(userID, page.Id) + require.NoError(t, err) + require.Equal(t, page.EditAt, persisted.BaseEditAt) +} + +// TestUpsertDraftPropsReplaceOrKeep verifies the whole-value replace-or-keep semantics of the props +// write-intent pointer: nil preserves the stored map untouched, a non-nil pointer replaces the whole +// map (dropping any key it doesn't carry), and a non-nil pointer to an empty map clears every key. +func TestUpsertDraftPropsReplaceOrKeep(t *testing.T) { + s := openTestDB(t) + + userID, pageID := mmmodel.NewId(), mmmodel.NewId() + space, err := s.CreateSpace(newSpace(mmmodel.NewId())) + require.NoError(t, err) + + d := newDraft(userID, space.Id, pageID, "") + d.Props = mmmodel.StringInterface{"foo": "bar"} + stored, _, err := s.UpsertDraft(d, nil, nil, &d.Props) + require.NoError(t, err) + require.Equal(t, "bar", stored.Props["foo"]) + + // A nil props pointer omits the write and preserves the stored map. + omit := newDraft(userID, space.Id, pageID, "") + afterOmit, _, err := s.UpsertDraft(omit, nil, nil, nil) + require.NoError(t, err) + require.Equal(t, "bar", afterOmit.Props["foo"], "a nil props pointer must preserve the stored map") + + // A non-nil props pointer replaces the whole map: the unrelated "foo" key set above is gone, + // not merged with the new "baz" key. + replace := newDraft(userID, space.Id, pageID, "") + replace.Props = mmmodel.StringInterface{"baz": "qux"} + afterReplace, _, err := s.UpsertDraft(replace, nil, nil, &replace.Props) + require.NoError(t, err) + require.Equal(t, "qux", afterReplace.Props["baz"]) + require.NotContains(t, afterReplace.Props, "foo", + "a non-nil props pointer must replace the whole map, not merge keys") + + // A non-nil pointer to an empty map clears every key. + toClear := newDraft(userID, space.Id, pageID, "") + emptyProps := mmmodel.StringInterface{} + cleared, _, err := s.UpsertDraft(toClear, nil, nil, &emptyProps) + require.NoError(t, err) + require.Empty(t, cleared.Props, "a non-nil pointer to an empty map must clear all keys") +} + +// TestUpsertDraftOversizedPropsRejected verifies the store rejects a draft whose Props field +// (the field Draft.IsValid actually checks) exceeds PagePropsMaxBytes, regardless of what the +// props write-intent pointer carries. This is enforced by Draft.IsValid, not by the pointer's +// contents — sizing the pointer's target (rather than draft.Props) is the App layer's job. +func TestUpsertDraftOversizedPropsRejected(t *testing.T) { + s := openTestDB(t) + + userID, pageID := mmmodel.NewId(), mmmodel.NewId() + space, err := s.CreateSpace(newSpace(mmmodel.NewId())) + require.NoError(t, err) + + d := newDraft(userID, space.Id, pageID, "") + d.Props = mmmodel.StringInterface{"k": strings.Repeat("x", model.PagePropsMaxBytes)} + _, _, err = s.UpsertDraft(d, nil, nil, &d.Props) + require.Error(t, err) + require.True(t, store.IsErrInvalidInput(err), "oversized draft.Props must be rejected by Draft.IsValid, got %v", err) +} + +// TestUpsertDraftEstablishGuardRejectsBaselineAheadOfPage verifies the establish-time guard: an +// establishing INSERT (no existing draft row) whose BaseEditAt is ahead of the live page's current +// EditAt is impossible (the client cannot have seen a version newer than the one that exists) and +// is rejected as invalid input. A baseline equal to the page's EditAt is accepted; a baseline +// behind it is not caught by this guard but is still rejected by the separate resurrection check +// (see TestUpsertDraftResurrectionClassification). +func TestUpsertDraftEstablishGuardRejectsBaselineAheadOfPage(t *testing.T) { + s := openTestDB(t) + + channelID := mmmodel.NewId() + userID := mmmodel.NewId() + space, err := s.CreateSpace(newSpace(channelID)) + require.NoError(t, err) + + t.Run("ahead of the live page is rejected", func(t *testing.T) { + page, err := s.CreatePage(newPage(space.Id, channelID, userID, ""), testDefaultMaxDepth) + require.NoError(t, err) + + ahead := newDraft(userID, space.Id, page.Id, "") + ahead.BaseEditAt = page.EditAt + 1000 + _, _, err = s.UpsertDraft(ahead, nil, nil, nil) + require.Error(t, err) + var inv *store.ErrInvalidInput + require.True(t, errors.As(err, &inv), "expected ErrInvalidInput, got %T: %v", err, err) + require.Equal(t, "BaseEditAt", inv.Field) + }) + + t.Run("equal to the live page is accepted", func(t *testing.T) { + page, err := s.CreatePage(newPage(space.Id, channelID, userID, ""), testDefaultMaxDepth) + require.NoError(t, err) + + equal := newDraft(userID, space.Id, page.Id, "") + equal.BaseEditAt = page.EditAt + _, _, err = s.UpsertDraft(equal, nil, nil, nil) + require.NoError(t, err, "an establishing baseline equal to the live page's EditAt must be accepted") + }) + + // A baseline strictly behind the live page's EditAt passes the ahead-only guard above (it is + // not "ahead"), but is still rejected — by the separate resurrection check just below the + // guard, since this is still a first-ever establish (no existing draft row) and the page + // advanced past the caller's baseline. This is a real optimistic-lock conflict, not a bug: + // the client's session is already stale on its very first save. + t.Run("behind the live page is rejected as a stale baseline, not by the ahead-only guard", func(t *testing.T) { + page, err := s.CreatePage(newPage(space.Id, channelID, userID, ""), testDefaultMaxDepth) + require.NoError(t, err) + + behind := newDraft(userID, space.Id, page.Id, "") + behind.BaseEditAt = page.EditAt - 1 + _, _, err = s.UpsertDraft(behind, nil, nil, nil) + require.Error(t, err) + require.False(t, store.IsErrInvalidInput(err), "a behind baseline must not trip the ahead-only establish guard") + require.True(t, store.IsErrConflict(err), "expected ErrConflict, got %T: %v", err, err) + require.Equal(t, store.ReasonConcurrentEdit, store.ConflictReason(err)) + }) +} + +// TestUpsertDraftConcurrentFirstAutosavesSerialize verifies that two concurrent establishing +// upserts for the same (userID, pageID) on an existing page do not both take the "no existing +// draft" branch: the per-space FOR UPDATE lock (lockLiveSpace) serializes them, so only the first +// is a true establish and every later one observes the row the first inserted and is treated as an +// update — neither is falsely rejected by the establish-time guard. +func TestUpsertDraftConcurrentFirstAutosavesSerialize(t *testing.T) { + s := openTestDB(t) + + channelID := mmmodel.NewId() + userID := mmmodel.NewId() + space, err := s.CreateSpace(newSpace(channelID)) + require.NoError(t, err) + page, err := s.CreatePage(newPage(space.Id, channelID, userID, ""), testDefaultMaxDepth) + require.NoError(t, err) + + const n = 8 + start := make(chan struct{}) + var wg sync.WaitGroup + errs := make([]error, n) + wg.Add(n) + for i := range n { + go func() { + defer wg.Done() + <-start + d := newDraft(userID, space.Id, page.Id, "") + d.BaseEditAt = page.EditAt + _, _, errs[i] = s.UpsertDraft(d, nil, nil, nil) + }() + } + close(start) + wg.Wait() + + for i, uErr := range errs { + require.NoError(t, uErr, "concurrent first-autosave %d must not be falsely rejected by the establish guard", i) + } + + got, err := s.GetDraft(userID, page.Id) + require.NoError(t, err) + require.Equal(t, page.EditAt, got.BaseEditAt) +} + +// TestUpsertDraftResurrectionClassification verifies UpsertDraft distinguishes the two resurrection +// reasons: an autosave with a stale non-zero BaseEditAt behind the page's current EditAt (the page +// advanced under it) classifies as ReasonConcurrentEdit, while an autosave with no baseline (0) on a +// page id a concurrent publish just claimed classifies as ReasonConcurrentAutosave. Both fire only +// when the draft row a resurrection would recreate no longer exists (a concurrent publish consumed +// it), matching the "refuse to resurrect a consumed draft" contract in UpsertDraft. +func TestUpsertDraftResurrectionClassification(t *testing.T) { + t.Run("stale non-zero baseline behind the page classifies as concurrent edit", func(t *testing.T) { + s := openTestDB(t) + channelID := mmmodel.NewId() + userID := mmmodel.NewId() + space, err := s.CreateSpace(newSpace(channelID)) + require.NoError(t, err) + page, err := s.CreatePage(newPage(space.Id, channelID, userID, ""), testDefaultMaxDepth) + require.NoError(t, err) + + d := newDraft(userID, space.Id, page.Id, "") + d.BaseEditAt = page.EditAt + _, _, err = s.UpsertDraft(d, nil, nil, nil) + require.NoError(t, err) + + // The page is edited (advancing EditAt past the draft's baseline), and the draft is + // removed — simulating a concurrent publish that consumed it. + newTitle := "Edited concurrently" + edited, err := s.UpdatePage(page.Id, page.SpaceId, &model.PagePatch{Title: &newTitle}, page.EditAt, false, userID) + require.NoError(t, err) + require.Greater(t, edited.EditAt, page.EditAt) + require.NoError(t, s.DeleteDraft(userID, page.Id)) + + // A stale-baseline autosave tries to re-establish the now-consumed draft. + stale := newDraft(userID, space.Id, page.Id, "") + stale.BaseEditAt = page.EditAt + _, _, err = s.UpsertDraft(stale, nil, nil, nil) + require.Error(t, err) + require.True(t, store.IsErrConflict(err), "expected ErrConflict, got %T: %v", err, err) + require.Equal(t, store.ReasonConcurrentEdit, store.ConflictReason(err)) + }) + + t.Run("no baseline on a page a concurrent publish just claimed classifies as concurrent autosave", func(t *testing.T) { + s := openTestDB(t) + channelID := mmmodel.NewId() + userID := mmmodel.NewId() + space, err := s.CreateSpace(newSpace(channelID)) + require.NoError(t, err) + + pageID := mmmodel.NewId() + d := newDraft(userID, space.Id, pageID, "") + _, _, err = s.UpsertDraft(d, nil, nil, nil) + require.NoError(t, err) + + // A concurrent publish creates the page at that exact id and removes the draft. + published := newPage(space.Id, channelID, userID, "") + published.Id = pageID + _, err = s.CreatePage(published, testDefaultMaxDepth) + require.NoError(t, err) + require.NoError(t, s.DeleteDraft(userID, pageID)) + + // A baseline-less autosave tries to re-establish the now-consumed new-page draft. + stale := newDraft(userID, space.Id, pageID, "") + _, _, err = s.UpsertDraft(stale, nil, nil, nil) + require.Error(t, err) + require.True(t, store.IsErrConflict(err), "expected ErrConflict, got %T: %v", err, err) + require.Equal(t, store.ReasonConcurrentAutosave, store.ConflictReason(err)) + }) +} diff --git a/server/store/store_test.go b/server/store/store_test.go index 81f9094..d9b987c 100644 --- a/server/store/store_test.go +++ b/server/store/store_test.go @@ -7,7 +7,6 @@ import ( "errors" "fmt" "runtime" - "strings" "sync" "sync/atomic" "testing" @@ -1640,1283 +1639,74 @@ func TestUpdatePageRejectsNilAndEmptyPatch(t *testing.T) { require.Equal(t, created.EditAt, after.EditAt, "a rejected patch must not bump EditAt") } -// --- Draft tests --- - -func newDraft(userID, spaceID, pageID, parentID string) *model.Draft { - return &model.Draft{ - UserId: userID, - SpaceId: spaceID, - PageId: pageID, - ParentId: parentID, - Title: "Test Draft", - Body: `{"type":"doc","content":[]}`, - } -} - -func TestDraft(t *testing.T) { - t.Run("upsert then get returns the stored draft", func(t *testing.T) { - s := openTestDB(t) - userID, pageID := mmmodel.NewId(), mmmodel.NewId() - space, spaceErr := s.CreateSpace(newSpace(mmmodel.NewId())) - require.NoError(t, spaceErr) - spaceID := space.Id - - saved, _, err := s.UpsertDraft(newDraft(userID, spaceID, pageID, ""), nil, nil, nil) - require.NoError(t, err) - require.NotZero(t, saved.CreateAt) - - got, err := s.GetDraft(userID, pageID) - require.NoError(t, err) - require.Equal(t, pageID, got.PageId) - require.Equal(t, "Test Draft", got.Title) - require.Equal(t, spaceID, got.SpaceId) - }) - - t.Run("upsert replaces existing row and preserves CreateAt", func(t *testing.T) { - s := openTestDB(t) - userID, pageID := mmmodel.NewId(), mmmodel.NewId() - space, spaceErr := s.CreateSpace(newSpace(mmmodel.NewId())) - require.NoError(t, spaceErr) - spaceID := space.Id - - first, _, err := s.UpsertDraft(newDraft(userID, spaceID, pageID, ""), nil, nil, nil) - require.NoError(t, err) - - second := newDraft(userID, spaceID, pageID, "") - second.CreateAt = first.CreateAt - second.Title = "Updated" - _, _, err = s.UpsertDraft(second, nil, nil, nil) - require.NoError(t, err) - - got, err := s.GetDraft(userID, pageID) - require.NoError(t, err) - require.Equal(t, "Updated", got.Title) - require.Equal(t, first.CreateAt, got.CreateAt, "CreateAt preserved across upsert") - }) - - t.Run("an autosave that omits a field keeps the stored value", func(t *testing.T) { - s := openTestDB(t) - userID, pageID := mmmodel.NewId(), mmmodel.NewId() - space, spaceErr := s.CreateSpace(newSpace(mmmodel.NewId())) - require.NoError(t, spaceErr) - - full := newDraft(userID, space.Id, pageID, "") - full.Title = "Original title" - full.Body = `{"type":"doc","content":[{"type":"paragraph"}]}` - full.BaseEditAt = 1234 - stored, _, err := s.UpsertDraft(full, nil, nil, nil) - require.NoError(t, err) - - // A body-only heartbeat: no title, no props. Neither may be wiped. - bodyOnly := newDraft(userID, space.Id, pageID, "") - bodyOnly.Title = "" - bodyOnly.Body = `{"type":"doc","content":[{"type":"paragraph"},{"type":"paragraph"}]}` - bodyOnly.Props = nil - saved, _, err := s.UpsertDraft(bodyOnly, nil, nil, nil) - require.NoError(t, err) - require.Equal(t, "Original title", saved.Title, "an omitted title must not wipe the stored one") - require.Equal(t, bodyOnly.Body, saved.Body, "the sent body must be written") - require.Equal(t, int64(1234), saved.BaseEditAt, - "an omitted baseline must not drop the stored optimistic-lock baseline") - require.Equal(t, stored.CreateAt, saved.CreateAt, "CreateAt preserved across upsert") - - // A title-only heartbeat: no body. The body just written must survive. - titleOnly := newDraft(userID, space.Id, pageID, "") - titleOnly.Title = "Renamed" - titleOnly.Body = "" - saved, _, err = s.UpsertDraft(titleOnly, nil, nil, nil) - require.NoError(t, err) - require.Equal(t, "Renamed", saved.Title) - require.Equal(t, bodyOnly.Body, saved.Body, "an omitted body must not wipe the stored one") - }) - - t.Run("two users can draft the same page id", func(t *testing.T) { - s := openTestDB(t) - pageID := mmmodel.NewId() - space, spaceErr := s.CreateSpace(newSpace(mmmodel.NewId())) - require.NoError(t, spaceErr) - spaceID := space.Id - userA, userB := mmmodel.NewId(), mmmodel.NewId() - - _, _, err := s.UpsertDraft(newDraft(userA, spaceID, pageID, ""), nil, nil, nil) - require.NoError(t, err) - _, _, err = s.UpsertDraft(newDraft(userB, spaceID, pageID, ""), nil, nil, nil) - require.NoError(t, err) - - gotA, err := s.GetDraft(userA, pageID) - require.NoError(t, err) - require.Equal(t, userA, gotA.UserId) - gotB, err := s.GetDraft(userB, pageID) - require.NoError(t, err) - require.Equal(t, userB, gotB.UserId) - }) - - t.Run("delete makes draft not found", func(t *testing.T) { - s := openTestDB(t) - userID, pageID := mmmodel.NewId(), mmmodel.NewId() - space, spaceErr := s.CreateSpace(newSpace(mmmodel.NewId())) - require.NoError(t, spaceErr) - spaceID := space.Id - - _, _, err := s.UpsertDraft(newDraft(userID, spaceID, pageID, ""), nil, nil, nil) - require.NoError(t, err) - - require.NoError(t, s.DeleteDraft(userID, pageID)) - - _, err = s.GetDraft(userID, pageID) - require.True(t, store.IsErrNotFound(err)) - }) - - t.Run("delete nonexistent draft returns not-found", func(t *testing.T) { - s := openTestDB(t) - err := s.DeleteDraft(mmmodel.NewId(), mmmodel.NewId()) - require.True(t, store.IsErrNotFound(err)) - }) - - t.Run("get nonexistent draft returns not-found", func(t *testing.T) { - s := openTestDB(t) - _, err := s.GetDraft(mmmodel.NewId(), mmmodel.NewId()) - require.True(t, store.IsErrNotFound(err)) - }) - - t.Run("drafts for space lists new-page drafts most-recent-first", func(t *testing.T) { - s := openTestDB(t) - userID := mmmodel.NewId() - space, err := s.CreateSpace(newSpace(mmmodel.NewId())) - require.NoError(t, err) - - _, _, err = s.UpsertDraft(newDraft(userID, space.Id, mmmodel.NewId(), ""), nil, nil, nil) - require.NoError(t, err) - second, _, err := s.UpsertDraft(newDraft(userID, space.Id, mmmodel.NewId(), ""), nil, nil, nil) - require.NoError(t, err) - - drafts, err := s.GetDraftsForSpace(userID, space.Id, 0, testDraftListLimit) - require.NoError(t, err) - require.Len(t, drafts, 2) - require.Equal(t, second.PageId, drafts[0].PageId, "most-recently-updated first") - }) - - t.Run("drafts for soft-deleted space are not listed but survive for restore", func(t *testing.T) { - s := openTestDB(t) - userID := mmmodel.NewId() - space, err := s.CreateSpace(newSpace(mmmodel.NewId())) - require.NoError(t, err) - - draft, _, err := s.UpsertDraft(newDraft(userID, space.Id, mmmodel.NewId(), ""), nil, nil, nil) - require.NoError(t, err) - - require.NoError(t, s.DeleteSpace(space.Id)) - - // While the space is soft-deleted both reads are gated to nothing... - drafts, err := s.GetDraftsForSpace(userID, space.Id, 0, testDraftListLimit) - require.NoError(t, err) - require.Empty(t, drafts, "a soft-deleted space lists no drafts") - - _, err = s.GetDraft(userID, draft.PageId) - require.True(t, store.IsErrNotFound(err), "a soft-deleted space gates GetDraft too") - - // ...but the draft row is kept (not purged), so it reappears once the space is restored. - require.NoError(t, s.RestoreSpace(space.Id)) - drafts, err = s.GetDraftsForSpace(userID, space.Id, 0, testDraftListLimit) - require.NoError(t, err) - require.Len(t, drafts, 1, "restoring the space brings its drafts back") - - kept, err := s.GetDraft(userID, draft.PageId) - require.NoError(t, err, "draft is readable again after restore") - require.Equal(t, draft.PageId, kept.PageId) - }) - - t.Run("drafts for space excludes a draft whose page lives in another space", func(t *testing.T) { - s := openTestDB(t) - userID := mmmodel.NewId() - - spaceA, err := s.CreateSpace(newSpace(mmmodel.NewId())) - require.NoError(t, err) - spaceB, err := s.CreateSpace(newSpace(mmmodel.NewId())) - require.NoError(t, err) - - // A live page in space B. UpsertDraft refuses to attach a space-A draft to it (see the - // write-path test below), so insert the cross-space row directly to exercise the - // read-path guard against a corrupt or legacy row. - pageInB, err := s.CreatePage(newPage(spaceB.Id, spaceB.ChannelId, userID, ""), testDefaultMaxDepth) - require.NoError(t, err) - now := mmmodel.GetMillis() - _, rawErr := s.RawExecForTest( - "INSERT INTO DOCS_Draft (UserId, SpaceId, PageId, ParentId, Title, Body, FileIds, Props, CreateAt, UpdateAt) VALUES ($1, $2, $3, '', '', '', '[]', '{}', $4, $4)", - userID, spaceA.Id, pageInB.Id, now) - require.NoError(t, rawErr) - - drafts, err := s.GetDraftsForSpace(userID, spaceA.Id, 0, testDraftListLimit) - require.NoError(t, err) - require.Empty(t, drafts, "a draft whose page belongs to another space must not be listed") - }) - - t.Run("upsert rejects a draft whose page lives in another space", func(t *testing.T) { - s := openTestDB(t) - userID := mmmodel.NewId() - - spaceA, err := s.CreateSpace(newSpace(mmmodel.NewId())) - require.NoError(t, err) - spaceB, err := s.CreateSpace(newSpace(mmmodel.NewId())) - require.NoError(t, err) - - pageInB, err := s.CreatePage(newPage(spaceB.Id, spaceB.ChannelId, userID, ""), testDefaultMaxDepth) - require.NoError(t, err) - - _, _, err = s.UpsertDraft(newDraft(userID, spaceA.Id, pageInB.Id, ""), nil, nil, nil) - require.True(t, store.IsErrInvalidInput(err), "expected invalid input for a cross-space page, got %v", err) - }) - - t.Run("drafts for space excludes drafts on soft-deleted pages", func(t *testing.T) { - s := openTestDB(t) - channelID := mmmodel.NewId() - space, err := s.CreateSpace(newSpace(channelID)) - require.NoError(t, err) - userID := mmmodel.NewId() - - // A draft editing a live page is included. - live, err := s.CreatePage(newPage(space.Id, channelID, userID, ""), testDefaultMaxDepth) - require.NoError(t, err) - dLive := newDraft(userID, space.Id, live.Id, "") - dLive.BaseEditAt = live.EditAt - _, _, err = s.UpsertDraft(dLive, nil, nil, nil) - require.NoError(t, err) - - // A draft whose page is soft-deleted is excluded. - deleted, err := s.CreatePage(newPage(space.Id, channelID, userID, ""), testDefaultMaxDepth) - require.NoError(t, err) - dDeleted := newDraft(userID, space.Id, deleted.Id, "") - dDeleted.BaseEditAt = deleted.EditAt - _, _, err = s.UpsertDraft(dDeleted, nil, nil, nil) - require.NoError(t, err) - require.NoError(t, deletePageErr(s, deleted.Id, deleted.SpaceId, userID)) - - drafts, err := s.GetDraftsForSpace(userID, space.Id, 0, testDraftListLimit) - require.NoError(t, err) - require.Len(t, drafts, 1) - require.Equal(t, live.Id, drafts[0].PageId) - }) - - t.Run("drafts for space excludes drafts on version snapshots", func(t *testing.T) { - s := openTestDB(t) - channelID := mmmodel.NewId() - space, err := s.CreateSpace(newSpace(channelID)) - require.NoError(t, err) - userID := mmmodel.NewId() - - // Draft a live page, then turn that page into a version snapshot (OriginalId set, - // soft-deleted) directly. UpsertDraft refuses to attach to a snapshot, so the draft is - // written while the page is still live; the read path must then exclude it: the LEFT - // JOIN matches the snapshot row, OriginalId != '' fails the live-page predicate, and - // p.Id IS NULL is false. - snap, err := s.CreatePage(newPage(space.Id, channelID, userID, ""), testDefaultMaxDepth) - require.NoError(t, err) - dSnap := newDraft(userID, space.Id, snap.Id, "") - dSnap.BaseEditAt = snap.EditAt - _, _, err = s.UpsertDraft(dSnap, nil, nil, nil) - require.NoError(t, err) - _, rawErr := s.ExecBuilderForTest(s.QueryBuilderForTest(). - Update("DOCS_Page"). - Set("OriginalId", mmmodel.NewId()). - Set("DeleteAt", mmmodel.GetMillis()). - Where(sq.Eq{"Id": snap.Id})) - require.NoError(t, rawErr) - - drafts, err := s.GetDraftsForSpace(userID, space.Id, 0, testDraftListLimit) - require.NoError(t, err) - require.Empty(t, drafts, "a draft on a version snapshot must be excluded") - }) - - t.Run("upsert rejects a draft for a soft-deleted page", func(t *testing.T) { - s := openTestDB(t) - channelID := mmmodel.NewId() - space, err := s.CreateSpace(newSpace(channelID)) - require.NoError(t, err) - userID := mmmodel.NewId() - - page, err := s.CreatePage(newPage(space.Id, channelID, userID, ""), testDefaultMaxDepth) - require.NoError(t, err) - require.NoError(t, deletePageErr(s, page.Id, page.SpaceId, userID)) - - // An autosave landing after the page was deleted must not recreate a draft for it. - _, _, err = s.UpsertDraft(newDraft(userID, space.Id, page.Id, ""), nil, nil, nil) - require.True(t, store.IsErrInvalidInput(err), "expected invalid input for a deleted page, got %v", err) - }) - - t.Run("upsert rejects a draft in a soft-deleted space", func(t *testing.T) { - s := openTestDB(t) - space, err := s.CreateSpace(newSpace(mmmodel.NewId())) - require.NoError(t, err) - require.NoError(t, s.DeleteSpace(space.Id)) - - _, _, err = s.UpsertDraft(newDraft(mmmodel.NewId(), space.Id, mmmodel.NewId(), ""), nil, nil, nil) - require.True(t, store.IsErrNotFound(err), "expected not-found for a deleted space, got %v", err) - }) - - t.Run("upsert accepts a new-page draft under a live parent", func(t *testing.T) { - s := openTestDB(t) - channelID := mmmodel.NewId() - space, err := s.CreateSpace(newSpace(channelID)) - require.NoError(t, err) - userID := mmmodel.NewId() - - parent, err := s.CreatePage(newPage(space.Id, channelID, userID, ""), testDefaultMaxDepth) - require.NoError(t, err) - - parentID := parent.Id - saved, _, err := s.UpsertDraft(newDraft(userID, space.Id, mmmodel.NewId(), parentID), &parentID, nil, nil) - require.NoError(t, err) - require.Equal(t, parent.Id, saved.ParentId) - }) - - t.Run("upsert rejects a draft whose parent does not exist", func(t *testing.T) { - s := openTestDB(t) - space, err := s.CreateSpace(newSpace(mmmodel.NewId())) - require.NoError(t, err) - - missingParentID := mmmodel.NewId() - _, _, err = s.UpsertDraft(newDraft(mmmodel.NewId(), space.Id, mmmodel.NewId(), missingParentID), &missingParentID, nil, nil) - require.True(t, store.IsErrInvalidInput(err), "expected invalid input for a missing parent, got %v", err) - }) - - t.Run("upsert rejects a draft whose parent is soft-deleted", func(t *testing.T) { - s := openTestDB(t) - channelID := mmmodel.NewId() - space, err := s.CreateSpace(newSpace(channelID)) - require.NoError(t, err) - userID := mmmodel.NewId() - - parent, err := s.CreatePage(newPage(space.Id, channelID, userID, ""), testDefaultMaxDepth) - require.NoError(t, err) - require.NoError(t, deletePageErr(s, parent.Id, parent.SpaceId, userID)) - - parentID := parent.Id - _, _, err = s.UpsertDraft(newDraft(userID, space.Id, mmmodel.NewId(), parentID), &parentID, nil, nil) - require.True(t, store.IsErrInvalidInput(err), "expected invalid input for a deleted parent, got %v", err) - }) - - t.Run("upsert rejects a draft whose parent lives in another space", func(t *testing.T) { - s := openTestDB(t) - userID := mmmodel.NewId() - - spaceA, err := s.CreateSpace(newSpace(mmmodel.NewId())) - require.NoError(t, err) - spaceB, err := s.CreateSpace(newSpace(mmmodel.NewId())) - require.NoError(t, err) - - parentInB, err := s.CreatePage(newPage(spaceB.Id, spaceB.ChannelId, userID, ""), testDefaultMaxDepth) - require.NoError(t, err) - - parentID := parentInB.Id - _, _, err = s.UpsertDraft(newDraft(userID, spaceA.Id, mmmodel.NewId(), parentID), &parentID, nil, nil) - require.True(t, store.IsErrInvalidInput(err), "expected invalid input for a cross-space parent, got %v", err) - }) - - t.Run("upsert accepts a parent that is the user's own draft in the same space", func(t *testing.T) { - s := openTestDB(t) - space, err := s.CreateSpace(newSpace(mmmodel.NewId())) - require.NoError(t, err) - userID := mmmodel.NewId() - - parentDraft, _, err := s.UpsertDraft(newDraft(userID, space.Id, mmmodel.NewId(), ""), nil, nil, nil) - require.NoError(t, err) - - parentPageID := parentDraft.PageId - saved, _, err := s.UpsertDraft(newDraft(userID, space.Id, mmmodel.NewId(), parentPageID), &parentPageID, nil, nil) - require.NoError(t, err) - require.Equal(t, parentDraft.PageId, saved.ParentId) - }) - - t.Run("upsert rejects a parent that is another user's draft", func(t *testing.T) { - s := openTestDB(t) - space, err := s.CreateSpace(newSpace(mmmodel.NewId())) - require.NoError(t, err) - userA, userB := mmmodel.NewId(), mmmodel.NewId() - - otherDraft, _, err := s.UpsertDraft(newDraft(userA, space.Id, mmmodel.NewId(), ""), nil, nil, nil) - require.NoError(t, err) - - otherPageID := otherDraft.PageId - _, _, err = s.UpsertDraft(newDraft(userB, space.Id, mmmodel.NewId(), otherPageID), &otherPageID, nil, nil) - require.True(t, store.IsErrInvalidInput(err), "expected invalid input for another user's draft parent, got %v", err) - }) - - // TestDraft/"upsert rejects a draft whose parent chain cycles back to itself" exercises - // checkNoDraftCycle's cycle branch: a root new-page draft, a second draft parented under it, - // then re-parenting the root under the second draft closes the loop root -> child -> root. - t.Run("upsert rejects a draft whose parent chain cycles back to itself", func(t *testing.T) { - s := openTestDB(t) - space, err := s.CreateSpace(newSpace(mmmodel.NewId())) - require.NoError(t, err) - userID := mmmodel.NewId() - - rootDraft, _, err := s.UpsertDraft(newDraft(userID, space.Id, mmmodel.NewId(), ""), nil, nil, nil) - require.NoError(t, err) - - rootPageID := rootDraft.PageId - childDraft, _, err := s.UpsertDraft(newDraft(userID, space.Id, mmmodel.NewId(), rootPageID), &rootPageID, nil, nil) - require.NoError(t, err) - - childPageID := childDraft.PageId - _, _, err = s.UpsertDraft(newDraft(userID, space.Id, rootDraft.PageId, childPageID), &childPageID, nil, nil) - require.Error(t, err) - var inv *store.ErrInvalidInput - require.True(t, errors.As(err, &inv), "expected ErrInvalidInput, got %T: %v", err, err) - require.Equal(t, store.ReasonDraftCycle, inv.Reason) - }) - - // TestDraft/"upsert rejects a draft whose parent chain exceeds the max depth" exercises - // checkNoDraftCycle's too-deep branch. Each draft added to the chain is itself parent-chain - // validated, so a chain of exactly model.MaxPageDepth new-page drafts is the deepest one - // that can be built without tripping the cap; a further draft parented under the deepest one - // is rejected as too deep. - t.Run("upsert rejects a draft whose parent chain exceeds the max depth", func(t *testing.T) { - s := openTestDB(t) - space, err := s.CreateSpace(newSpace(mmmodel.NewId())) - require.NoError(t, err) - userID := mmmodel.NewId() - - parentID := "" - for range model.MaxPageDepth { - pageID := mmmodel.NewId() - var parentParam *string - if parentID != "" { - p := parentID - parentParam = &p - } - _, _, err = s.UpsertDraft(newDraft(userID, space.Id, pageID, parentID), parentParam, nil, nil) - require.NoError(t, err) - parentID = pageID - } - - deepestParentID := parentID - _, _, err = s.UpsertDraft(newDraft(userID, space.Id, mmmodel.NewId(), deepestParentID), &deepestParentID, nil, nil) - require.Error(t, err) - var inv *store.ErrInvalidInput - require.True(t, errors.As(err, &inv), "expected ErrInvalidInput, got %T: %v", err, err) - require.Equal(t, store.ReasonDraftTooDeep, inv.Reason) - }) - - t.Run("drafts for space is scoped to the user", func(t *testing.T) { - s := openTestDB(t) - space, err := s.CreateSpace(newSpace(mmmodel.NewId())) - require.NoError(t, err) - userA, userB := mmmodel.NewId(), mmmodel.NewId() - - _, _, err = s.UpsertDraft(newDraft(userA, space.Id, mmmodel.NewId(), ""), nil, nil, nil) - require.NoError(t, err) - _, _, err = s.UpsertDraft(newDraft(userB, space.Id, mmmodel.NewId(), ""), nil, nil, nil) - require.NoError(t, err) - - drafts, err := s.GetDraftsForSpace(userA, space.Id, 0, testDraftListLimit) - require.NoError(t, err) - require.Len(t, drafts, 1) - require.Equal(t, userA, drafts[0].UserId) - }) - - t.Run("body, file_ids and props round-trip through the database", func(t *testing.T) { - s := openTestDB(t) - userID, pageID := mmmodel.NewId(), mmmodel.NewId() - space, spaceErr := s.CreateSpace(newSpace(mmmodel.NewId())) - require.NoError(t, spaceErr) - spaceID := space.Id - - d := newDraft(userID, spaceID, pageID, "") - d.Body = `{"type":"doc","content":[{"type":"paragraph"}]}` - d.FileIds = mmmodel.StringArray{mmmodel.NewId(), mmmodel.NewId()} - d.Props = mmmodel.StringInterface{"k": float64(1700000000123)} - _, _, err := s.UpsertDraft(d, nil, &d.FileIds, &d.Props) - require.NoError(t, err) - - got, err := s.GetDraft(userID, pageID) - require.NoError(t, err) - require.Equal(t, d.Body, got.Body) - require.Equal(t, d.FileIds, got.FileIds, "StringArray must round-trip through the TEXT column") - require.Equal(t, float64(1700000000123), got.Props["k"], "Props must round-trip through the jsonb column") - }) - - t.Run("empty props default to an empty map on read", func(t *testing.T) { - s := openTestDB(t) - userID, pageID := mmmodel.NewId(), mmmodel.NewId() - space, spaceErr := s.CreateSpace(newSpace(mmmodel.NewId())) - require.NoError(t, spaceErr) - spaceID := space.Id - - _, _, err := s.UpsertDraft(newDraft(userID, spaceID, pageID, ""), nil, nil, nil) - require.NoError(t, err) - - got, err := s.GetDraft(userID, pageID) - require.NoError(t, err) - require.NotNil(t, got.Props) - require.Empty(t, got.Props) - }) - - t.Run("upsert overwrites parent id", func(t *testing.T) { - s := openTestDB(t) - userID, pageID := mmmodel.NewId(), mmmodel.NewId() - space, spaceErr := s.CreateSpace(newSpace(mmmodel.NewId())) - require.NoError(t, spaceErr) - spaceID := space.Id - - // Parents must be live pages in the space (UpsertDraft validates ParentId liveness). - firstPage, err := s.CreatePage(newPage(spaceID, space.ChannelId, userID, ""), testDefaultMaxDepth) - require.NoError(t, err) - secondPage, err := s.CreatePage(newPage(spaceID, space.ChannelId, userID, ""), testDefaultMaxDepth) - require.NoError(t, err) - firstParent, secondParent := firstPage.Id, secondPage.Id - - _, _, err = s.UpsertDraft(newDraft(userID, spaceID, pageID, firstParent), &firstParent, nil, nil) - require.NoError(t, err) - got, err := s.GetDraft(userID, pageID) - require.NoError(t, err) - require.Equal(t, firstParent, got.ParentId) - - _, _, err = s.UpsertDraft(newDraft(userID, spaceID, pageID, secondParent), &secondParent, nil, nil) - require.NoError(t, err) - got, err = s.GetDraft(userID, pageID) - require.NoError(t, err) - require.Equal(t, secondParent, got.ParentId, "second upsert must overwrite ParentId") - }) - - t.Run("title-only empty body round-trips", func(t *testing.T) { - s := openTestDB(t) - userID, pageID := mmmodel.NewId(), mmmodel.NewId() - space, spaceErr := s.CreateSpace(newSpace(mmmodel.NewId())) - require.NoError(t, spaceErr) - spaceID := space.Id - - d := newDraft(userID, spaceID, pageID, "") - d.Title = "Title Only" - d.Body = "" - _, _, err := s.UpsertDraft(d, nil, nil, nil) - require.NoError(t, err) - - got, err := s.GetDraft(userID, pageID) - require.NoError(t, err) - require.Equal(t, "Title Only", got.Title) - require.Equal(t, "", got.Body, "empty body must round-trip as empty string") - }) - - t.Run("drafts for space excludes other spaces for the same user", func(t *testing.T) { - s := openTestDB(t) - userID := mmmodel.NewId() - spaceA, err := s.CreateSpace(newSpace(mmmodel.NewId())) - require.NoError(t, err) - spaceB, err := s.CreateSpace(newSpace(mmmodel.NewId())) - require.NoError(t, err) - - _, _, err = s.UpsertDraft(newDraft(userID, spaceA.Id, mmmodel.NewId(), ""), nil, nil, nil) - require.NoError(t, err) - _, _, err = s.UpsertDraft(newDraft(userID, spaceA.Id, mmmodel.NewId(), ""), nil, nil, nil) - require.NoError(t, err) - _, _, err = s.UpsertDraft(newDraft(userID, spaceB.Id, mmmodel.NewId(), ""), nil, nil, nil) - require.NoError(t, err) - - drafts, err := s.GetDraftsForSpace(userID, spaceA.Id, 0, testDraftListLimit) - require.NoError(t, err) - require.Len(t, drafts, 2) - for _, d := range drafts { - require.Equal(t, spaceA.Id, d.SpaceId) - } - }) - - t.Run("drafts for space returns empty when user has none", func(t *testing.T) { - s := openTestDB(t) - drafts, err := s.GetDraftsForSpace(mmmodel.NewId(), mmmodel.NewId(), 0, testDraftListLimit) - require.NoError(t, err) - require.Empty(t, drafts) - }) - - t.Run("store rejects invalid ids", func(t *testing.T) { - s := openTestDB(t) - valid := mmmodel.NewId() - - // Upsert runs the full model IsValid, so a malformed (non-empty) id is rejected as - // invalid input. - _, _, err := s.UpsertDraft(newDraft("bad", valid, valid, ""), nil, nil, nil) - require.True(t, store.IsErrInvalidInput(err), "upsert with bad user id, got %v", err) - - // Upsert with nil draft must return ErrInvalidInput. - _, _, err = s.UpsertDraft(nil, nil, nil, nil) - require.True(t, store.IsErrInvalidInput(err), "upsert nil draft, got %v", err) - - // Get/Delete guard only against empty ids (matching the page/space store convention); - // a non-empty but unknown id falls through to the query and returns not-found. - _, err = s.GetDraft("", valid) - require.True(t, store.IsErrInvalidInput(err), "get with empty user id, got %v", err) - - _, err = s.GetDraft(valid, "") - require.True(t, store.IsErrInvalidInput(err), "get with empty page id, got %v", err) - - err = s.DeleteDraft("", valid) - require.True(t, store.IsErrInvalidInput(err), "delete with empty user id, got %v", err) - - err = s.DeleteDraft(valid, "") - require.True(t, store.IsErrInvalidInput(err), "delete with empty page id, got %v", err) - }) - - t.Run("GetDraftsForSpace rejects empty userID", func(t *testing.T) { - s := openTestDB(t) - _, err := s.GetDraftsForSpace("", mmmodel.NewId(), 0, testDraftListLimit) - require.True(t, store.IsErrInvalidInput(err), "got %v", err) - }) - - t.Run("GetDraftsForSpace rejects empty spaceID", func(t *testing.T) { - s := openTestDB(t) - _, err := s.GetDraftsForSpace(mmmodel.NewId(), "", 0, testDraftListLimit) - require.True(t, store.IsErrInvalidInput(err), "got %v", err) - }) - - t.Run("GetDraftsForSpace rejects non-positive limit", func(t *testing.T) { - s := openTestDB(t) - _, err := s.GetDraftsForSpace(mmmodel.NewId(), mmmodel.NewId(), 0, 0) - require.True(t, store.IsErrInvalidInput(err), "zero limit must be rejected, got %v", err) - }) -} - -// TestDeletePageReparentsPendingDrafts verifies that deleting a page reparents the new-page -// drafts pending under it to the deleted page's parent — mirroring live-child promotion — so a -// draft never dangles under a soft-deleted parent and stays publishable. -func TestDeletePageReparentsPendingDrafts(t *testing.T) { - s := openTestDB(t) - - channelID := mmmodel.NewId() - userID := mmmodel.NewId() - space, err := s.CreateSpace(newSpace(channelID)) - require.NoError(t, err) - - grandparent, err := s.CreatePage(newPage(space.Id, channelID, userID, ""), testDefaultMaxDepth) - require.NoError(t, err) - parent, err := s.CreatePage(newPage(space.Id, channelID, userID, grandparent.Id), testDefaultMaxDepth) - require.NoError(t, err) - - // A new-page draft (its own page not yet created) pending as a child of parent. - newPageID := mmmodel.NewId() - parentID := parent.Id - _, _, err = s.UpsertDraft(newDraft(userID, space.Id, newPageID, parentID), &parentID, nil, nil) - require.NoError(t, err) - - require.NoError(t, deletePageErr(s, parent.Id, parent.SpaceId, userID)) - - // The draft survives and is reparented to the deleted page's parent (the grandparent), - // which the invariant guarantees is live. - got, err := s.GetDraft(userID, newPageID) - require.NoError(t, err, "pending draft must survive its parent's deletion") - require.Equal(t, grandparent.Id, got.ParentId, "draft must be reparented to the deleted page's parent") - - // The reparented draft is publishable: CreatePage with its parent now succeeds. - _, err = s.CreatePage(newPage(space.Id, channelID, userID, got.ParentId), testDefaultMaxDepth) - require.NoError(t, err, "draft's reparented parent must be a valid live parent") -} - -// TestCreatePageSubtreeMissingParent verifies CreatePageSubtree rejects a root whose ParentId does -// not resolve to a live page in the given space, rather than inserting an orphaned subtree. -func TestCreatePageSubtreeMissingParent(t *testing.T) { - s := openTestDB(t) - - channelID := mmmodel.NewId() - userID := mmmodel.NewId() - space, err := s.CreateSpace(newSpace(channelID)) - require.NoError(t, err) - - root := newPage(space.Id, channelID, userID, mmmodel.NewId()) - root.Id = mmmodel.NewId() - _, err = s.CreatePageSubtree([]*model.Page{root}, 0) - require.True(t, store.IsErrInvalidInput(err), "expected ErrInvalidInput for a missing parent, got %v", err) -} - -// TestCreatePageSubtreeMissingSpace verifies CreatePageSubtree rejects a root targeting a -// nonexistent (or soft-deleted) space. -func TestCreatePageSubtreeMissingSpace(t *testing.T) { - s := openTestDB(t) - - userID := mmmodel.NewId() - root := newPage(mmmodel.NewId(), mmmodel.NewId(), userID, "") - root.Id = mmmodel.NewId() - _, err := s.CreatePageSubtree([]*model.Page{root}, 0) - require.True(t, store.IsErrNotFound(err), "expected ErrNotFound for a missing space, got %v", err) -} - -// TestGetSpacePages verifies GetSpacePages returns live pages for a space and excludes pages from -// other spaces and soft-deleted pages. -func TestGetSpacePages(t *testing.T) { - s := openTestDB(t) - - channelID := mmmodel.NewId() - userID := mmmodel.NewId() - space, err := s.CreateSpace(newSpace(channelID)) - require.NoError(t, err) - otherSpace, err := s.CreateSpace(newSpace(mmmodel.NewId())) - require.NoError(t, err) - - // Create two pages in the target space. - p1, err := s.CreatePage(newPage(space.Id, channelID, userID, ""), testDefaultMaxDepth) - require.NoError(t, err) - p2, err := s.CreatePage(newPage(space.Id, channelID, userID, ""), testDefaultMaxDepth) - require.NoError(t, err) - - // A page in a different space must not appear. - _, err = s.CreatePage(newPage(otherSpace.Id, mmmodel.NewId(), userID, ""), testDefaultMaxDepth) - require.NoError(t, err) - - // A soft-deleted page in the target space must not appear. - deleted, err := s.CreatePage(newPage(space.Id, channelID, userID, ""), testDefaultMaxDepth) - require.NoError(t, err) - require.NoError(t, deletePageErr(s, deleted.Id, deleted.SpaceId, userID)) - - pages, err := s.GetSpacePages(space.Id, 0, 100) - require.NoError(t, err) - require.Len(t, pages, 2, "must return exactly the two live pages in the space") - - ids := summaryIDs(pages) - require.Contains(t, ids, p1.Id) - require.Contains(t, ids, p2.Id) - require.NotContains(t, ids, deleted.Id) -} - -// TestCreatePageSubtreeSuccess verifies that CreatePageSubtree inserts a root plus children and -// returns all created rows with their assigned IDs. -func TestCreatePageSubtreeSuccess(t *testing.T) { - s := openTestDB(t) - - channelID := mmmodel.NewId() - userID := mmmodel.NewId() - space, err := s.CreateSpace(newSpace(channelID)) - require.NoError(t, err) - - // Build a two-level subtree: root → child → grandchild. - root := newPage(space.Id, channelID, userID, "") - root.PreSave() - child := newPage(space.Id, channelID, userID, root.Id) - child.PreSave() - grandchild := newPage(space.Id, channelID, userID, child.Id) - grandchild.PreSave() - - created, err := s.CreatePageSubtree([]*model.Page{root, child, grandchild}, testDefaultMaxDepth) - require.NoError(t, err) - require.Len(t, created, 3, "must return all three created pages") - - ids := idsOf(created) - require.Contains(t, ids, root.Id) - require.Contains(t, ids, child.Id) - require.Contains(t, ids, grandchild.Id) - - // Verify they are live in the DB. - for _, id := range ids { - got, getErr := s.GetPage(id, false) - require.NoError(t, getErr, "page %s must be fetchable after subtree create", id) - require.Zero(t, got.DeleteAt) - } -} - -// TestSpaceDeleteRestoreKeepsPageTimestampsMonotonic verifies the delete/restore cascades never -// regress a page's UpdateAt/EditAt CAS tokens, even when a prior structural operation advanced -// them past wall clock — a regressed token would make a stale client baseline read as current. -func TestSpaceDeleteRestoreKeepsPageTimestampsMonotonic(t *testing.T) { - s := openTestDB(t) - - channelID := mmmodel.NewId() - userID := mmmodel.NewId() - space, err := s.CreateSpace(newSpace(channelID)) - require.NoError(t, err) - page, err := s.CreatePage(newPage(space.Id, channelID, userID, ""), testDefaultMaxDepth) - require.NoError(t, err) - - // Simulate prior structural operations having pushed the CAS tokens past wall clock. - future := mmmodel.GetMillis() + 60_000 - _, err = s.RawExecForTest("UPDATE DOCS_Page SET UpdateAt = $1, EditAt = $1 WHERE Id = $2", future, page.Id) - require.NoError(t, err) - - require.NoError(t, s.DeleteSpace(space.Id)) - deleted, err := s.GetPage(page.Id, true) - require.NoError(t, err) - require.Greater(t, deleted.UpdateAt, future, "delete cascade must advance UpdateAt, never regress it") - require.Greater(t, deleted.EditAt, future, "delete cascade must advance EditAt, never regress it") - - require.NoError(t, s.RestoreSpace(space.Id)) - restored, err := s.GetPage(page.Id, false) - require.NoError(t, err) - require.Greater(t, restored.UpdateAt, deleted.UpdateAt, "restore cascade must advance UpdateAt, never regress it") - require.Greater(t, restored.EditAt, deleted.EditAt, "restore cascade must advance EditAt, never regress it") -} - -// TestWithSpaceMembershipLockSerializes verifies lock-holders for the same space are mutually -// exclusive: no two callbacks are ever inside the lock at once, and callbacks run to completion -// even when their work spans multiple scheduling points. -func TestWithSpaceMembershipLockSerializes(t *testing.T) { - s := openTestDB(t) - spaceID := mmmodel.NewId() - - const n = 20 - var active atomic.Int32 - var overlaps atomic.Int32 - var wg sync.WaitGroup - errs := make([]error, n) - wg.Add(n) - for i := range n { - go func() { - defer wg.Done() - errs[i] = s.WithSpaceMembershipLock(spaceID, func() error { - if active.Add(1) > 1 { - overlaps.Add(1) - } - // Widen the hold window across a scheduling point, mimicking the multi-call - // guard the lock exists to protect. - runtime.Gosched() - active.Add(-1) - return nil - }) - }() - } - wg.Wait() - - for i, lErr := range errs { - require.NoError(t, lErr, "lock call %d", i) - } - require.Zero(t, overlaps.Load(), "concurrent holders observed — advisory lock failed to serialize") -} - -// TestWithSpaceMembershipLockAcquireTimeout verifies a waiter gives up with a retryable -// ErrConflict once the acquisition timeout elapses, instead of blocking indefinitely on a -// pooled connection while another holder is inside the lock. -func TestWithSpaceMembershipLockAcquireTimeout(t *testing.T) { - s := openTestDB(t) - spaceID := mmmodel.NewId() - - holderIn := make(chan struct{}) - releaseHolder := make(chan struct{}) - holderDone := make(chan error, 1) - go func() { - holderDone <- s.WithSpaceMembershipLock(spaceID, func() error { - close(holderIn) - <-releaseHolder - return nil - }) - }() - <-holderIn - - err := s.WithSpaceMembershipLockTimeoutForTest(spaceID, 300*time.Millisecond, func() error { - t.Error("callback must not run when the lock was never acquired") - return nil - }) - require.Error(t, err) - require.True(t, store.IsErrConflict(err), "lock acquisition timeout must surface as a retryable conflict; got %v", err) - - close(releaseHolder) - require.NoError(t, <-holderDone) - - // With the holder gone, the same call must acquire immediately and run the callback. - ran := false - require.NoError(t, s.WithSpaceMembershipLockTimeoutForTest(spaceID, 300*time.Millisecond, func() error { - ran = true - return nil - })) - require.True(t, ran) -} - -// TestGetActiveEditorsForPage covers the presence window predicate: a draft updated at/after the -// cutoff counts its user as active; one before the cutoff, or on another page, does not. -func TestGetActiveEditorsForPage(t *testing.T) { - s := openTestDB(t) - space, err := s.CreateSpace(newSpace(mmmodel.NewId())) - require.NoError(t, err) - - pageID := mmmodel.NewId() - userID := mmmodel.NewId() - _, _, err = s.UpsertDraft(newDraft(userID, space.Id, pageID, ""), nil, nil, nil) - require.NoError(t, err) - now := mmmodel.GetMillis() - - t.Run("within window includes the editor", func(t *testing.T) { - editors, err := s.GetPageActiveEditors(pageID, space.Id, now-5*60*1000) - require.NoError(t, err) - require.Contains(t, editors, userID) - }) - - t.Run("cutoff after the update excludes the editor", func(t *testing.T) { - editors, err := s.GetPageActiveEditors(pageID, space.Id, now+60*1000) - require.NoError(t, err) - require.NotContains(t, editors, userID) - }) - - t.Run("a different page has no editors", func(t *testing.T) { - editors, err := s.GetPageActiveEditors(mmmodel.NewId(), space.Id, 0) - require.NoError(t, err) - require.Empty(t, editors) - }) - - t.Run("a new-page draft at the same reserved id in another space does not leak", func(t *testing.T) { - otherSpace, err := s.CreateSpace(newSpace(mmmodel.NewId())) - require.NoError(t, err) - otherUser := mmmodel.NewId() - // Same (reserved) pageID, different space and user — an unpublished new-page draft. - _, _, err = s.UpsertDraft(newDraft(otherUser, otherSpace.Id, pageID, ""), nil, nil, nil) - require.NoError(t, err) - - editors, err := s.GetPageActiveEditors(pageID, space.Id, mmmodel.GetMillis()-5*60*1000) - require.NoError(t, err) - require.Contains(t, editors, userID) - require.NotContains(t, editors, otherUser, - "presence for a page must not disclose an editor from another space sharing the reserved id") - }) -} - -func TestGetActiveEditorsForPageInputValidation(t *testing.T) { - s := openTestDB(t) - valid := mmmodel.NewId() - - _, err := s.GetPageActiveEditors("", valid, 0) - require.True(t, store.IsErrInvalidInput(err), "empty pageID, got %v", err) - - _, err = s.GetPageActiveEditors(valid, "", 0) - require.True(t, store.IsErrInvalidInput(err), "empty spaceID, got %v", err) -} - -func TestGetActiveEditorsForPageMultipleEditorsOrderedByLastActiveAt(t *testing.T) { - s := openTestDB(t) - space, err := s.CreateSpace(newSpace(mmmodel.NewId())) - require.NoError(t, err) - - pageID := mmmodel.NewId() - userA, userB := mmmodel.NewId(), mmmodel.NewId() - - _, _, err = s.UpsertDraft(newDraft(userA, space.Id, pageID, ""), nil, nil, nil) - require.NoError(t, err) - _, _, err = s.UpsertDraft(newDraft(userB, space.Id, pageID, ""), nil, nil, nil) - require.NoError(t, err) - - // Push userA's LastActiveAt into the past so userB (more recent) should appear first. - past := mmmodel.GetMillis() - 60*1000 - _, err = s.ExecBuilderForTest(s.QueryBuilderForTest(). - Update("DOCS_Draft"). - Set("LastActiveAt", past). - Where(sq.Eq{"UserId": userA, "PageId": pageID})) - require.NoError(t, err) - - editors, err := s.GetPageActiveEditors(pageID, space.Id, 0) - require.NoError(t, err) - require.Len(t, editors, 2) - require.Equal(t, userB, editors[0], "most-recently-active editor must appear first") - require.Equal(t, userA, editors[1]) -} - -// TestGetActiveEditorsForPageIgnoresMaintenanceWrites pins presence to LastActiveAt rather than -// UpdateAt. Deleting a page reparents the drafts pending under it, which stamps their UpdateAt -// without their owner having touched them — that must not report the owner as an active editor. -func TestGetActiveEditorsForPageIgnoresMaintenanceWrites(t *testing.T) { - s := openTestDB(t) - channelID := mmmodel.NewId() - space, err := s.CreateSpace(newSpace(channelID)) - require.NoError(t, err) - - userID := mmmodel.NewId() - parent, err := s.CreatePage(newPage(space.Id, channelID, userID, ""), testDefaultMaxDepth) - require.NoError(t, err) - - // A new-page draft pending under the parent, last actually edited well outside the window. - childPageID := mmmodel.NewId() - parentID := parent.Id - _, _, err = s.UpsertDraft(newDraft(userID, space.Id, childPageID, parentID), &parentID, nil, nil) - require.NoError(t, err) - - stale := mmmodel.GetMillis() - 60*60*1000 - _, err = s.ExecBuilderForTest(s.QueryBuilderForTest(). - Update("DOCS_Draft"). - Set("UpdateAt", stale). - Set("LastActiveAt", stale). - Where(sq.Eq{"UserId": userID, "PageId": childPageID})) - require.NoError(t, err) - - // Someone else deletes the parent, which reparents the pending draft and bumps its UpdateAt. - _, err = s.DeletePage(parent.Id, space.Id, mmmodel.NewId()) - require.NoError(t, err) - - cutoff := mmmodel.GetMillis() - 5*60*1000 - editors, err := s.GetPageActiveEditors(childPageID, space.Id, cutoff) - require.NoError(t, err) - require.NotContains(t, editors, userID, - "reparenting a draft must not report its owner as an active editor") -} - -// TestUpsertDraftBumpsUpdateAtMonotonically guards the draft's UpdateAt version token: it must -// advance strictly past the stored value even when the saving node's wall clock is behind it, so a -// later autosave can never commit an UpdateAt that collides with the value a publish already -// captured (which would let the publish CAS delete the newer draft and ship older content). -func TestUpsertDraftBumpsUpdateAtMonotonically(t *testing.T) { - s := openTestDB(t) - space, err := s.CreateSpace(newSpace(mmmodel.NewId())) - require.NoError(t, err) - userID := mmmodel.NewId() - pageID := mmmodel.NewId() - - _, _, err = s.UpsertDraft(newDraft(userID, space.Id, pageID, ""), nil, nil, nil) - require.NoError(t, err) - - // Force the stored UpdateAt ahead of the next save's wall clock. Without the monotonic bump, - // the next upsert would write a smaller UpdateAt (its own GetMillis()). - future := mmmodel.GetMillis() + 60*60*1000 - _, err = s.ExecBuilderForTest(s.QueryBuilderForTest(). - Update("DOCS_Draft"). - Set("UpdateAt", future). - Where(sq.Eq{"UserId": userID, "PageId": pageID})) - require.NoError(t, err) - - saved, _, err := s.UpsertDraft(newDraft(userID, space.Id, pageID, ""), nil, nil, nil) - require.NoError(t, err) - require.Equal(t, future+1, saved.UpdateAt, - "UpdateAt must advance to stored+1 when the incoming timestamp is not already greater") -} - -func TestDeleteDraftVersion(t *testing.T) { +// TestCreatePageSubtreeMissingParent verifies CreatePageSubtree rejects a root whose ParentId does +// not resolve to a live page in the given space, rather than inserting an orphaned subtree. +func TestCreatePageSubtreeMissingParent(t *testing.T) { s := openTestDB(t) - space, err := s.CreateSpace(newSpace(mmmodel.NewId())) - require.NoError(t, err) - userID := mmmodel.NewId() - pageID := mmmodel.NewId() - saved, _, err := s.UpsertDraft(newDraft(userID, space.Id, pageID, ""), nil, nil, nil) + channelID := mmmodel.NewId() + userID := mmmodel.NewId() + space, err := s.CreateSpace(newSpace(channelID)) require.NoError(t, err) - t.Run("stale version deletes nothing and leaves the draft intact", func(t *testing.T) { - deleted, delErr := s.DeleteDraftVersion(userID, pageID, saved.UpdateAt-1) - require.NoError(t, delErr) - require.False(t, deleted, "a mismatched version must not delete the row") - got, getErr := s.GetDraft(userID, pageID) - require.NoError(t, getErr, "the draft must survive a stale-version delete") - require.Equal(t, saved.UpdateAt, got.UpdateAt) - }) - - t.Run("matching version deletes the draft", func(t *testing.T) { - deleted, delErr := s.DeleteDraftVersion(userID, pageID, saved.UpdateAt) - require.NoError(t, delErr) - require.True(t, deleted, "the matching version must delete the row") - _, getErr := s.GetDraft(userID, pageID) - require.True(t, store.IsErrNotFound(getErr), "the draft must be gone") - }) - - t.Run("missing draft reports false without error", func(t *testing.T) { - deleted, delErr := s.DeleteDraftVersion(userID, mmmodel.NewId(), 1) - require.NoError(t, delErr) - require.False(t, deleted) - }) + root := newPage(space.Id, channelID, userID, mmmodel.NewId()) + root.Id = mmmodel.NewId() + _, err = s.CreatePageSubtree([]*model.Page{root}, 0) + require.True(t, store.IsErrInvalidInput(err), "expected ErrInvalidInput for a missing parent, got %v", err) } -// TestPublishDraft covers the atomic publish transaction at the store boundary: the new-page -// insert-and-delete-draft path, and the edit path's optimistic-lock CAS. -func TestPublishDraft(t *testing.T) { - t.Run("new page inserts the page and deletes the draft", func(t *testing.T) { - s := openTestDB(t) - channelID := mmmodel.NewId() - space, err := s.CreateSpace(newSpace(channelID)) - require.NoError(t, err) - userID := mmmodel.NewId() - pageID := mmmodel.NewId() - - draft, _, err := s.UpsertDraft(newDraft(userID, space.Id, pageID, ""), nil, nil, nil) - require.NoError(t, err) - - page := &model.Page{Id: pageID, SpaceId: space.Id, Title: "Published", Body: `{"type":"doc","content":[]}`, UserId: userID} - published, err := s.PublishDraft(true, page, userID, space.Id, false, testDefaultMaxDepth, draft.UpdateAt) - require.NoError(t, err) - require.Equal(t, pageID, published.Id) - - _, getErr := s.GetDraft(userID, pageID) - require.True(t, store.IsErrNotFound(getErr), "draft must be deleted by publish") - - live, err := s.GetPage(pageID, false) - require.NoError(t, err) - require.Equal(t, "Published", live.Title) - }) - - t.Run("edit path conflicts on a stale baseline", func(t *testing.T) { - s := openTestDB(t) - channelID := mmmodel.NewId() - space, err := s.CreateSpace(newSpace(channelID)) - require.NoError(t, err) - userID := mmmodel.NewId() - - created, err := s.CreatePage(newPage(space.Id, channelID, userID, ""), testDefaultMaxDepth) - require.NoError(t, err) - d := newDraft(userID, space.Id, created.Id, "") - d.BaseEditAt = created.EditAt - draft, _, err := s.UpsertDraft(d, nil, nil, nil) - require.NoError(t, err) - - edit := *created - edit.Title = "Edited" - edit.Body = `{"type":"doc","content":[]}` - edit.EditAt = created.EditAt - 1 // stale baseline - - _, err = s.PublishDraft(false, &edit, userID, space.Id, false, testDefaultMaxDepth, draft.UpdateAt) - require.True(t, store.IsErrConflict(err), "a stale baseline must conflict, got %v", err) - }) - - t.Run("edit path succeeds with a matching baseline", func(t *testing.T) { - s := openTestDB(t) - channelID := mmmodel.NewId() - space, err := s.CreateSpace(newSpace(channelID)) - require.NoError(t, err) - userID := mmmodel.NewId() - - created, err := s.CreatePage(newPage(space.Id, channelID, userID, ""), testDefaultMaxDepth) - require.NoError(t, err) - d2 := newDraft(userID, space.Id, created.Id, "") - d2.BaseEditAt = created.EditAt - draft, _, err := s.UpsertDraft(d2, nil, nil, nil) - require.NoError(t, err) - - edit := *created - edit.Title = "Edited" - edit.Body = `{"type":"doc","content":[]}` - edit.EditAt = created.EditAt // matching baseline - - published, err := s.PublishDraft(false, &edit, userID, space.Id, false, testDefaultMaxDepth, draft.UpdateAt) - require.NoError(t, err) - require.Equal(t, "Edited", published.Title) - require.Greater(t, published.EditAt, created.EditAt, "publish advances EditAt") - - _, getErr := s.GetDraft(userID, created.Id) - require.True(t, store.IsErrNotFound(getErr), "draft must be deleted by publish") - }) - - t.Run("an autosave landing after the draft was read rolls the publish back", func(t *testing.T) { - s := openTestDB(t) - channelID := mmmodel.NewId() - space, err := s.CreateSpace(newSpace(channelID)) - require.NoError(t, err) - userID := mmmodel.NewId() - - created, err := s.CreatePage(newPage(space.Id, channelID, userID, ""), testDefaultMaxDepth) - require.NoError(t, err) - d3 := newDraft(userID, space.Id, created.Id, "") - d3.BaseEditAt = created.EditAt - stale, _, err := s.UpsertDraft(d3, nil, nil, nil) - require.NoError(t, err) - - // The user's editor autosaves again after the publish path read the draft. - newer := newDraft(userID, space.Id, created.Id, "") - newer.Body = `{"type":"doc","content":[{"type":"paragraph"}]}` - newer.BaseEditAt = created.EditAt - newer, _, err = s.UpsertDraft(newer, nil, nil, nil) - require.NoError(t, err) - require.Greater(t, newer.UpdateAt, stale.UpdateAt, "the autosave must advance UpdateAt") - - edit := *created - edit.Title = "Published from stale content" - edit.Body = `{"type":"doc","content":[]}` - edit.EditAt = created.EditAt - - _, err = s.PublishDraft(false, &edit, userID, space.Id, false, testDefaultMaxDepth, stale.UpdateAt) - require.True(t, store.IsErrConflict(err), "publishing stale draft content must conflict, got %v", err) - - // The page must be untouched and the newer draft must survive for the client to republish. - live, err := s.GetPage(created.Id, false) - require.NoError(t, err) - require.NotEqual(t, "Published from stale content", live.Title, "the rolled-back publish must not have written the page") +// TestCreatePageSubtreeMissingSpace verifies CreatePageSubtree rejects a root targeting a +// nonexistent (or soft-deleted) space. +func TestCreatePageSubtreeMissingSpace(t *testing.T) { + s := openTestDB(t) - survived, err := s.GetDraft(userID, created.Id) - require.NoError(t, err, "the newer draft must survive the rolled-back publish") - require.Equal(t, newer.Body, survived.Body) - }) + userID := mmmodel.NewId() + root := newPage(mmmodel.NewId(), mmmodel.NewId(), userID, "") + root.Id = mmmodel.NewId() + _, err := s.CreatePageSubtree([]*model.Page{root}, 0) + require.True(t, store.IsErrNotFound(err), "expected ErrNotFound for a missing space, got %v", err) } -// TestUpsertDraftBaseEditAtWriteOnce verifies BaseEditAt is frozen at the establishing INSERT: a -// later upsert on the same (UserId, PageId) key carries a different BaseEditAt, but the stored -// (and returned) value never moves off the value the draft was established with. -func TestUpsertDraftBaseEditAtWriteOnce(t *testing.T) { +// TestGetSpacePages verifies GetSpacePages returns live pages for a space and excludes pages from +// other spaces and soft-deleted pages. +func TestGetSpacePages(t *testing.T) { s := openTestDB(t) channelID := mmmodel.NewId() userID := mmmodel.NewId() space, err := s.CreateSpace(newSpace(channelID)) require.NoError(t, err) - page, err := s.CreatePage(newPage(space.Id, channelID, userID, ""), testDefaultMaxDepth) - require.NoError(t, err) - - established := newDraft(userID, space.Id, page.Id, "") - established.BaseEditAt = page.EditAt - saved, _, err := s.UpsertDraft(established, nil, nil, nil) - require.NoError(t, err) - require.Equal(t, page.EditAt, saved.BaseEditAt) - - later := newDraft(userID, space.Id, page.Id, "") - later.BaseEditAt = page.EditAt + 1000 - updated, _, err := s.UpsertDraft(later, nil, nil, nil) - require.NoError(t, err) - require.Equal(t, page.EditAt, updated.BaseEditAt, - "BaseEditAt is write-once: a later upsert must not change the established baseline") - - // The persisted row (not just the returned struct) must reflect the same frozen value. - persisted, err := s.GetDraft(userID, page.Id) - require.NoError(t, err) - require.Equal(t, page.EditAt, persisted.BaseEditAt) -} - -// TestUpsertDraftPropsReplaceOrKeep verifies the whole-value replace-or-keep semantics of the props -// write-intent pointer: nil preserves the stored map untouched, a non-nil pointer replaces the whole -// map (dropping any key it doesn't carry), and a non-nil pointer to an empty map clears every key. -func TestUpsertDraftPropsReplaceOrKeep(t *testing.T) { - s := openTestDB(t) - - userID, pageID := mmmodel.NewId(), mmmodel.NewId() - space, err := s.CreateSpace(newSpace(mmmodel.NewId())) + otherSpace, err := s.CreateSpace(newSpace(mmmodel.NewId())) require.NoError(t, err) - d := newDraft(userID, space.Id, pageID, "") - d.Props = mmmodel.StringInterface{"foo": "bar"} - stored, _, err := s.UpsertDraft(d, nil, nil, &d.Props) + // Create two pages in the target space. + p1, err := s.CreatePage(newPage(space.Id, channelID, userID, ""), testDefaultMaxDepth) require.NoError(t, err) - require.Equal(t, "bar", stored.Props["foo"]) - - // A nil props pointer omits the write and preserves the stored map. - omit := newDraft(userID, space.Id, pageID, "") - afterOmit, _, err := s.UpsertDraft(omit, nil, nil, nil) + p2, err := s.CreatePage(newPage(space.Id, channelID, userID, ""), testDefaultMaxDepth) require.NoError(t, err) - require.Equal(t, "bar", afterOmit.Props["foo"], "a nil props pointer must preserve the stored map") - // A non-nil props pointer replaces the whole map: the unrelated "foo" key set above is gone, - // not merged with the new "baz" key. - replace := newDraft(userID, space.Id, pageID, "") - replace.Props = mmmodel.StringInterface{"baz": "qux"} - afterReplace, _, err := s.UpsertDraft(replace, nil, nil, &replace.Props) + // A page in a different space must not appear. + _, err = s.CreatePage(newPage(otherSpace.Id, mmmodel.NewId(), userID, ""), testDefaultMaxDepth) require.NoError(t, err) - require.Equal(t, "qux", afterReplace.Props["baz"]) - require.NotContains(t, afterReplace.Props, "foo", - "a non-nil props pointer must replace the whole map, not merge keys") - // A non-nil pointer to an empty map clears every key. - toClear := newDraft(userID, space.Id, pageID, "") - emptyProps := mmmodel.StringInterface{} - cleared, _, err := s.UpsertDraft(toClear, nil, nil, &emptyProps) + // A soft-deleted page in the target space must not appear. + deleted, err := s.CreatePage(newPage(space.Id, channelID, userID, ""), testDefaultMaxDepth) require.NoError(t, err) - require.Empty(t, cleared.Props, "a non-nil pointer to an empty map must clear all keys") -} - -// TestUpsertDraftOversizedPropsRejected verifies the store rejects a draft whose Props field -// (the field Draft.IsValid actually checks) exceeds PagePropsMaxBytes, regardless of what the -// props write-intent pointer carries. This is enforced by Draft.IsValid, not by the pointer's -// contents — sizing the pointer's target (rather than draft.Props) is the App layer's job. -func TestUpsertDraftOversizedPropsRejected(t *testing.T) { - s := openTestDB(t) + require.NoError(t, deletePageErr(s, deleted.Id, deleted.SpaceId, userID)) - userID, pageID := mmmodel.NewId(), mmmodel.NewId() - space, err := s.CreateSpace(newSpace(mmmodel.NewId())) + pages, err := s.GetSpacePages(space.Id, 0, 100) require.NoError(t, err) + require.Len(t, pages, 2, "must return exactly the two live pages in the space") - d := newDraft(userID, space.Id, pageID, "") - d.Props = mmmodel.StringInterface{"k": strings.Repeat("x", model.PagePropsMaxBytes)} - _, _, err = s.UpsertDraft(d, nil, nil, &d.Props) - require.Error(t, err) - require.True(t, store.IsErrInvalidInput(err), "oversized draft.Props must be rejected by Draft.IsValid, got %v", err) + ids := summaryIDs(pages) + require.Contains(t, ids, p1.Id) + require.Contains(t, ids, p2.Id) + require.NotContains(t, ids, deleted.Id) } -// TestUpsertDraftEstablishGuardRejectsBaselineAheadOfPage verifies the establish-time guard: an -// establishing INSERT (no existing draft row) whose BaseEditAt is ahead of the live page's current -// EditAt is impossible (the client cannot have seen a version newer than the one that exists) and -// is rejected as invalid input. A baseline equal to the page's EditAt is accepted; a baseline -// behind it is not caught by this guard but is still rejected by the separate resurrection check -// (see TestUpsertDraftResurrectionClassification). -func TestUpsertDraftEstablishGuardRejectsBaselineAheadOfPage(t *testing.T) { +// TestCreatePageSubtreeSuccess verifies that CreatePageSubtree inserts a root plus children and +// returns all created rows with their assigned IDs. +func TestCreatePageSubtreeSuccess(t *testing.T) { s := openTestDB(t) channelID := mmmodel.NewId() @@ -2924,54 +1714,35 @@ func TestUpsertDraftEstablishGuardRejectsBaselineAheadOfPage(t *testing.T) { space, err := s.CreateSpace(newSpace(channelID)) require.NoError(t, err) - t.Run("ahead of the live page is rejected", func(t *testing.T) { - page, err := s.CreatePage(newPage(space.Id, channelID, userID, ""), testDefaultMaxDepth) - require.NoError(t, err) - - ahead := newDraft(userID, space.Id, page.Id, "") - ahead.BaseEditAt = page.EditAt + 1000 - _, _, err = s.UpsertDraft(ahead, nil, nil, nil) - require.Error(t, err) - var inv *store.ErrInvalidInput - require.True(t, errors.As(err, &inv), "expected ErrInvalidInput, got %T: %v", err, err) - require.Equal(t, "BaseEditAt", inv.Field) - }) - - t.Run("equal to the live page is accepted", func(t *testing.T) { - page, err := s.CreatePage(newPage(space.Id, channelID, userID, ""), testDefaultMaxDepth) - require.NoError(t, err) + // Build a two-level subtree: root → child → grandchild. + root := newPage(space.Id, channelID, userID, "") + root.PreSave() + child := newPage(space.Id, channelID, userID, root.Id) + child.PreSave() + grandchild := newPage(space.Id, channelID, userID, child.Id) + grandchild.PreSave() - equal := newDraft(userID, space.Id, page.Id, "") - equal.BaseEditAt = page.EditAt - _, _, err = s.UpsertDraft(equal, nil, nil, nil) - require.NoError(t, err, "an establishing baseline equal to the live page's EditAt must be accepted") - }) + created, err := s.CreatePageSubtree([]*model.Page{root, child, grandchild}, testDefaultMaxDepth) + require.NoError(t, err) + require.Len(t, created, 3, "must return all three created pages") - // A baseline strictly behind the live page's EditAt passes the ahead-only guard above (it is - // not "ahead"), but is still rejected — by the separate resurrection check just below the - // guard, since this is still a first-ever establish (no existing draft row) and the page - // advanced past the caller's baseline. This is a real optimistic-lock conflict, not a bug: - // the client's session is already stale on its very first save. - t.Run("behind the live page is rejected as a stale baseline, not by the ahead-only guard", func(t *testing.T) { - page, err := s.CreatePage(newPage(space.Id, channelID, userID, ""), testDefaultMaxDepth) - require.NoError(t, err) + ids := idsOf(created) + require.Contains(t, ids, root.Id) + require.Contains(t, ids, child.Id) + require.Contains(t, ids, grandchild.Id) - behind := newDraft(userID, space.Id, page.Id, "") - behind.BaseEditAt = page.EditAt - 1 - _, _, err = s.UpsertDraft(behind, nil, nil, nil) - require.Error(t, err) - require.False(t, store.IsErrInvalidInput(err), "a behind baseline must not trip the ahead-only establish guard") - require.True(t, store.IsErrConflict(err), "expected ErrConflict, got %T: %v", err, err) - require.Equal(t, store.ReasonConcurrentEdit, store.ConflictReason(err)) - }) + // Verify they are live in the DB. + for _, id := range ids { + got, getErr := s.GetPage(id, false) + require.NoError(t, getErr, "page %s must be fetchable after subtree create", id) + require.Zero(t, got.DeleteAt) + } } -// TestUpsertDraftConcurrentFirstAutosavesSerialize verifies that two concurrent establishing -// upserts for the same (userID, pageID) on an existing page do not both take the "no existing -// draft" branch: the per-space FOR UPDATE lock (lockLiveSpace) serializes them, so only the first -// is a true establish and every later one observes the row the first inserted and is treated as an -// update — neither is falsely rejected by the establish-time guard. -func TestUpsertDraftConcurrentFirstAutosavesSerialize(t *testing.T) { +// TestSpaceDeleteRestoreKeepsPageTimestampsMonotonic verifies the delete/restore cascades never +// regress a page's UpdateAt/EditAt CAS tokens, even when a prior structural operation advanced +// them past wall clock — a regressed token would make a stale client baseline read as current. +func TestSpaceDeleteRestoreKeepsPageTimestampsMonotonic(t *testing.T) { s := openTestDB(t) channelID := mmmodel.NewId() @@ -2981,94 +1752,94 @@ func TestUpsertDraftConcurrentFirstAutosavesSerialize(t *testing.T) { page, err := s.CreatePage(newPage(space.Id, channelID, userID, ""), testDefaultMaxDepth) require.NoError(t, err) - const n = 8 - start := make(chan struct{}) + // Simulate prior structural operations having pushed the CAS tokens past wall clock. + future := mmmodel.GetMillis() + 60_000 + _, err = s.RawExecForTest("UPDATE DOCS_Page SET UpdateAt = $1, EditAt = $1 WHERE Id = $2", future, page.Id) + require.NoError(t, err) + + require.NoError(t, s.DeleteSpace(space.Id)) + deleted, err := s.GetPage(page.Id, true) + require.NoError(t, err) + require.Greater(t, deleted.UpdateAt, future, "delete cascade must advance UpdateAt, never regress it") + require.Greater(t, deleted.EditAt, future, "delete cascade must advance EditAt, never regress it") + + require.NoError(t, s.RestoreSpace(space.Id)) + restored, err := s.GetPage(page.Id, false) + require.NoError(t, err) + require.Greater(t, restored.UpdateAt, deleted.UpdateAt, "restore cascade must advance UpdateAt, never regress it") + require.Greater(t, restored.EditAt, deleted.EditAt, "restore cascade must advance EditAt, never regress it") +} + +// TestWithSpaceMembershipLockSerializes verifies lock-holders for the same space are mutually +// exclusive: no two callbacks are ever inside the lock at once, and callbacks run to completion +// even when their work spans multiple scheduling points. +func TestWithSpaceMembershipLockSerializes(t *testing.T) { + s := openTestDB(t) + spaceID := mmmodel.NewId() + + const n = 20 + var active atomic.Int32 + var overlaps atomic.Int32 var wg sync.WaitGroup errs := make([]error, n) wg.Add(n) for i := range n { go func() { defer wg.Done() - <-start - d := newDraft(userID, space.Id, page.Id, "") - d.BaseEditAt = page.EditAt - _, _, errs[i] = s.UpsertDraft(d, nil, nil, nil) + errs[i] = s.WithSpaceMembershipLock(spaceID, func() error { + if active.Add(1) > 1 { + overlaps.Add(1) + } + // Widen the hold window across a scheduling point, mimicking the multi-call + // guard the lock exists to protect. + runtime.Gosched() + active.Add(-1) + return nil + }) }() } - close(start) wg.Wait() - for i, uErr := range errs { - require.NoError(t, uErr, "concurrent first-autosave %d must not be falsely rejected by the establish guard", i) + for i, lErr := range errs { + require.NoError(t, lErr, "lock call %d", i) } - - got, err := s.GetDraft(userID, page.Id) - require.NoError(t, err) - require.Equal(t, page.EditAt, got.BaseEditAt) + require.Zero(t, overlaps.Load(), "concurrent holders observed — advisory lock failed to serialize") } -// TestUpsertDraftResurrectionClassification verifies UpsertDraft distinguishes the two resurrection -// reasons: an autosave with a stale non-zero BaseEditAt behind the page's current EditAt (the page -// advanced under it) classifies as ReasonConcurrentEdit, while an autosave with no baseline (0) on a -// page id a concurrent publish just claimed classifies as ReasonConcurrentAutosave. Both fire only -// when the draft row a resurrection would recreate no longer exists (a concurrent publish consumed -// it), matching the "refuse to resurrect a consumed draft" contract in UpsertDraft. -func TestUpsertDraftResurrectionClassification(t *testing.T) { - t.Run("stale non-zero baseline behind the page classifies as concurrent edit", func(t *testing.T) { - s := openTestDB(t) - channelID := mmmodel.NewId() - userID := mmmodel.NewId() - space, err := s.CreateSpace(newSpace(channelID)) - require.NoError(t, err) - page, err := s.CreatePage(newPage(space.Id, channelID, userID, ""), testDefaultMaxDepth) - require.NoError(t, err) - - d := newDraft(userID, space.Id, page.Id, "") - d.BaseEditAt = page.EditAt - _, _, err = s.UpsertDraft(d, nil, nil, nil) - require.NoError(t, err) +// TestWithSpaceMembershipLockAcquireTimeout verifies a waiter gives up with a retryable +// ErrConflict once the acquisition timeout elapses, instead of blocking indefinitely on a +// pooled connection while another holder is inside the lock. +func TestWithSpaceMembershipLockAcquireTimeout(t *testing.T) { + s := openTestDB(t) + spaceID := mmmodel.NewId() - // The page is edited (advancing EditAt past the draft's baseline), and the draft is - // removed — simulating a concurrent publish that consumed it. - newTitle := "Edited concurrently" - edited, err := s.UpdatePage(page.Id, page.SpaceId, &model.PagePatch{Title: &newTitle}, page.EditAt, false, userID) - require.NoError(t, err) - require.Greater(t, edited.EditAt, page.EditAt) - require.NoError(t, s.DeleteDraft(userID, page.Id)) + holderIn := make(chan struct{}) + releaseHolder := make(chan struct{}) + holderDone := make(chan error, 1) + go func() { + holderDone <- s.WithSpaceMembershipLock(spaceID, func() error { + close(holderIn) + <-releaseHolder + return nil + }) + }() + <-holderIn - // A stale-baseline autosave tries to re-establish the now-consumed draft. - stale := newDraft(userID, space.Id, page.Id, "") - stale.BaseEditAt = page.EditAt - _, _, err = s.UpsertDraft(stale, nil, nil, nil) - require.Error(t, err) - require.True(t, store.IsErrConflict(err), "expected ErrConflict, got %T: %v", err, err) - require.Equal(t, store.ReasonConcurrentEdit, store.ConflictReason(err)) + err := s.WithSpaceMembershipLockTimeoutForTest(spaceID, 300*time.Millisecond, func() error { + t.Error("callback must not run when the lock was never acquired") + return nil }) + require.Error(t, err) + require.True(t, store.IsErrConflict(err), "lock acquisition timeout must surface as a retryable conflict; got %v", err) - t.Run("no baseline on a page a concurrent publish just claimed classifies as concurrent autosave", func(t *testing.T) { - s := openTestDB(t) - channelID := mmmodel.NewId() - userID := mmmodel.NewId() - space, err := s.CreateSpace(newSpace(channelID)) - require.NoError(t, err) - - pageID := mmmodel.NewId() - d := newDraft(userID, space.Id, pageID, "") - _, _, err = s.UpsertDraft(d, nil, nil, nil) - require.NoError(t, err) - - // A concurrent publish creates the page at that exact id and removes the draft. - published := newPage(space.Id, channelID, userID, "") - published.Id = pageID - _, err = s.CreatePage(published, testDefaultMaxDepth) - require.NoError(t, err) - require.NoError(t, s.DeleteDraft(userID, pageID)) + close(releaseHolder) + require.NoError(t, <-holderDone) - // A baseline-less autosave tries to re-establish the now-consumed new-page draft. - stale := newDraft(userID, space.Id, pageID, "") - _, _, err = s.UpsertDraft(stale, nil, nil, nil) - require.Error(t, err) - require.True(t, store.IsErrConflict(err), "expected ErrConflict, got %T: %v", err, err) - require.Equal(t, store.ReasonConcurrentAutosave, store.ConflictReason(err)) - }) + // With the holder gone, the same call must acquire immediately and run the callback. + ran := false + require.NoError(t, s.WithSpaceMembershipLockTimeoutForTest(spaceID, 300*time.Millisecond, func() error { + ran = true + return nil + })) + require.True(t, ran) } From be226396a5232970096371640dce1ac2eb5b3003 Mon Sep 17 00:00:00 2001 From: "Catalin I. Tomai" Date: Sun, 26 Jul 2026 22:27:29 +0200 Subject: [PATCH 34/36] publish/autosave into shared store transactions; harden guards, presence --- assets/i18n/en.json | 6 +- server/api_page_drafts.go | 12 +- server/api_page_drafts_test.go | 46 ++++ server/app/page_content.go | 8 +- server/app/page_content_test.go | 22 ++ server/app/page_draft.go | 340 +++++++++++-------------- server/app/page_draft_internal_test.go | 126 +++++++++ server/app/page_draft_test.go | 207 ++++++++++++++- server/app/page_presence.go | 76 +++++- server/app/service_test.go | 4 +- server/app/ws_events.go | 8 +- server/app/ws_events_test.go | 41 ++- server/model/draft.go | 32 ++- server/model/page_content.go | 28 +- server/model/page_content_test.go | 80 ++++++ server/store/draft_store.go | 145 +++++++---- server/store/draft_store_test.go | 134 ++++++++-- server/store/page_move.go | 4 +- server/store/page_move_test.go | 2 +- server/store/page_store.go | 291 +++++++++------------ server/store/store.go | 8 +- 21 files changed, 1152 insertions(+), 468 deletions(-) create mode 100644 server/app/page_draft_internal_test.go diff --git a/assets/i18n/en.json b/assets/i18n/en.json index 9be09c7..305a448 100644 --- a/assets/i18n/en.json +++ b/assets/i18n/en.json @@ -307,6 +307,10 @@ "id": "app.page_draft.publish.page_deleted.app_error", "translation": "The page was deleted and can no longer be published." }, + { + "id": "app.page_draft.publish.page_not_found.app_error", + "translation": "The page does not exist or is not accessible in this space." + }, { "id": "app.page_draft.publish.parent_unpublished.app_error", "translation": "The parent page must be published before this page can be published." @@ -345,7 +349,7 @@ }, { "id": "app.page_draft.update.nil_draft.app_error", - "translation": "Draft must not be nil." + "translation": "Draft is required." }, { "id": "app.page_draft.update.page_not_found.app_error", diff --git a/server/api_page_drafts.go b/server/api_page_drafts.go index c3856a2..7f8c48f 100644 --- a/server/api_page_drafts.go +++ b/server/api_page_drafts.go @@ -30,7 +30,9 @@ const ( // // For existing published pages, the first request creates the draft (open an edit session). The client // must send the top-level base_edit_at field — the page's EditAt at the moment the user opened it — on -// every autosave, so a subsequent publish can detect a concurrent edit. It is not a props key. +// every autosave, so a subsequent publish can detect a concurrent edit. It is not a props key. The +// stored baseline is write-once: whichever request establishes the draft row freezes it, and values +// sent on later autosaves are ignored (re-baselining requires discarding the draft and reopening). // // For new-page drafts (no page row yet), the draft must already exist via POST // /spaces/{space_id}/drafts. This prevents a space member who learns another user's pending page @@ -60,8 +62,8 @@ func (p *Plugin) handleUpdatePageDraft(w http.ResponseWriter, r *http.Request) { // base_edit_at nil → 0 (no baseline). A new-page draft legitimately has no baseline. For an // existing published page the client must send base_edit_at on every autosave (see the handler doc - // above): omitting it on the first autosave of an edit session is rejected with 409, because the - // store will not open an edit-session draft against a live page without a baseline. Props flows via + // above): omitting it on the first autosave of an edit session is rejected with 409 — an + // edit-session draft cannot be opened against a live page without a baseline. Props flows via // the pointer below (like file_ids), so it is not set on the struct here. var baseEditAt int64 if req.BaseEditAt != nil { @@ -170,7 +172,9 @@ func (p *Plugin) handleCreateSpaceDraft(w http.ResponseWriter, r *http.Request) // the draft, stored in its write-once BaseEditAt column (sent as the top-level base_edit_at field on // the autosave requests). This differs from the per-request base_edit_at on handleUpdatePage (and // expected_update_at on handleMovePage) because a publish ships whatever the draft already holds -// rather than re-supplying a freshly-read baseline. +// rather than re-supplying a freshly-read baseline. Because that baseline is write-once, a 409 +// edit-conflict cannot be resolved by autosaving a newer base_edit_at: the client recovers by +// republishing with force, or by discarding the draft and reopening the edit session. func (p *Plugin) handlePublishPageDraft(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) spaceID := vars["space_id"] diff --git a/server/api_page_drafts_test.go b/server/api_page_drafts_test.go index 694b6c5..1bc0340 100644 --- a/server/api_page_drafts_test.go +++ b/server/api_page_drafts_test.go @@ -485,3 +485,49 @@ func TestHandler_UpdatePageDraftPropsBaselineNoLongerHonored(t *testing.T) { }) require.Equal(t, http.StatusConflict, rec.Code, "props.original_page_edit_at must not be honored as a baseline") } + +// TestHandler_PublishForceOverHTTP exercises the {"force": true} publish body through the real +// router: a stale-baseline publish that 409s without it succeeds when the request carries force. +func TestHandler_PublishForceOverHTTP(t *testing.T) { + h := openTestPlugin(t, nil) + space := seedSpace(t, h.store, mmmodel.NewId()) + userA := mmmodel.NewId() + userB := mmmodel.NewId() + base := "/api/v1/spaces/" + space.Id + + // Create a new-page draft and publish it to get a live page. + rec := h.do(t, http.MethodPost, base+"/drafts", userA, map[string]any{"title": "Shared Doc"}) + require.Equal(t, http.StatusCreated, rec.Code) + var draft model.Draft + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &draft)) + pageID := draft.PageId + + rec = h.do(t, http.MethodPost, base+"/pages/"+pageID+"/draft/publish", userA, nil) + require.Equal(t, http.StatusCreated, rec.Code) + var page model.Page + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &page)) + editAt := page.EditAt + + // Both users open edit sessions at the same baseline; B publishes first, staling A's baseline. + rec = h.do(t, http.MethodPatch, base+"/pages/"+pageID+"/draft", userA, map[string]any{ + "title": "Edit by A", + "base_edit_at": editAt, + }) + require.Equal(t, http.StatusOK, rec.Code) + rec = h.do(t, http.MethodPatch, base+"/pages/"+pageID+"/draft", userB, map[string]any{ + "title": "Edit by B", + "base_edit_at": editAt, + }) + require.Equal(t, http.StatusOK, rec.Code) + rec = h.do(t, http.MethodPost, base+"/pages/"+pageID+"/draft/publish", userB, nil) + require.Equal(t, http.StatusOK, rec.Code) + + // Without force the stale baseline 409s; with {"force": true} in the HTTP body it publishes. + rec = h.do(t, http.MethodPost, base+"/pages/"+pageID+"/draft/publish", userA, nil) + require.Equal(t, http.StatusConflict, rec.Code) + rec = h.do(t, http.MethodPost, base+"/pages/"+pageID+"/draft/publish", userA, map[string]any{"force": true}) + require.Equal(t, http.StatusOK, rec.Code) + var published model.Page + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &published)) + require.Equal(t, "Edit by A", published.Title) +} diff --git a/server/app/page_content.go b/server/app/page_content.go index 028d63e..56f4ca6 100644 --- a/server/app/page_content.go +++ b/server/app/page_content.go @@ -59,8 +59,7 @@ func normalizePatchContent(where string, patch *model.PagePatch) *mmmodel.AppErr // normalizeContentBody normalizes a body without deriving SearchText, for callers (draft autosave) // that store only the body. It shares normalizeContent with normalizePageContent but discards the -// parsed doc, so it skips the full-text BuildSearchText walk that normalizeContent's caller would -// otherwise run on every call — a waste on the highest-frequency write path. +// parsed doc. func normalizeContentBody(where, body string) (string, *mmmodel.AppError) { normBody, _, _, err := normalizeContent(body) if err != nil { @@ -95,6 +94,11 @@ func normalizeContentToDoc(content string) (doc model.TipTapDocument, empty bool if content == "" { return model.TipTapDocument{}, true, nil } + // Reject over-limit content up front for both input forms: ParseTipTapDocument enforces the + // same cap only on the JSON path. + if len(content) > model.PageBodyMaxBytes { + return model.TipTapDocument{}, false, errors.New("content exceeds the maximum body size") + } // Treat the body as TipTap only when it is actually valid JSON: a plain-text body that merely // starts with "{" (e.g. "{shrug}") is not JSON and must be wrapped, not rejected. A body that is // valid JSON but not a "doc" is a genuine content error and ParseTipTapDocument rejects it. diff --git a/server/app/page_content_test.go b/server/app/page_content_test.go index c19e43f..2bdfc2d 100644 --- a/server/app/page_content_test.go +++ b/server/app/page_content_test.go @@ -114,3 +114,25 @@ func TestNormalizePatchContent(t *testing.T) { require.NotContains(t, *patch.Body, "javascript:alert") }) } + +func TestNormalizeContentRejectsOversizedBodyBeforeParsing(t *testing.T) { + // The size gate must run before json.Unmarshal, so the stored-body limit — not the larger + // request-transport cap — bounds the parse allocation. + oversized := `{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"` + + strings.Repeat("a", model.PageBodyMaxBytes) + `"}]}]}` + _, _, appErr := normalizePageContent("TestNormalizeContent", oversized) + require.NotNil(t, appErr) + require.Equal(t, http.StatusBadRequest, appErr.StatusCode) +} + +// TestNormalizePageContentAcceptsMaxParagraphs pins the paragraph cap's boundary: a body producing +// exactly maxPlainTextParagraphs paragraphs is accepted — the cap rejects strictly more than the +// documented limit, not "the limit or more". +func TestNormalizePageContentAcceptsMaxParagraphs(t *testing.T) { + // "x\n" repeated N-1 times splits into N-1 "x" paragraphs plus one trailing empty paragraph: + // exactly maxPlainTextParagraphs. + body := strings.Repeat("x\n", maxPlainTextParagraphs-1) + + _, _, appErr := normalizePageContent("test", body) + require.Nil(t, appErr) +} diff --git a/server/app/page_draft.go b/server/app/page_draft.go index ba23df8..690d404 100644 --- a/server/app/page_draft.go +++ b/server/app/page_draft.go @@ -15,10 +15,6 @@ import ( "github.com/mattermost/mattermost-plugin-docs/server/store" ) -func presenceBroadcastKey(pageID, userID string) string { - return pageID + ":" + userID -} - // UpdatePageDraft upserts the calling user's autosave draft for a page in a space. channelID is // the space's backing channel, used to scope the presence broadcast. // @@ -93,30 +89,9 @@ func (s *Service) UpdatePageDraft(draft *model.Draft, parentID *string, fileIDs } } - existingDraft, existingDraftErr := s.store.GetDraft(draft.UserId, draft.PageId) - switch { - case existingDraftErr != nil && !store.IsErrNotFound(existingDraftErr): - return nil, storeAppError("UpdatePageDraft", existingDraftErr) - case existingDraftErr == nil && existingDraft.SpaceId != draft.SpaceId: - // Existing draft belongs to a different space: reject to prevent cross-space drift. - return nil, mmmodel.NewAppError("UpdatePageDraft", "app.page_draft.update.page_not_found.app_error", nil, "", http.StatusNotFound) - case store.IsErrNotFound(existingDraftErr): - // No draft for THIS user+page. Allow only if the page is already published/live in the - // space — PageExistsInSpace checks DOCS_Page, not drafts. A page id reserved via - // CreateSpaceDraft (the id is allocated, but no DOCS_Page row exists yet) has only a - // DOCS_Draft row, so its author reaches this method through the "existing draft" branch - // above (user-scoped GetDraft), never here. - // This prevents PATCH /spaces/X/pages//draft from ghost-drafting a non-existent page. - pageIsLive, existsErr := s.store.PageExistsInSpace(draft.PageId, draft.SpaceId) - if existsErr != nil { - return nil, storeAppError("UpdatePageDraft", existsErr) - } - if !pageIsLive { - return nil, mmmodel.NewAppError("UpdatePageDraft", "app.page_draft.update.page_not_found.app_error", nil, "", http.StatusNotFound) - } - } - - saved, savedPageWasLive, err := s.store.UpsertDraft(draft, parentID, fileIDs, props) + // AutosaveDraft enforces the autosave-path guards itself (see its godoc), so no separate + // pre-check reads are needed here. + saved, savedPageWasLive, err := s.store.AutosaveDraft(draft, parentID, fileIDs, props) if err != nil { switch store.ConflictReason(err) { case store.ReasonConcurrentEdit: @@ -127,40 +102,16 @@ func (s *Service) UpdatePageDraft(draft *model.Draft, parentID *string, fileIDs nil, "", http.StatusConflict).Wrap(err) } if store.InvalidInputReason(err) == store.ReasonPageNotLive { - // The target page was deleted, snapshotted, or moved out of this space between the - // pre-check above and the store's locked read. That is a concurrent state change, not - // bad input, so mirror the 404 the pre-check returns rather than a generic 400. + // The page is not addressable in this space — deleted, snapshotted, moved away, held as + // a draft in another space, or never reserved. All of these read as "no such page here", + // so report 404 rather than a generic 400. return nil, mmmodel.NewAppError("UpdatePageDraft", "app.page_draft.update.page_not_found.app_error", nil, "", http.StatusNotFound).Wrap(err) } return nil, storeAppError("UpdatePageDraft", err) } - // New-page drafts (no published page row yet) must not broadcast presence to the space channel: - // that would expose the reserved page ID and the author's identity to all space members before - // the page exists. Send the event only to the author so their own UI can track the session. - // UpsertDraft determined liveness as part of the same call, so trust its result here. - if !savedPageWasLive { - s.publishSelfPresence(saved.UserId, saved.PageId, saved.SpaceId, []string{saved.UserId}) - return saved, nil - } - - // Existing published page: rate-limited channel-wide broadcast so other viewers see this user - // in the active-editors indicator. The rate-limit bucket is keyed per (page, user) so each editor - // gets an independent limit — one editor's broadcast can't rate-limit another editor on the same page. - presenceKey := presenceBroadcastKey(saved.PageId, saved.UserId) - now := mmmodel.GetMillis() - s.sweepPresenceBroadcastTimes(now) - existing, loaded := s.presenceBroadcastTimes.LoadOrStore(presenceKey, now) - if loaded { - lastTime, ok := existing.(int64) - if !ok || now-lastTime < presenceBroadcastMinIntervalMs { - return saved, nil - } - if !s.presenceBroadcastTimes.CompareAndSwap(presenceKey, existing, now) { - return saved, nil - } - } - s.broadcastPagePresence(saved.PageId, saved.SpaceId, channelID) + // AutosaveDraft determined page liveness as part of the same call, so trust its result here. + s.maybeBroadcastDraftPresence(savedPageWasLive, saved.PageId, saved.UserId, saved.SpaceId, channelID) return saved, nil } @@ -246,12 +197,13 @@ func (s *Service) validateDraftParent(userID, spaceID, parentID string) *mmmodel return nil } // Not a published page in this space — accept only the caller's own draft in this space. - _, draftErr := s.GetPageDraft(userID, spaceID, parentID) - if draftErr == nil { + // GetDraftSpaceID probes existence (space-checked below) without hauling the draft body. + draftSpaceID, draftErr := s.store.GetDraftSpaceID(userID, parentID) + if draftErr == nil && draftSpaceID == spaceID { return nil } - if draftErr.StatusCode != http.StatusNotFound { - return draftErr + if draftErr != nil && !store.IsErrNotFound(draftErr) { + return storeAppError("validateDraftParent", draftErr) } return mmmodel.NewAppError("validateDraftParent", "app.page_draft.create.invalid_parent.app_error", nil, "", http.StatusBadRequest) } @@ -287,7 +239,7 @@ func (s *Service) GetPageDraft(userID, spaceID, pageID string) (*model.Draft, *m } // DeletePageDraft removes the calling user's draft for the given page — the discard path. (A -// publish deletes the draft inside the store.PublishDraft transaction without calling here.) +// publish deletes the draft inside the store publish transaction without calling here.) // Returns not-found when no draft exists. channelID is the space's backing channel, used to scope // the presence broadcast. func (s *Service) DeletePageDraft(userID, spaceID, pageID, channelID string) *mmmodel.AppError { @@ -302,46 +254,33 @@ func (s *Service) DeletePageDraft(userID, spaceID, pageID, channelID string) *mm } s.log.Debug("Deleting page draft", "space_id", spaceID, "page_id", pageID, "user_id", userID) - // A draft is keyed by (UserId, PageId) without SpaceId, so confirm it belongs to the space - // named in the request before deleting — otherwise a member of another space could delete a - // draft here by passing this space's id with a foreign page id. - if _, appErr := s.GetPageDraft(userID, spaceID, pageID); appErr != nil { - // GetPageDraft returns its own get.* not-found key; translate it to the delete operation's - // key so a discard reports a delete-appropriate message. - if appErr.StatusCode == http.StatusNotFound { - return mmmodel.NewAppError("DeletePageDraft", "app.page_draft.delete.not_found.app_error", nil, "", http.StatusNotFound).Wrap(appErr) - } - return appErr - } - // Discard is unconditional. Autosave and discard are separate HTTP requests in separate // transactions, so an autosave request the same user dispatched just before the discard can // still be in flight when the discard commits and then re-insert (resurrect) this draft — the - // server does not guarantee the autosave commits before the later-issued delete. An unpublished - // new-page draft has no page row for UpsertDraft's staleness guard to key on, so the guard - // cannot tell that a discard happened (a published page is protected; only this case is not). + // server does not guarantee the autosave commits before the later-issued delete. + // + // UpsertDraft's staleness guard cannot catch this case: an unpublished new-page draft has no + // page row for the guard to key on (a published page is protected; only this case is not). + // // The resulting zombie draft is harmless — it is visible only to its owning user (drafts are // keyed by (UserId, PageId)), and that user removes it by discarding again — so a deletion // tombstone to permanently block re-insertion is not warranted. + // A draft is keyed by (UserId, PageId) without SpaceId; DeleteDraftReparenting scopes its + // delete by (UserId, SpaceId, PageId) and requires a live space, so a member of another space + // cannot delete a draft here by passing this space's id with a foreign page id — no separate + // pre-check read is needed. pageWasLive, delErr := s.store.DeleteDraftReparenting(userID, spaceID, pageID) if delErr != nil { - // A concurrent publish/delete may have removed the draft between the check above and here; - // treat that benign race as a 404, matching the not-found path of the initial check, rather - // than a 500 that would also emit a spurious server-side error log. + // No matching draft — never existed, wrong space, dead space, or removed by a concurrent + // publish/delete. All read as "nothing to discard", so report 404 rather than a 500 that + // would also emit a spurious server-side error log. if store.IsErrNotFound(delErr) { return mmmodel.NewAppError("DeletePageDraft", "app.page_draft.delete.not_found.app_error", nil, "", http.StatusNotFound).Wrap(delErr) } return storeAppError("DeletePageDraft", delErr) } - // Presence cleanup: only broadcast channel-wide if the page is published. A new-page draft - // discard was never visible to the channel (no channel broadcast on create) — its session was - // announced to the author alone (publishSelfPresence), so clear it the same way. - if !pageWasLive { - s.publishSelfPresence(userID, pageID, spaceID, []string{}) - return nil - } - s.clearThrottleAndBroadcastPagePresence(pageID, userID, spaceID, channelID) + s.endDraftPresenceSession(pageWasLive, pageID, userID, spaceID, channelID) return nil } @@ -400,21 +339,9 @@ func (s *Service) PublishPageDraft(userID, spaceID, pageID string, force bool) ( // 2. Derive isNewPage from the database; never trust client state. existing, existingErr := s.GetPageWithDeleted(pageID) - isNewPage := false - switch { - case existingErr != nil && existingErr.StatusCode == http.StatusNotFound: - isNewPage = true - case existingErr != nil: - return nil, false, existingErr - case existing.SpaceId != spaceID: - // The page id resolves to a page in another space (GetPageWithDeleted is not space-scoped). - // The caller is only authorized for spaceID, so this id is not publishable here. - return nil, false, mmmodel.NewAppError("PublishPageDraft", "app.page_draft.publish.conflict.app_error", - nil, "", http.StatusConflict) - case existing.DeleteAt != 0: - return nil, false, mmmodel.NewAppError("PublishPageDraft", "app.page_draft.publish.page_deleted.app_error", - nil, "", http.StatusConflict) - // default: live page in this space → edit path + isNewPage, targetErr := derivePublishTarget(existing, existingErr, spaceID) + if targetErr != nil { + return nil, false, targetErr } // 3. Parent guard (new-page path only): a new page's parent must be a published live page; a @@ -434,23 +361,31 @@ func (s *Service) PublishPageDraft(userID, spaceID, pageID string, force bool) ( } } - // 4/5. Validate & normalise the draft body and build the *model.Page for the store call - // (new-page vs edit-path field rules; see helper). - pageForWrite, buildErr := s.buildPageForPublish(isNewPage, pageID, spaceID, userID, draft, force) - if buildErr != nil { - return nil, false, buildErr - } - - // A "baseline-only" edit draft carries an optimistic-lock baseline but no populated field, so - // there is no page change to write; discard it rather than bumping EditAt for nothing (see helper). - if !isNewPage && pageForWrite.Title == "" && pageForWrite.Body == "" && len(pageForWrite.Props) == 0 { - return s.discardBaselineOnlyDraft(userID, pageID, spaceID, draft.UpdateAt) + // 4/5/6. Validate & normalise the draft, build the store write (new-page insert vs edit patch; + // see helpers), and commit page + draft-delete in one transaction. draft.UpdateAt is passed + // through so a concurrent autosave rolls this publish back as a conflict rather than shipping + // older content — see store.deletePublishedDraftTx. + var page *model.Page + var storeErr error + if isNewPage { + pageForWrite, buildErr := s.buildNewPageForPublish(pageID, spaceID, userID, draft) + if buildErr != nil { + return nil, false, buildErr + } + page, storeErr = s.store.PublishNewPageDraft(pageForWrite, userID, spaceID, model.MaxPageDepth, draft.UpdateAt) + } else { + patch, buildErr := s.buildEditPatchForPublish(draft, force) + if buildErr != nil { + return nil, false, buildErr + } + // A "baseline-only" edit draft carries an optimistic-lock baseline but no populated field, + // so there is no page change to write; discard it rather than bumping EditAt for nothing + // (see helper). + if patch.Title == nil && patch.Body == nil && patch.Props == nil { + return s.discardBaselineOnlyDraft(userID, pageID, spaceID, draft.UpdateAt) + } + page, storeErr = s.store.PublishPageEditDraft(pageID, spaceID, patch, draft.BaseEditAt, force, userID, draft.UpdateAt) } - - // 6. Atomic write: page + draft-delete in one transaction. draft.UpdateAt is passed through so a - // concurrent autosave rolls this publish back as a conflict rather than shipping older content — - // see store.PublishDraft. - page, storeErr := s.store.PublishDraft(isNewPage, pageForWrite, userID, spaceID, force, model.MaxPageDepth, draft.UpdateAt) if storeErr != nil { switch { // The draft moved under this publish: the caller's own editor autosaved after this call read it, @@ -460,9 +395,11 @@ func (s *Service) PublishPageDraft(userID, spaceID, pageID string, force bool) ( return nil, false, mmmodel.NewAppError("PublishPageDraft", "app.page_draft.publish.draft_changed.app_error", nil, "", http.StatusConflict).Wrap(storeErr) - // Someone else edited the page since the baseline was captured. The client must re-read the page - // and publish against a fresh baseline (or force). Return the current server page alongside the - // conflict so the client can diff and re-baseline in one round-trip rather than a follow-up GET. + // Someone else edited the page since the baseline was captured. The draft's baseline is + // write-once (see store.UpsertDraft), so the client cannot re-baseline the existing draft: + // it recovers by publishing with force, or by discarding the draft and reopening the edit + // session against the current page. Return the current server page alongside the conflict so + // the client can diff and choose in one round-trip rather than a follow-up GET. // The pre-lock `existing` snapshot is stale by definition here, so re-read the live page. case store.ConflictReason(storeErr) == store.ReasonConcurrentEdit: editConflictErr := mmmodel.NewAppError("PublishPageDraft", "app.page_draft.publish.edit_conflict.app_error", @@ -512,87 +449,107 @@ func (s *Service) PublishPageDraft(userID, spaceID, pageID string, force bool) ( "space_id": page.SpaceId, }, page.ChannelId) } - // The publish deleted the draft inside PublishDraft (bypassing the app-level DeletePageDraft - // that normally broadcasts presence), so broadcast presence now so the active-editors indicator - // clears on other clients. Delete the rate-limit entry first so the broadcast is not suppressed. - s.clearThrottleAndBroadcastPagePresence(pageID, userID, spaceID, page.ChannelId) + // The publish deleted the draft inside the store publish transaction (bypassing the app-level DeletePageDraft + // that normally broadcasts presence), so end the presence session now so the active-editors + // indicator clears on other clients. The page is live by definition at this point. + s.endDraftPresenceSession(true, pageID, userID, spaceID, page.ChannelId) return page, isNewPage, nil } -// buildPageForPublish normalises the draft body and constructs the *model.Page passed to -// store.PublishDraft. Props follow the same write intent as PagePatch.Props: a new page adopts the -// draft's props outright, while an edit replaces the live page's props only when the draft carries a -// non-empty map and preserves them otherwise. (A bare Draft.Props map cannot express "clear to empty" -// distinctly from "unset", so an empty draft map means preserve, consistent with how Title/Body are -// carried below.) No client sets page props through drafts today; this keeps the publish path ready -// for when one does. The caller detects a baseline-only edit (every field empty) after the build. -func (s *Service) buildPageForPublish(isNewPage bool, pageID, spaceID, userID string, draft *model.Draft, force bool) (*model.Page, *mmmodel.AppError) { +// derivePublishTarget classifies a publish target from the GetPageWithDeleted read: no page means +// a new-page publish, a live page in the caller's space means an edit-publish. A page in another +// space reports 404 rather than confirming the id exists elsewhere. A deleted page reports 409: +// the draft outlived its page and cannot be published. +func derivePublishTarget(existing *model.Page, existingErr *mmmodel.AppError, spaceID string) (isNewPage bool, appErr *mmmodel.AppError) { + // The cross-space and deleted cases below are reachable only when the page moved or was deleted + // between the draft read and this classification — the draft read's liveness filter excludes + // drafts in either steady state. + switch { + case existingErr != nil && existingErr.StatusCode == http.StatusNotFound: + return true, nil + case existingErr != nil: + return false, existingErr + case existing == nil: + // GetPageWithDeleted never returns (nil, nil) today; classify it like not-found rather + // than dereferencing nil, matching the guard adoptPublishRaceWinner applies to the same read. + return true, nil + case existing.SpaceId != spaceID: + // GetPageWithDeleted is not space-scoped and the caller is only authorized for spaceID, so + // collapse to the same 404 the space-scoped reads return. + return false, mmmodel.NewAppError("PublishPageDraft", "app.page_draft.publish.page_not_found.app_error", + nil, "", http.StatusNotFound) + case existing.DeleteAt != 0: + return false, mmmodel.NewAppError("PublishPageDraft", "app.page_draft.publish.page_deleted.app_error", + nil, "", http.StatusConflict) + default: // live page in this space → edit path + return false, nil + } +} + +// buildNewPageForPublish normalises the draft body and constructs the *model.Page inserted by +// store.PublishNewPageDraft. The page adopts the draft's title, body, and props outright. +func (s *Service) buildNewPageForPublish(pageID, spaceID, userID string, draft *model.Draft) (*model.Page, *mmmodel.AppError) { body, searchText, contentErr := normalizePageContent("PublishPageDraft", draft.Body) if contentErr != nil { return nil, contentErr } - - if isNewPage { - title, titleErr := validateTitle("PublishPageDraft", draft.Title, model.PageTitleMaxRunes) - if titleErr != nil { - return nil, titleErr - } - // ChannelId is derived by the store from the space, matching CreatePage, so it is - // intentionally left unset here. - return &model.Page{ - Id: pageID, - SpaceId: spaceID, - ParentId: draft.ParentId, - Title: title, - Body: body, - SearchText: searchText, - Props: maps.Clone(draft.Props), - UserId: userID, - LastModifiedBy: userID, - }, nil - } - - // Edit path: require an optimistic-lock baseline unless force, so a client that never - // captured the page's EditAt cannot silently overwrite a concurrent edit. - baseEditAt := draft.BaseEditAt - haveBaseline := baseEditAt != 0 - if !force && !haveBaseline { - return nil, mmmodel.NewAppError("PublishPageDraft", "app.page_draft.publish.baseline_required.app_error", - nil, "", http.StatusBadRequest) + title, titleErr := validateTitle("PublishPageDraft", draft.Title, model.PageTitleMaxRunes) + if titleErr != nil { + return nil, titleErr } - // Build pageForWrite with only the fields this draft changed; leave every other field at its - // zero value. The store treats a zero/empty field as "keep the live page's current value" and - // writes just the non-empty ones. Omitted fields are deliberately NOT copied from the pre-lock - // `existing` snapshot: that snapshot can be stale, so on a force-publish copying it back would - // overwrite a field this draft never touched and revert a concurrent edit to it. - // Body uses "" as its unset marker — a document the user cleared is stored as EmptyTipTapJSON, - // never "" — so treating an empty ("") body as unset preserves the live content instead of wiping it. - pageForWrite := &model.Page{ + // ChannelId is derived by the store from the space, matching CreatePage, so it is + // intentionally left unset here. + return &model.Page{ Id: pageID, SpaceId: spaceID, + ParentId: draft.ParentId, + Title: title, + Body: body, + SearchText: searchText, + Props: maps.Clone(draft.Props), + UserId: userID, LastModifiedBy: userID, + }, nil +} + +// buildEditPatchForPublish normalises the draft body and translates the draft into the +// *model.PagePatch applied by store.PublishPageEditDraft, carrying only the fields this draft +// changed. A Draft has no per-field presence markers, so empty is its unset marker: an omitted +// field stays nil in the patch and keeps the live page's current value, which means a partial +// draft never wipes an untouched field and a force-publish cannot revert a concurrent edit to a +// field this draft did not change. Body's "" reading is safe because a document the user cleared +// is stored as EmptyTipTapJSON, never "". Props follow the same rule: a bare map cannot express +// "clear to empty" distinctly from "unset", so an empty draft map means preserve. +// The caller detects a baseline-only draft (every patch field nil) after the build. +func (s *Service) buildEditPatchForPublish(draft *model.Draft, force bool) (*model.PagePatch, *mmmodel.AppError) { + body, searchText, contentErr := normalizePageContent("PublishPageDraft", draft.Body) + if contentErr != nil { + return nil, contentErr + } + // Require an optimistic-lock baseline unless force, so a client that never captured the + // page's EditAt cannot silently overwrite a concurrent edit. + if !force && draft.BaseEditAt == 0 { + return nil, mmmodel.NewAppError("PublishPageDraft", "app.page_draft.publish.baseline_required.app_error", + nil, "", http.StatusBadRequest) } + patch := &model.PagePatch{} if draft.Title != "" { title, titleErr := validateTitle("PublishPageDraft", draft.Title, model.PageTitleMaxRunes) if titleErr != nil { return nil, titleErr } - pageForWrite.Title = title + patch.Title = &title } if draft.Body != "" { - pageForWrite.Body = body - pageForWrite.SearchText = searchText + patch.Body = &body + patch.SearchText = &searchText } if len(draft.Props) > 0 { - // No clone here: the store's edit path merges via model.Page.Patch, which clones Props - // itself. (The new-page path above clones because it inserts pageForWrite directly.) - pageForWrite.Props = draft.Props + // No clone here: the store merges via model.Page.Patch, which clones Props itself. + patch.Props = &draft.Props } - if haveBaseline { - pageForWrite.EditAt = baseEditAt - } - return pageForWrite, nil + return patch, nil } // discardBaselineOnlyDraft handles an edit-path publish whose draft carries an optimistic-lock @@ -613,11 +570,24 @@ func (s *Service) discardBaselineOnlyDraft(userID, pageID, spaceID string, draft return nil, false, mmmodel.NewAppError("PublishPageDraft", "app.page_draft.publish.draft_changed.app_error", nil, "", http.StatusConflict) } - current, getErr := s.GetPage(pageID) + current, getErr := s.GetPageInSpace("PublishPageDraft", pageID, spaceID, false) if getErr != nil { + // The draft is already discarded, so this publish's mutation succeeded; only the re-read + // failed. Mirror the edit-conflict branch: log and translate rather than forwarding the + // re-read error as a publish failure. Clear the rate-limit entry so a later session starts + // unthrottled — with no readable page there is no channel to broadcast the session end to. + s.log.Warn("failed to re-read page after discarding baseline-only draft", + "page_id", pageID, "user_id", userID, "err", getErr) + s.clearPresenceThrottle(pageID, userID) + if getErr.StatusCode == http.StatusNotFound { + // A concurrent delete or cross-space move made the page unreadable: the draft outlived + // its page, which is the defined page_deleted conflict. + return nil, false, mmmodel.NewAppError("PublishPageDraft", "app.page_draft.publish.page_deleted.app_error", + nil, "", http.StatusConflict).Wrap(getErr) + } return nil, false, getErr } - s.clearThrottleAndBroadcastPagePresence(pageID, userID, spaceID, current.ChannelId) + s.endDraftPresenceSession(true, pageID, userID, spaceID, current.ChannelId) return current, false, nil } @@ -640,9 +610,9 @@ func (s *Service) adoptPublishRaceWinner(userID, pageID, spaceID string, draftUp s.log.Warn("failed to delete orphaned draft after adopting race winner", "page_id", pageID, "user_id", userID, "err", delErr) } - // The draft is consumed; clear the rate-limit entry and broadcast presence so - // the active-editors indicator drops this user, matching the non-conflict path. - s.clearThrottleAndBroadcastPagePresence(pageID, userID, spaceID, raced.ChannelId) + // The draft is consumed; end the presence session so the active-editors indicator drops + // this user, matching the non-conflict path. The adopted winner is live by definition. + s.endDraftPresenceSession(true, pageID, userID, spaceID, raced.ChannelId) return raced, true } if rErr != nil { diff --git a/server/app/page_draft_internal_test.go b/server/app/page_draft_internal_test.go new file mode 100644 index 0000000..64acf27 --- /dev/null +++ b/server/app/page_draft_internal_test.go @@ -0,0 +1,126 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package app + +import ( + "net/http" + "testing" + + mmmodel "github.com/mattermost/mattermost/server/public/model" + "github.com/stretchr/testify/require" + + "github.com/mattermost/mattermost-plugin-docs/server/internal/testutil" + "github.com/mattermost/mattermost-plugin-docs/server/model" + "github.com/mattermost/mattermost-plugin-docs/server/store" +) + +// TestAdoptPublishRaceWinner exercises the PK-collision adoption path directly: PublishPageDraft +// reaches it only when a concurrent publish inserts the page between the target classification and +// the store insert, a window that cannot be held open through the public service surface. +func TestAdoptPublishRaceWinner(t *testing.T) { + s, _ := testutil.OpenTestStore(t) + svc := New(s, nil, nil) + space := testutil.MustCreateSpace(t, s, mmmodel.NewId(), mmmodel.NewId()) + userID := mmmodel.NewId() + + // newDraftAt reserves pageID as the user's new-page draft, mirroring CreateSpaceDraft. + newDraftAt := func(t *testing.T, pageID string) *model.Draft { + t.Helper() + d, _, err := s.UpsertDraft(&model.Draft{UserId: userID, SpaceId: space.Id, PageId: pageID, Title: "Doc"}, nil, nil, nil) + require.NoError(t, err) + return d + } + + // winnerAt commits a live page at pageID, simulating the concurrent publish that won the id. + winnerAt := func(t *testing.T, pageID string) *model.Page { + t.Helper() + winner := testutil.NewPage(space.Id, space.ChannelId, userID, "") + winner.Id = pageID + created, err := s.CreatePage(winner, testutil.UncappedMaxDepth) + require.NoError(t, err) + return created + } + + t.Run("live winner in the same space is adopted and the orphaned draft is consumed", func(t *testing.T) { + pageID := mmmodel.NewId() + draft := newDraftAt(t, pageID) + created := winnerAt(t, pageID) + + raced, adopted := svc.adoptPublishRaceWinner(userID, pageID, space.Id, draft.UpdateAt) + require.True(t, adopted) + require.Equal(t, created.Id, raced.Id) + + _, err := s.GetDraft(userID, pageID) + require.True(t, store.IsErrNotFound(err), "the orphaned draft must be deleted, got %v", err) + }) + + t.Run("deleted winner is not adopted", func(t *testing.T) { + pageID := mmmodel.NewId() + draft := newDraftAt(t, pageID) + winnerAt(t, pageID) + _, err := s.DeletePage(pageID, space.Id, userID) + require.NoError(t, err) + + raced, adopted := svc.adoptPublishRaceWinner(userID, pageID, space.Id, draft.UpdateAt) + require.False(t, adopted, "a deleted page is not a publishable winner") + require.Nil(t, raced) + }) + + t.Run("winner in another space is not adopted", func(t *testing.T) { + pageID := mmmodel.NewId() + draft := newDraftAt(t, pageID) + winnerAt(t, pageID) + + raced, adopted := svc.adoptPublishRaceWinner(userID, pageID, mmmodel.NewId(), draft.UpdateAt) + require.False(t, adopted, "a winner outside the caller's space is not adoptable") + require.Nil(t, raced) + + _, err := s.GetDraft(userID, pageID) + require.NoError(t, err, "a non-adopted draft must be left in place") + }) +} + +// TestDerivePublishTarget covers the publish-target classification directly: the cross-space and +// deleted branches guard races (the page moved or was deleted between the draft read and the +// classification) that cannot be constructed through the public service surface — the draft +// read's liveness filter excludes both steady states. +func TestDerivePublishTarget(t *testing.T) { + spaceID := mmmodel.NewId() + + t.Run("missing page is a new-page publish", func(t *testing.T) { + notFound := mmmodel.NewAppError("GetPageWithDeleted", "app.page.get.not_found.app_error", nil, "", http.StatusNotFound) + isNewPage, appErr := derivePublishTarget(nil, notFound, spaceID) + require.Nil(t, appErr) + require.True(t, isNewPage) + }) + + t.Run("read failure is passed through", func(t *testing.T) { + readErr := mmmodel.NewAppError("GetPageWithDeleted", "app.store.not_found.app_error", nil, "", http.StatusInternalServerError) + isNewPage, appErr := derivePublishTarget(nil, readErr, spaceID) + require.False(t, isNewPage) + require.Equal(t, readErr, appErr) + }) + + t.Run("page in another space reports not-found", func(t *testing.T) { + isNewPage, appErr := derivePublishTarget(&model.Page{SpaceId: mmmodel.NewId()}, nil, spaceID) + require.False(t, isNewPage) + require.NotNil(t, appErr) + require.Equal(t, http.StatusNotFound, appErr.StatusCode) + require.Equal(t, "app.page_draft.publish.page_not_found.app_error", appErr.Id) + }) + + t.Run("deleted page conflicts", func(t *testing.T) { + isNewPage, appErr := derivePublishTarget(&model.Page{SpaceId: spaceID, DeleteAt: 1}, nil, spaceID) + require.False(t, isNewPage) + require.NotNil(t, appErr) + require.Equal(t, http.StatusConflict, appErr.StatusCode) + require.Equal(t, "app.page_draft.publish.page_deleted.app_error", appErr.Id) + }) + + t.Run("live page in the caller's space is an edit publish", func(t *testing.T) { + isNewPage, appErr := derivePublishTarget(&model.Page{SpaceId: spaceID}, nil, spaceID) + require.Nil(t, appErr) + require.False(t, isNewPage) + }) +} diff --git a/server/app/page_draft_test.go b/server/app/page_draft_test.go index 570097f..8115b48 100644 --- a/server/app/page_draft_test.go +++ b/server/app/page_draft_test.go @@ -7,6 +7,7 @@ import ( "fmt" "net/http" "strings" + "sync" "testing" "github.com/stretchr/testify/mock" @@ -17,7 +18,6 @@ import ( "github.com/mattermost/mattermost-plugin-docs/server/app" "github.com/mattermost/mattermost-plugin-docs/server/model" - "github.com/mattermost/mattermost-plugin-docs/server/store" ) // docWith returns a minimal TipTap document whose paragraph contains text. @@ -252,6 +252,77 @@ func TestDeletePageDraftRejectsWrongSpace(t *testing.T) { require.Equal(t, draft.PageId, got.PageId) } +// TestPublishPageDraftConcurrentPublishesConverge races two publishes of the same new-page draft +// (a double-clicked publish). The winner is not fixed, so the assertions are outcome invariants: +// exactly one publish creates the page; the other either adopts the winner (success without +// wasCreated — see adoptPublishRaceWinner) or reports a benign already-published outcome (404 +// draft gone / 409 conflict). Either way the page ends up live and the draft consumed. +func TestPublishPageDraftConcurrentPublishesConverge(t *testing.T) { + h := openTestService(t) + space := mustCreateSpace(t, h.store, mmmodel.NewId()) + userID := mmmodel.NewId() + + draft, appErr := h.svc.CreateSpaceDraft(userID, space.Id, "Doc", "") + require.Nil(t, appErr) + _, appErr = h.svc.UpdatePageDraft(&model.Draft{UserId: userID, SpaceId: space.Id, PageId: draft.PageId, Title: "Doc", Body: docWith("race")}, nil, nil, nil, "") + require.Nil(t, appErr) + + type result struct { + page *model.Page + created bool + appErr *mmmodel.AppError + } + results := make([]result, 2) + start := make(chan struct{}) + var wg sync.WaitGroup + for i := range results { + wg.Go(func() { + <-start + p, created, pubErr := h.svc.PublishPageDraft(userID, space.Id, draft.PageId, false) + results[i] = result{page: p, created: created, appErr: pubErr} + }) + } + close(start) + wg.Wait() + + var created, benign int + for _, r := range results { + switch { + case r.appErr == nil && r.created: + created++ + require.Equal(t, draft.PageId, r.page.Id) + case r.appErr == nil: + // Adopted the winner: same live page, without wasCreated. + require.Equal(t, draft.PageId, r.page.Id) + default: + require.Contains(t, []int{http.StatusNotFound, http.StatusConflict}, r.appErr.StatusCode, + "the losing publish may only fail benignly, got %v", r.appErr) + benign++ + } + } + require.Equal(t, 1, created, "exactly one concurrent publish must create the page, got %+v", results) + require.LessOrEqual(t, benign, 1) + + // Converged state: the draft is consumed and the page is live in the space. + _, appErr = h.svc.GetPageDraft(userID, space.Id, draft.PageId) + require.NotNil(t, appErr) + require.Equal(t, http.StatusNotFound, appErr.StatusCode, "the draft must be consumed by the publish") + snapshot, appErr := h.svc.GetPageActiveEditors(draft.PageId, space.Id) + require.Nil(t, appErr, "the published page must resolve in its space") + require.Empty(t, snapshot.ActiveEditors) +} + +// TestGetPageActiveEditorsUnknownPageReturns404 pins the plain not-found case: a page id with no +// row at all (as opposed to a page living in another space, covered below). +func TestGetPageActiveEditorsUnknownPageReturns404(t *testing.T) { + h := openTestService(t) + space := mustCreateSpace(t, h.store, mmmodel.NewId()) + + _, appErr := h.svc.GetPageActiveEditors(mmmodel.NewId(), space.Id) + require.NotNil(t, appErr) + require.Equal(t, http.StatusNotFound, appErr.StatusCode) +} + func TestGetPageActiveEditorsRejectsWrongSpace(t *testing.T) { h := openTestService(t) spaceA := mustCreateSpace(t, h.store, mmmodel.NewId()) @@ -873,7 +944,7 @@ func TestCreateSpaceDraftEnforcesQuota(t *testing.T) { // would be slow and the store enforcement path (inside UpsertDraft's transaction) is what // we're testing. now := mmmodel.GetMillis() - for i := range store.MaxDraftsPerUserPerSpace { + for i := range model.MaxDraftsPerUserPerSpace { pageID := mmmodel.NewId() title := fmt.Sprintf("draft-%d", i) _, err := h.db.Exec( @@ -888,3 +959,135 @@ func TestCreateSpaceDraftEnforcesQuota(t *testing.T) { require.NotNil(t, appErr) require.Equal(t, http.StatusTooManyRequests, appErr.StatusCode) } + +// TestGetPageDraftRejectsInvalidIDs exercises GetPageDraft's three input-validation branches at +// the service layer, independent of HTTP routing. +func TestGetPageDraftRejectsInvalidIDs(t *testing.T) { + h := openTestService(t) + space := mustCreateSpace(t, h.store, mmmodel.NewId()) + userID, pageID := mmmodel.NewId(), mmmodel.NewId() + + cases := []struct { + name string + userID, spaceID, pageID string + }{ + {"invalid user id", "not-valid", space.Id, pageID}, + {"invalid space id", userID, "not-valid", pageID}, + {"invalid page id", userID, space.Id, "not-valid"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, appErr := h.svc.GetPageDraft(tc.userID, tc.spaceID, tc.pageID) + require.NotNil(t, appErr) + require.Equal(t, http.StatusBadRequest, appErr.StatusCode) + }) + } +} + +// TestGetPageDraftsForSpaceRejectsInvalidIDs exercises the list endpoint's service-level input +// validation, which handler tests reach only through valid routes. +func TestGetPageDraftsForSpaceRejectsInvalidIDs(t *testing.T) { + h := openTestService(t) + space := mustCreateSpace(t, h.store, mmmodel.NewId()) + + _, _, appErr := h.svc.GetPageDraftsForSpace("not-valid", space.Id, 0, 10) + require.NotNil(t, appErr) + require.Equal(t, http.StatusBadRequest, appErr.StatusCode) + + _, _, appErr = h.svc.GetPageDraftsForSpace(mmmodel.NewId(), "not-valid", 0, 10) + require.NotNil(t, appErr) + require.Equal(t, http.StatusBadRequest, appErr.StatusCode) +} + +func TestGetPageActiveEditorsRejectsInvalidSpaceID(t *testing.T) { + h := openTestService(t) + + _, appErr := h.svc.GetPageActiveEditors(mmmodel.NewId(), "not-valid") + require.NotNil(t, appErr) + require.Equal(t, http.StatusBadRequest, appErr.StatusCode) +} + +// TestPublishForceAppliesDraftFieldOverConcurrentEdit covers the force-publish interaction where +// the concurrent edit touched BOTH a field the draft changed and one it did not: the draft's +// value must win for the field it carries, while the concurrent value survives for the field the +// draft left unset. +func TestPublishForceAppliesDraftFieldOverConcurrentEdit(t *testing.T) { + h := openTestService(t) + space := mustCreateSpace(t, h.store, mmmodel.NewId()) + userID := mmmodel.NewId() + + page := publishNewPage(t, h, space.Id, userID, "Original title", "original body") + baseEditAt := page.EditAt + + // A title-only edit draft baselined at the current EditAt. + _, appErr := h.svc.UpdatePageDraft(&model.Draft{ + UserId: userID, SpaceId: space.Id, PageId: page.Id, Title: "Draft title", + BaseEditAt: baseEditAt, + }, nil, nil, nil, "") + require.Nil(t, appErr) + + // A concurrent edit changes the title AND the body, advancing EditAt past the baseline. + concurrentTitle := "Concurrent title" + concurrentBody := docWith("concurrent body") + _, appErr = h.svc.UpdatePage(page.Id, space.Id, + &model.PagePatch{Title: &concurrentTitle, Body: &concurrentBody}, new(baseEditAt), false, userID) + require.Nil(t, appErr) + + forced, _, appErr := h.svc.PublishPageDraft(userID, space.Id, page.Id, true) + require.Nil(t, appErr) + require.Equal(t, "Draft title", forced.Title, "the field the draft carries must win under force") + require.Contains(t, forced.Body, "concurrent body", "a field the draft never set must keep the concurrent value") +} + +// TestUpdatePageDraftRejectsOversizedFileIds covers the inline fileIDs size guard: fileIDs travels +// to the store as a separate write-intent pointer, so Draft.IsValid never sees it and this guard +// is the only bound on the write path. +func TestUpdatePageDraftRejectsOversizedFileIds(t *testing.T) { + h := openTestService(t) + space := mustCreateSpace(t, h.store, mmmodel.NewId()) + userID := mmmodel.NewId() + + // 12 valid ids serialize to ~349 runes, over the DraftFileIdsMaxRunes=300 cap. + ids := make(mmmodel.StringArray, 0, 12) + for range 12 { + ids = append(ids, mmmodel.NewId()) + } + + _, appErr := h.svc.UpdatePageDraft(&model.Draft{UserId: userID, SpaceId: space.Id, PageId: mmmodel.NewId(), Title: "x"}, nil, &ids, nil, "") + require.NotNil(t, appErr) + require.Equal(t, http.StatusBadRequest, appErr.StatusCode) + require.Equal(t, "model.draft.is_valid.file_ids.app_error", appErr.Id) +} + +// TestUpdatePageDraftRejectsOversizedProps covers the inline props size guard: props travels to the +// store as a separate write-intent pointer, so Draft.IsValid checks the (empty) draft.Props field +// and this guard is the only bound on the written value. +func TestUpdatePageDraftRejectsOversizedProps(t *testing.T) { + h := openTestService(t) + space := mustCreateSpace(t, h.store, mmmodel.NewId()) + userID := mmmodel.NewId() + + props := mmmodel.StringInterface{"k": strings.Repeat("x", model.PagePropsMaxBytes)} + _, appErr := h.svc.UpdatePageDraft(&model.Draft{UserId: userID, SpaceId: space.Id, PageId: mmmodel.NewId(), Title: "x"}, nil, nil, &props, "") + require.NotNil(t, appErr) + require.Equal(t, http.StatusBadRequest, appErr.StatusCode) + require.Equal(t, "model.shared.props_too_large.app_error", appErr.Id) +} + +// TestUpdatePageDraftRejectsInvalidFileId covers the per-entry id check on the fileIDs write +// intent, including the empty entry an empty-slice clear must not admit. +func TestUpdatePageDraftRejectsInvalidFileId(t *testing.T) { + h := openTestService(t) + space := mustCreateSpace(t, h.store, mmmodel.NewId()) + userID := mmmodel.NewId() + + for name, entry := range map[string]string{"malformed id": "not-a-valid-id", "empty entry": ""} { + t.Run(name, func(t *testing.T) { + ids := mmmodel.StringArray{entry} + _, appErr := h.svc.UpdatePageDraft(&model.Draft{UserId: userID, SpaceId: space.Id, PageId: mmmodel.NewId(), Title: "x"}, nil, &ids, nil, "") + require.NotNil(t, appErr) + require.Equal(t, http.StatusBadRequest, appErr.StatusCode) + require.Equal(t, "app.page_draft.update.invalid_file_id.app_error", appErr.Id) + }) + } +} diff --git a/server/app/page_presence.go b/server/app/page_presence.go index 44c3837..32422a7 100644 --- a/server/app/page_presence.go +++ b/server/app/page_presence.go @@ -28,6 +28,17 @@ func activeEditorSince() int64 { return mmmodel.GetMillis() - ActiveEditorTimeoutMs } +func presenceBroadcastKey(pageID, userID string) string { + return pageID + ":" + userID +} + +// clearPresenceThrottle removes the (pageID, userID) broadcast rate-limit entry, so the next +// broadcast for that editor is not suppressed and a later session starts unthrottled. Called +// whenever a draft session ends. +func (s *Service) clearPresenceThrottle(pageID, userID string) { + s.presenceBroadcastTimes.Delete(presenceBroadcastKey(pageID, userID)) +} + // sweepPresenceBroadcastTimes removes stale entries from the broadcast rate-limit map to bound its // size. An entry is normally removed when its session ends (discard or publish); this sweep is the // fallback for sessions abandoned without either. @@ -85,22 +96,24 @@ func (s *Service) publishSelfPresence(userID, pageID, spaceID string, editors [] // broadcastPagePresence fans a page_presence_updated event out to the space audience on channelID // (the space's backing channel), carrying the current active-editor set, snapshot_at, and active_timeout_ms. -// Best-effort: failures are swallowed. +// Best-effort: failures are logged, never surfaced. The return value reports whether a snapshot was +// actually published, so a throttling caller can release its claimed slot on failure; a nil client +// (store-only unit tests) is a deliberate no-op, not a failure. // // Broadcasts fire only on user actions (autosave, discard, publish), never periodically, so a client // that receives no newer snapshot cannot distinguish a still-active editor from one whose session // ended abnormally. active_timeout_ms lets it expire the snapshot's editors on its own once // snapshot_at + active_timeout_ms has passed. -func (s *Service) broadcastPagePresence(pageID, spaceID, channelID string) { +func (s *Service) broadcastPagePresence(pageID, spaceID, channelID string) bool { if s.client == nil { - return + return true } // Stamp snapshot_at before the editors query so it marks when the snapshot was taken, not when the // broadcast finished assembling — clients use it to discard out-of-order snapshots. snapshotAt := mmmodel.GetMillis() editors, ok := s.getActiveEditors(pageID, spaceID) if !ok { - return + return false } s.publishToChannels(wsEventPagePresenceUpdated, map[string]any{ "page_id": pageID, @@ -109,13 +122,54 @@ func (s *Service) broadcastPagePresence(pageID, spaceID, channelID string) { "snapshot_at": snapshotAt, "active_timeout_ms": ActiveEditorTimeoutMs, }, channelID) + return true } -// clearThrottleAndBroadcastPagePresence clears the rate-limit entry for (pageID, userID) so the -// following broadcast is not suppressed, then broadcasts channel-wide. Used whenever a draft session -// ends (discard, publish, race-loss cleanup) and the active-editors indicator must drop this user. -func (s *Service) clearThrottleAndBroadcastPagePresence(pageID, userID, spaceID, channelID string) { - s.presenceBroadcastTimes.Delete(presenceBroadcastKey(pageID, userID)) +// maybeBroadcastDraftPresence performs the rate-limited presence broadcast that follows a +// successful autosave. The rate-limit bucket is keyed per (page, user) so each editor gets an +// independent limit — one editor's broadcast can't rate-limit another editor on the same page — +// and it covers both audiences below, so autosave cadence cannot flood either. +// +// The claimed slot is released again when the channel broadcast fails (a store error while +// assembling the snapshot), so the next autosave retries immediately instead of waiting out the +// full throttle window on an attempt that published nothing. +func (s *Service) maybeBroadcastDraftPresence(pageWasLive bool, pageID, userID, spaceID, channelID string) { + presenceKey := presenceBroadcastKey(pageID, userID) + now := mmmodel.GetMillis() + s.sweepPresenceBroadcastTimes(now) + existing, loaded := s.presenceBroadcastTimes.LoadOrStore(presenceKey, now) + if loaded { + lastTime, ok := existing.(int64) + if !ok || now-lastTime < presenceBroadcastMinIntervalMs { + return + } + if !s.presenceBroadcastTimes.CompareAndSwap(presenceKey, existing, now) { + return + } + } + // New-page drafts (no published page row yet) must not broadcast presence to the space channel: + // that would expose the reserved page ID and the author's identity to all space members before + // the page exists. Send the event only to the author so their own UI can track the session. + if !pageWasLive { + s.publishSelfPresence(userID, pageID, spaceID, []string{userID}) + return + } + if !s.broadcastPagePresence(pageID, spaceID, channelID) { + s.presenceBroadcastTimes.CompareAndDelete(presenceKey, now) + } +} + +// endDraftPresenceSession clears the (pageID, userID) rate-limit entry — so the announcement below +// is never suppressed and a later session starts unthrottled — and announces the end of a draft +// session: channel-wide when the page is live, to the author alone otherwise (an unpublished +// new-page draft's session was never visible to the channel, so it ends the same way it was +// announced, with an empty editor set). +func (s *Service) endDraftPresenceSession(pageWasLive bool, pageID, userID, spaceID, channelID string) { + s.clearPresenceThrottle(pageID, userID) + if !pageWasLive { + s.publishSelfPresence(userID, pageID, spaceID, []string{}) + return + } s.broadcastPagePresence(pageID, spaceID, channelID) } @@ -130,8 +184,8 @@ type PageActiveEditors struct { // GetPageActiveEditors returns the editor-presence snapshot for the given page in the given space, // after confirming the page exists in that space. Returns 404 if the page is unknown or belongs to -// another space, and 500 on a store failure (unlike the best-effort getActiveEditors, this backs a -// REST read that must not report "nobody editing" when the query actually failed). +// another space; store failures are propagated (unlike the best-effort getActiveEditors, this backs +// a REST read that must not report "nobody editing" when the query actually failed). func (s *Service) GetPageActiveEditors(pageID, spaceID string) (*PageActiveEditors, *mmmodel.AppError) { if !mmmodel.IsValidId(pageID) { return nil, mmmodel.NewAppError("GetPageActiveEditors", "app.page.presence.invalid_page_id.app_error", nil, "", http.StatusBadRequest) diff --git a/server/app/service_test.go b/server/app/service_test.go index cb2e60b..977ed02 100644 --- a/server/app/service_test.go +++ b/server/app/service_test.go @@ -540,7 +540,7 @@ func TestServiceUpdatePageOversizedBody(t *testing.T) { _, err := h.svc.UpdatePage(created.Id, created.SpaceId, &model.PagePatch{Body: mmmodel.NewPointer(oversized), SearchText: mmmodel.NewPointer("")}, new(created.EditAt), false, mmmodel.NewId()) require.NotNil(t, err) require.Equal(t, http.StatusBadRequest, err.StatusCode) - require.Equal(t, "model.page.is_valid.body.app_error", err.Id) + require.Equal(t, "app.page.invalid_content.app_error", err.Id) } // TestServiceUpdatePageOversizedSearchTextIgnored verifies a caller-supplied oversized SearchText @@ -570,7 +570,7 @@ func TestServiceCreatePageOversizedBody(t *testing.T) { _, err := h.svc.CreatePage(space.Id, "", "Title", oversized, mmmodel.NewId()) require.NotNil(t, err) require.Equal(t, http.StatusBadRequest, err.StatusCode) - require.Equal(t, "model.page.is_valid.body.app_error", err.Id) + require.Equal(t, "app.page.invalid_content.app_error", err.Id) } // TestServiceUpdatePageCanSetEmptyBody verifies the patch contract: a non-nil empty Body diff --git a/server/app/ws_events.go b/server/app/ws_events.go index e9569a4..62b0f8a 100644 --- a/server/app/ws_events.go +++ b/server/app/ws_events.go @@ -34,10 +34,10 @@ const ( wsEventPageMoved = "page_moved" wsEventPageDuplicated = "page_duplicated" wsEventPageMovedToSpace = "page_moved_to_space" - // Unlike the other page_* events above — which carry only {page_id, space_id} as a - // "something changed, refetch" signal — wsEventPagePresenceUpdated carries the full presence - // snapshot inline ({page_id, space_id, active_editors, snapshot_at, active_timeout_ms}), so clients - // need no follow-up fetch. It is rate-limited on autosave but always fires on discard and publish. + // Unlike the page_* events above — refetch signals carrying only the ids the client needs to + // refetch — wsEventPagePresenceUpdated carries the full presence snapshot inline + // ({page_id, space_id, active_editors, snapshot_at, active_timeout_ms}), so clients need no + // follow-up fetch. wsEventPagePresenceUpdated = "page_presence_updated" wsEventSpaceCreated = "space_created" diff --git a/server/app/ws_events_test.go b/server/app/ws_events_test.go index e5447bf..262a4b9 100644 --- a/server/app/ws_events_test.go +++ b/server/app/ws_events_test.go @@ -208,6 +208,43 @@ func TestServiceUpdatePageDraft_PublishesPresenceEvent(t *testing.T) { &mmmodel.WebsocketBroadcast{ChannelId: channelID}) } +// TestServiceUpdatePageDraft_PresenceBroadcastThrottled pins the autosave presence rate limit +// end-to-end: repeated autosaves for the same (page, user) inside presenceBroadcastMinIntervalMs +// broadcast page_presence_updated exactly once — the first autosave claims the throttle slot and +// the rest are suppressed. (Discard and publish bypass the throttle; they are pinned elsewhere.) +func TestServiceUpdatePageDraft_PresenceBroadcastThrottled(t *testing.T) { + mockAPI := &plugintest.API{} + h := openTestServiceWithAPI(t, mockAPI) + + channelID := mmmodel.NewId() + userID := mmmodel.NewId() + space := mustCreateSpace(t, h.store, channelID) + page := publishNewPage(t, h, space.Id, userID, "Doc", "v1") + + presenceBroadcasts := func() int { + n := 0 + for _, c := range mockAPI.Calls { + if c.Method == "PublishWebSocketEvent" && len(c.Arguments) > 0 && c.Arguments[0] == "page_presence_updated" { + n++ + } + } + return n + } + + // publishNewPage ends the draft session (broadcasting presence and clearing the throttle), so + // count deltas from here rather than absolute totals. + before := presenceBroadcasts() + for range 3 { + _, appErr := h.svc.UpdatePageDraft(&model.Draft{ + UserId: userID, SpaceId: space.Id, PageId: page.Id, Title: "Doc", + BaseEditAt: page.EditAt, + }, nil, nil, nil, channelID) + require.Nil(t, appErr) + } + require.Equal(t, before+1, presenceBroadcasts(), + "autosaves within the throttle window must broadcast presence exactly once") +} + // TestServicePublishPageDraft_PublishesCreatedEvent pins that publishing a brand-new page's draft // reuses page_created (not a draft-specific event): {page_id, space_id, parent_id} payload, // broadcast to the new page's backing channel. Also pins the accompanying presence-clear broadcast. @@ -225,7 +262,7 @@ func TestServicePublishPageDraft_PublishesCreatedEvent(t *testing.T) { map[string]any{"page_id": page.Id, "space_id": space.Id, "parent_id": page.ParentId}, &mmmodel.WebsocketBroadcast{ChannelId: channelID}) - // PublishDraft bypasses DeletePageDraft, so PublishPageDraft broadcasts presence directly. + // The store publish transaction deletes the draft itself, bypassing DeletePageDraft, so PublishPageDraft broadcasts presence directly. mockAPI.AssertCalled(t, "PublishWebSocketEvent", "page_presence_updated", mock.MatchedBy(func(payload map[string]any) bool { editors, ok := payload["active_editors"].([]string) @@ -265,7 +302,7 @@ func TestServicePublishPageDraft_PublishesUpdatedEvent(t *testing.T) { map[string]any{"page_id": republished.Id, "space_id": space.Id}, &mmmodel.WebsocketBroadcast{ChannelId: channelID}) - // PublishDraft bypasses DeletePageDraft, so PublishPageDraft broadcasts presence directly. + // The store publish transaction deletes the draft itself, bypassing DeletePageDraft, so PublishPageDraft broadcasts presence directly. mockAPI.AssertCalled(t, "PublishWebSocketEvent", "page_presence_updated", mock.MatchedBy(func(payload map[string]any) bool { editors, ok := payload["active_editors"].([]string) diff --git a/server/model/draft.go b/server/model/draft.go index 34aa556..03c12b1 100644 --- a/server/model/draft.go +++ b/server/model/draft.go @@ -14,6 +14,10 @@ import ( // DraftFileIdsMaxRunes is the maximum rune length of the serialized FileIds JSON array. const DraftFileIdsMaxRunes = 300 +// MaxDraftsPerUserPerSpace is the maximum number of draft rows a single user may hold in one +// space. +const MaxDraftsPerUserPerSpace = 100 + // Draft is a per-user autosave draft for a space page, stored in DOCS_Draft. // // A draft is keyed by (UserId, PageId): PageId is the page id reserved when the @@ -49,19 +53,19 @@ type Draft struct { } // DraftSummary is the metadata projection returned by draft collection endpoints. It deliberately -// omits Body, which can be up to PageBodyMaxBytes per draft. Fetch a Draft by page id when the -// content is required. +// omits Body and Props: Body can be up to PageBodyMaxBytes per draft, and Props is opaque and may +// be up to PagePropsMaxBytes — matching the fields PageSummary omits for the same reason. Fetch a +// Draft by page id when the content is required. type DraftSummary struct { - UserId string `json:"user_id"` - SpaceId string `json:"space_id"` - PageId string `json:"page_id"` - ParentId string `json:"parent_id"` - Title string `json:"title"` - FileIds mmmodel.StringArray `json:"file_ids"` - Props mmmodel.StringInterface `json:"props"` - CreateAt int64 `json:"create_at"` - UpdateAt int64 `json:"update_at"` - LastActiveAt int64 `json:"last_active_at"` + UserId string `json:"user_id"` + SpaceId string `json:"space_id"` + PageId string `json:"page_id"` + ParentId string `json:"parent_id"` + Title string `json:"title"` + FileIds mmmodel.StringArray `json:"file_ids"` + CreateAt int64 `json:"create_at"` + UpdateAt int64 `json:"update_at"` + LastActiveAt int64 `json:"last_active_at"` } // PreSave sanitizes Draft and defaults its Id-independent fields before insert. @@ -131,6 +135,10 @@ func (d *Draft) IsValid() *mmmodel.AppError { return mmmodel.NewAppError("Draft.IsValid", "model.draft.is_valid.update_at.app_error", nil, "page_id="+d.PageId, http.StatusBadRequest) } + // Write-path contract only: PreSave always stamps LastActiveAt, so a zero here means the + // caller skipped PreSave. Stored rows can still legitimately hold 0 — bulk maintenance writes + // (cross-space move) reset LastActiveAt in SQL to drop the owner from presence — so a read + // path must not treat a stored 0 as invalid. if d.LastActiveAt == 0 { return mmmodel.NewAppError("Draft.IsValid", "model.draft.is_valid.last_active_at.app_error", nil, "page_id="+d.PageId, http.StatusBadRequest) } diff --git a/server/model/page_content.go b/server/model/page_content.go index 8326b19..1135580 100644 --- a/server/model/page_content.go +++ b/server/model/page_content.go @@ -52,6 +52,13 @@ func ParseTipTapDocument(contentJSON string) (TipTapDocument, error) { }, nil } + // Reject over-limit content before parsing: json.Unmarshal materializes every node as a + // map[string]any at a large multiple of its encoded size, and the per-node budget inside the + // sanitize walk only applies after that allocation. + if len(contentJSON) > PageBodyMaxBytes { + return TipTapDocument{}, errors.New("content exceeds the maximum body size") + } + var doc TipTapDocument if err := json.Unmarshal([]byte(contentJSON), &doc); err != nil { return TipTapDocument{}, err @@ -124,9 +131,10 @@ func writeSearchTextPart(b *strings.Builder, part string) { // within a sane depth. const maxTipTapDepth = 100 -// maxTipTapNodes caps the total number of content nodes in a TipTap document. A 2 MiB JSON payload -// can contain hundreds of thousands of tiny nodes; unmarshaling them before sanitization causes -// significant allocation and CPU amplification. The plain-text path is capped at maxPlainTextParagraphs +// maxTipTapNodes caps the total number of content nodes in a TipTap document, bounding the +// sanitize walk and the stored node count. It applies after json.Unmarshal has materialized the +// document, so it does not bound the parse itself — the pre-parse body-size check at the top of +// ParseTipTapDocument does that. The plain-text path is capped at maxPlainTextParagraphs // (10 000 paragraphs → ~20 000 nodes, since each non-empty line is a paragraph plus a text child); // rich documents with ~5 inline nodes per paragraph stay well under 50 000 for any sane document. const maxTipTapNodes = 50_000 @@ -231,6 +239,13 @@ var dangerousAttrKeys = map[string]struct{}{ "data": {}, } +// trimBrowserIgnoredChars trims leading/trailing ASCII space and control characters (r <= ' ') — +// the characters an HTML tokenizer ignores around attribute names and a browser strips from the +// ends of a URL — so a sanitizer match cannot be defeated by padding with them. +func trimBrowserIgnoredChars(s string) string { + return strings.TrimFunc(s, func(r rune) bool { return r <= ' ' }) +} + // stripDangerousKeys strips script-bearing keys (event handlers plus the dangerousAttrKeys set) and // neutralizes dangerous URL schemes on any URL-valued key, at the top level of m only. Key names are // matched case-insensitively, since HTML attribute names are case-insensitive. @@ -240,7 +255,10 @@ var dangerousAttrKeys = map[string]struct{}{ // rather than nested under its "attrs". func stripDangerousKeys(m map[string]any) { for key, val := range m { - lower := strings.ToLower(key) + // Trim leading/trailing whitespace and control characters before matching: an HTML + // tokenizer treats "\tonclick" as the onclick attribute, so a key that fails to match + // here because of such a prefix would carry its payload through every check below. + lower := strings.ToLower(trimBrowserIgnoredChars(key)) if _, dangerous := dangerousAttrKeys[lower]; strings.HasPrefix(lower, "on") || dangerous { delete(m, key) continue @@ -510,7 +528,7 @@ func decodeURLScheme(url string) (scheme, lower string, hasScheme bool) { cleaned := urlStripChars.Replace(url) cleaned = html.UnescapeString(cleaned) cleaned = urlStripChars.Replace(cleaned) - cleaned = strings.TrimFunc(cleaned, func(r rune) bool { return r <= ' ' }) + cleaned = trimBrowserIgnoredChars(cleaned) lower = strings.ToLower(cleaned) scheme, hasScheme = urlScheme(lower) return scheme, lower, hasScheme diff --git a/server/model/page_content_test.go b/server/model/page_content_test.go index 89f48cf..5e9c55b 100644 --- a/server/model/page_content_test.go +++ b/server/model/page_content_test.go @@ -546,6 +546,53 @@ func TestParseTipTapDocumentRejectsTooDeep(t *testing.T) { require.Error(t, err, "content nested beyond the limit must be rejected") } +func TestParseTipTapDocumentNodeDepthBoundary(t *testing.T) { + // Pins the exact node-nesting cutoff, mirroring maxTipTapDepth in the sanitize walk + // (server/model/page_content.go); a drifted or off-by-one cap fails these loudly, unlike the + // far-past-the-limit rejection test above. + const depthLimit = 100 + + // nestedTo builds a document whose deepest node sits at the given depth (the top-level + // content node is depth 0). + nestedTo := func(depth int) string { + n := depth + 1 + return `{"type":"doc","content":` + strings.Repeat(`[{"type":"blockquote","content":`, n) + `[]` + strings.Repeat(`}]`, n) + `}` + } + + t.Run("nesting at the limit is accepted", func(t *testing.T) { + _, err := model.ParseTipTapDocument(nestedTo(depthLimit)) + require.NoError(t, err) + }) + + t.Run("nesting one past the limit is rejected", func(t *testing.T) { + _, err := model.ParseTipTapDocument(nestedTo(depthLimit + 1)) + require.Error(t, err) + }) +} + +func TestParseTipTapDocumentAttrsDepthBoundary(t *testing.T) { + // Pins the exact attrs-nesting cutoff: the attrs walk shares maxTipTapDepth with the node walk + // but counts depth independently, so it gets its own boundary pin. + const depthLimit = 100 + + // attrsNestedTo builds a node whose attrs contain a map chain whose deepest map sits at the + // given depth (the attrs object itself is depth 0). + attrsNestedTo := func(depth int) string { + return `{"type":"doc","content":[{"type":"paragraph","attrs":` + + strings.Repeat(`{"a":`, depth) + `{"b":1}` + strings.Repeat(`}`, depth) + `}]}` + } + + t.Run("attrs nesting at the limit is accepted", func(t *testing.T) { + _, err := model.ParseTipTapDocument(attrsNestedTo(depthLimit)) + require.NoError(t, err) + }) + + t.Run("attrs nesting one past the limit is rejected", func(t *testing.T) { + _, err := model.ParseTipTapDocument(attrsNestedTo(depthLimit + 1)) + require.Error(t, err) + }) +} + func TestParseTipTapDocumentRejectsOffSchemaNodeType(t *testing.T) { // A node type outside the allowlist is rejected outright, so a client node type the server does // not know about surfaces as a loud failure rather than passing through unrecognized. @@ -669,3 +716,36 @@ func TestBuildSearchText(t *testing.T) { require.Equal(t, "a b c", model.BuildSearchText(doc)) }) } + +func TestParseTipTapDocumentSanitizesWhitespacePrefixedAttrKeys(t *testing.T) { + // An HTML tokenizer treats "\tonclick" as the onclick attribute, so keys carrying leading or + // trailing whitespace/control characters must match the handler denylist and URL allowlist the + // same as their clean forms. + raw := map[string]any{ + "type": "doc", + "content": []any{ + map[string]any{ + "type": "image", + "attrs": map[string]any{ + " onclick": "alert(document.cookie)", + "\tonerror": "steal()", + " href": "javascript:alert(1)", + "alt": "a cat", + }, + }, + }, + } + b, err := json.Marshal(raw) + require.NoError(t, err) + doc, err := model.ParseTipTapDocument(string(b)) + require.NoError(t, err) + + var parsed map[string]any + require.NoError(t, json.Unmarshal([]byte(marshal(t, doc)), &parsed)) + attrs := parsed["content"].([]any)[0].(map[string]any)["attrs"].(map[string]any) + + require.NotContains(t, attrs, " onclick", "whitespace-prefixed event handler must be stripped") + require.NotContains(t, attrs, "\tonerror", "control-char-prefixed event handler must be stripped") + require.Equal(t, "", attrs[" href"], "whitespace-prefixed URL key must pass through sanitizeURL") + require.Equal(t, "a cat", attrs["alt"], "clean attr must survive") +} diff --git a/server/store/draft_store.go b/server/store/draft_store.go index 9278b2b..554b900 100644 --- a/server/store/draft_store.go +++ b/server/store/draft_store.go @@ -16,11 +16,6 @@ import ( "github.com/mattermost/mattermost-plugin-docs/server/model" ) -// MaxDraftsPerUserPerSpace is the maximum number of draft rows a single user may hold in one -// space. Enforced atomically inside UpsertDraft after the space lock, so it holds under -// concurrent creates. -const MaxDraftsPerUserPerSpace = 100 - // maxActiveEditorsPerPage caps the number of user IDs returned by GetPageActiveEditors. A page // with more simultaneous editors than this cap is pathological; the cap prevents unbounded result // sets if LastActiveAt filtering alone is insufficient. @@ -30,9 +25,10 @@ var draftSelectColumns = []string{ "UserId", "SpaceId", "PageId", "ParentId", "Title", "Body", "FileIds", "Props", "CreateAt", "UpdateAt", "LastActiveAt", "BaseEditAt", } -// draftMetaColumns is the metadata column set for draft queries — Body omitted because it can be up to PageBodyMaxBytes per draft. +// draftMetaColumns is the metadata column set for draft queries — Body and Props omitted because +// they can be up to PageBodyMaxBytes and PagePropsMaxBytes per draft (see model.DraftSummary). var draftMetaColumns = []string{ - "UserId", "SpaceId", "PageId", "ParentId", "Title", "FileIds", "Props", "CreateAt", "UpdateAt", "LastActiveAt", + "UserId", "SpaceId", "PageId", "ParentId", "Title", "FileIds", "CreateAt", "UpdateAt", "LastActiveAt", } // applyDraftLivenessFilter adds the space-liveness JOIN and page-liveness condition shared by @@ -64,17 +60,18 @@ func (s *Store) deleteDraftsForPage(tx *sqlx.Tx, pageID, spaceID string) error { } // reparentDraftsForPage reparents every new-page draft pointing at pageID to newParentID, -// so drafts don't retain a soft-deleted page as their pending parent. Must run inside tx. +// so drafts don't retain a soft-deleted page as their pending parent. The spaceID predicate +// scopes the rewrite the same way deleteDraftsForPage scopes its delete. Must run inside tx. // // UpdateAt uses GREATEST(now, UpdateAt+1) for the same reason as UpsertDraft: it must be a -// strictly-monotonic token so PublishDraft's CAS-delete cannot match a row that a concurrent -// autosave already advanced past this reparent. -func (s *Store) reparentDraftsForPage(tx *sqlx.Tx, pageID, newParentID string, now int64) error { +// strictly-monotonic token so the publish CAS-delete (deletePublishedDraftTx) cannot match a row +// that a concurrent autosave already advanced past this reparent. +func (s *Store) reparentDraftsForPage(tx *sqlx.Tx, pageID, newParentID, spaceID string, now int64) error { query := s.getQueryBuilder(). Update("DOCS_Draft"). Set("ParentId", newParentID). Set("UpdateAt", monotonicBump("UpdateAt", now)). - Where(sq.Eq{"ParentId": pageID}) + Where(sq.Eq{"ParentId": pageID, "SpaceId": spaceID}) if _, err := s.execBuilder(tx, query); err != nil { return errors.Wrap(err, "failed to reparent page drafts") } @@ -117,32 +114,35 @@ func (s *Store) countDraftsForUser(e sqlx.ExtContext, userID, spaceID string) (i return count, nil } -// draftExistsTx reports whether a draft row keyed by (userID, pageID) currently exists, read within -// tx so it observes the transaction's own uncommitted writes. -func (s *Store) draftExistsTx(tx *sqlx.Tx, userID, pageID string) (bool, error) { - var one int +// draftExistsTx reports whether a draft row keyed by (userID, pageID) currently exists — and if so, +// its stored SpaceId — read within tx so it observes the transaction's own uncommitted writes. +func (s *Store) draftExistsTx(tx *sqlx.Tx, userID, pageID string) (exists bool, spaceID string, err error) { builder := s.getQueryBuilder(). - Select("1"). + Select("SpaceId"). From("DOCS_Draft"). Where(sq.Eq{"UserId": userID, "PageId": pageID}) - switch err := s.getBuilder(tx, &one, builder); { + switch err := s.getBuilder(tx, &spaceID, builder); { case err == nil: - return true, nil + return true, spaceID, nil case errors.Is(err, sql.ErrNoRows): - return false, nil + return false, "", nil default: - return false, errors.Wrap(err, "failed to check draft existence") + return false, "", errors.Wrap(err, "failed to check draft existence") } } // checkNoDraftCycle walks the parent chain from startParentID through the caller's draft rows and // returns an error if leafPageID appears anywhere in the chain (cycle) or if the total depth -// (draft chain + live-page ancestor) would exceed model.MaxPageDepth. A published-page -// ancestor (no matching draft row) terminates the recursion early. Squirrel cannot express -// recursive CTEs, so raw SQL is used here. +// (draft chain + live-page ancestor) would exceed model.MaxPageDepth. A live-page ancestor +// terminates the recursion: an edit draft on a live page is version bookkeeping, not a hierarchy +// edge (its ParentId is never applied at publish), so the walk follows draft ParentId links only +// for new-page drafts and switches to the live tree (pageDepth) at the boundary. Squirrel cannot +// express recursive CTEs, so raw SQL is used here. func (s *Store) checkNoDraftCycle(tx *sqlx.Tx, userID, leafPageID, startParentID string) error { - // The live_ancestor subquery finds the deepest node in the draft chain that has no draft row - // (i.e. the live-page boundary). COALESCE converts NULL to '' so the struct scan never fails. + // The recursive term follows a draft row only when its page is not live — an edit draft on a + // live page must not redirect the walk (its stored ParentId defaults to '' and would silently + // truncate the chain). The live_ancestor subquery finds the deepest node that IS a live page + // (the live-tree boundary). COALESCE converts NULL to '' so the struct scan never fails. query := fmt.Sprintf(` WITH RECURSIVE chain(node, depth) AS ( SELECT $1::varchar(26), 0 @@ -153,6 +153,10 @@ WITH RECURSIVE chain(node, depth) AS ( WHERE d.UserId = $2 AND chain.node <> '' AND chain.depth < %d + AND NOT EXISTS ( + SELECT 1 FROM DOCS_Page lp + WHERE lp.Id = d.PageId AND lp.DeleteAt = 0 AND lp.OriginalId = '' + ) ) SELECT COALESCE(bool_or(node = $3), false) AS is_cycle, @@ -161,7 +165,7 @@ SELECT COALESCE(( SELECT c.node FROM chain c WHERE c.node <> '' - AND NOT EXISTS (SELECT 1 FROM DOCS_Draft d2 WHERE d2.UserId = $2 AND d2.PageId = c.node) + AND EXISTS (SELECT 1 FROM DOCS_Page lp WHERE lp.Id = c.node AND lp.DeleteAt = 0 AND lp.OriginalId = '') ORDER BY c.depth DESC LIMIT 1 ), '') AS live_ancestor FROM chain`, model.MaxPageDepth, model.MaxPageDepth) @@ -228,7 +232,22 @@ FROM chain`, model.MaxPageDepth, model.MaxPageDepth) // clears all keys). This is a whole-value replace, not a key-wise merge, mirroring parentID/fileIDs. // The written value's serialized size must be validated by the caller (App layer): the struct's own // Props field — the only one IsValid checks — is not what gets written. -func (s *Store) UpsertDraft(draft *model.Draft, parentID *string, fileIDs *mmmodel.StringArray, props *mmmodel.StringInterface) (_ *model.Draft, pageWasLive bool, err error) { +func (s *Store) UpsertDraft(draft *model.Draft, parentID *string, fileIDs *mmmodel.StringArray, props *mmmodel.StringInterface) (*model.Draft, bool, error) { + return s.upsertDraft(draft, parentID, fileIDs, props, false) +} + +// AutosaveDraft is UpsertDraft with the autosave-path guards enabled: it refuses to establish a new +// draft row when the page id has no live page backing it (only CreateSpaceDraft may reserve a page +// id, so an autosave for an unknown id cannot squat on it), and it rejects an autosave addressed to +// a different space than an existing draft's stored SpaceId (ReasonPageNotLive) — e.g. a client +// still saving under a stale space URL after a cross-space move re-homed the draft — preventing +// cross-space drift. Updating the caller's own existing draft, including a new-page draft whose +// page is not yet published, is unaffected. +func (s *Store) AutosaveDraft(draft *model.Draft, parentID *string, fileIDs *mmmodel.StringArray, props *mmmodel.StringInterface) (*model.Draft, bool, error) { + return s.upsertDraft(draft, parentID, fileIDs, props, true) +} + +func (s *Store) upsertDraft(draft *model.Draft, parentID *string, fileIDs *mmmodel.StringArray, props *mmmodel.StringInterface, autosave bool) (_ *model.Draft, pageWasLive bool, err error) { if draft == nil { return nil, false, &ErrInvalidInput{Entity: "Draft", Field: "draft", Value: nil} } @@ -273,22 +292,28 @@ func (s *Store) UpsertDraft(draft *model.Draft, parentID *string, fileIDs *mmmod return nil, false, lockErr } + isExisting, existingSpaceID, existErr := s.draftExistsTx(tx, draft.UserId, draft.PageId) + if existErr != nil { + return nil, false, existErr + } + // On the autosave path an existing draft pins the space: the ON CONFLICT clause below never + // changes a stored SpaceId, so an upsert addressed to a different space would silently update + // the row's content under the wrong space URL. Reject it instead. + if autosave && isExisting && existingSpaceID != draft.SpaceId { + return nil, false, &ErrInvalidInput{Entity: "Draft", Field: "SpaceId", Value: draft.SpaceId, Reason: ReasonPageNotLive} + } // Quota check: enforce MaxDraftsPerUserPerSpace atomically inside the space lock so // concurrent CreateSpaceDraft calls in the same space cannot both pass a stale pre-check // and each insert a row that pushes the total past the cap. // Skip the check on the UPDATE path: updating an existing draft adds no row, so it cannot // push the total over the cap. - isExisting, existErr := s.draftExistsTx(tx, draft.UserId, draft.PageId) - if existErr != nil { - return nil, false, existErr - } if !isExisting { count, countErr := s.countDraftsForUser(tx, draft.UserId, draft.SpaceId) if countErr != nil { return nil, false, countErr } - if count >= MaxDraftsPerUserPerSpace { - return nil, false, &ErrLimitExceeded{Resource: "Draft", Limit: MaxDraftsPerUserPerSpace, Reason: ReasonDraftQuotaExceeded} + if count >= model.MaxDraftsPerUserPerSpace { + return nil, false, &ErrLimitExceeded{Resource: "Draft", Limit: model.MaxDraftsPerUserPerSpace, Reason: ReasonDraftQuotaExceeded} } } @@ -314,7 +339,7 @@ func (s *Store) UpsertDraft(draft *model.Draft, parentID *string, fileIDs *mmmod } // Establish-time baseline sanity check: on the establishing INSERT (no draft row yet), a // baseline ahead of the live page is impossible — the client cannot have seen a version newer - // than the one that exists — so reject it as invalid input (400 via storeAppError). isExisting + // than the one that exists — so reject it as invalid input. isExisting // is authoritative here: lockLiveSpace's per-space FOR UPDATE serializes same-space upserts // before it is read, so a concurrent establish cannot make it stale. The update path needs no // guard — BaseEditAt is write-once, so the incoming value is ignored on conflict. @@ -326,7 +351,7 @@ func (s *Store) UpsertDraft(draft *model.Draft, parentID *string, fileIDs *mmmod // publish or another edit). A still-existing draft row may keep saving — the conflict is // deferred to publish — but if no row exists, this upsert would re-INSERT a phantom draft // that a just-committed publish deleted, so reject it as a stale edit instead. Holding the - // page row FOR UPDATE serializes this with PublishDraft's own draft delete, so the existence + // page row FOR UPDATE serializes this with the publish transaction's draft delete, so the existence // check is stable within the transaction. conflictReason := "" base := draft.BaseEditAt @@ -337,14 +362,14 @@ func (s *Store) UpsertDraft(draft *model.Draft, parentID *string, fileIDs *mmmod // which means a concurrent publish claimed this page id. If the draft no longer // exists (publish deleted it), reject rather than resurrect it — a re-INSERT here // would leave stale recoverable content and ghost presence behind. Holding the page - // row FOR UPDATE serializes this check with PublishDraft's draft delete. + // row FOR UPDATE serializes this check with the publish transaction's draft delete. conflictReason = ReasonConcurrentAutosave } if conflictReason != "" { - // Re-read draft existence now that the page row is locked: a concurrent PublishDraft + // Re-read draft existence now that the page row is locked: a concurrent publish // may have committed (deleting the draft) between the pre-lock draftExistsTx above // and this point, making the earlier isExisting result stale. - isExistingNow, reErr := s.draftExistsTx(tx, draft.UserId, draft.PageId) + isExistingNow, _, reErr := s.draftExistsTx(tx, draft.UserId, draft.PageId) if reErr != nil { return nil, false, reErr } @@ -353,7 +378,13 @@ func (s *Store) UpsertDraft(draft *model.Draft, parentID *string, fileIDs *mmmod } } case errors.Is(pErr, sql.ErrNoRows): - // New-page draft: no page row to lock. + // New-page draft: no page row to lock. On the autosave path, a draft may only be + // established here when the page is live — a page id with neither a page row nor an + // existing draft is unknown, and autosaving it would ghost-draft (or squat on) an + // unreserved id. Reserving a page id is CreateSpaceDraft's job. + if autosave && !isExisting { + return nil, false, &ErrInvalidInput{Entity: "Draft", Field: "PageId", Value: draft.PageId, Reason: ReasonPageNotLive} + } default: return nil, false, errors.Wrap(pErr, "failed to lock page for draft upsert") } @@ -445,6 +476,34 @@ func (s *Store) GetDraft(userID, pageID string) (*model.Draft, error) { return &draft, nil } +// GetDraftSpaceID returns the SpaceId of the draft keyed by (userID, pageID) without fetching the +// row, gated by the same liveness filter as GetDraft — for callers that need only the space (or +// bare existence) and must not haul the draft body. Returns ErrNotFound when no live draft exists. +func (s *Store) GetDraftSpaceID(userID, pageID string) (string, error) { + if userID == "" { + return "", &ErrInvalidInput{Entity: "Draft", Field: "userId", Value: userID} + } + if pageID == "" { + return "", &ErrInvalidInput{Entity: "Draft", Field: "pageId", Value: pageID} + } + + builder := applyDraftLivenessFilter( + s.getQueryBuilder(). + Select("d.SpaceId"). + From("DOCS_Draft d"), + ).Where(sq.Eq{"d.UserId": userID, "d.PageId": pageID}) + + var spaceID string + if err := s.getBuilder(s.db, &spaceID, builder); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return "", &ErrNotFound{EntityName: "Draft", ID: pageID} + } + return "", errors.Wrap(err, "unable_to_get_draft_space_id") + } + + return spaceID, nil +} + // DeleteDraft removes the draft keyed by (userID, pageID), or returns ErrNotFound. func (s *Store) DeleteDraft(userID, pageID string) error { if userID == "" { @@ -518,8 +577,8 @@ func (s *Store) DeleteDraftReparenting(userID, spaceID, pageID string) (pageWasL defer s.finalizeTransaction(tx, &err) // Lock the space row before any draft row, matching the space→row lock order of every other - // structural draft mutation (UpsertDraft, PublishDraft): this serializes concurrent discards in - // the same chain so two cannot interleave into a dangling parent, and keeps the lock order + // structural draft mutation (UpsertDraft, deletePublishedDraftTx): this serializes concurrent + // discards in the same chain so two cannot interleave into a dangling parent, and keeps the lock order // consistent to avoid an AB-BA deadlock against UpsertDraft. if lockErr := s.lockLiveSpace(tx, spaceID); lockErr != nil { return false, lockErr @@ -578,7 +637,7 @@ func (s *Store) DeleteDraftReparenting(userID, spaceID, pageID string) (pageWasL } // GetDraftsForSpace returns the user's drafts in the given space, most-recently-updated first, -// with Body omitted (metadata only — see draftMetaColumns). Results pass applyDraftLivenessFilter, +// with Body and Props omitted (metadata only — see draftMetaColumns). Results pass applyDraftLivenessFilter, // so a soft-deleted space lists no drafts (they survive the soft-delete and reappear after // RestoreSpace) and a draft whose page is soft-deleted is excluded from results. Results are // paginated via offset/limit (see applyLimitOffset); callers must pass a positive limit. diff --git a/server/store/draft_store_test.go b/server/store/draft_store_test.go index 3d24143..b8bad3f 100644 --- a/server/store/draft_store_test.go +++ b/server/store/draft_store_test.go @@ -712,6 +712,16 @@ func TestGetActiveEditorsForPage(t *testing.T) { require.NotContains(t, editors, userID) }) + t.Run("cutoff exactly at LastActiveAt includes the editor", func(t *testing.T) { + // The window is inclusive (LastActiveAt >= cutoff): a draft updated exactly at the cutoff + // still counts, pinning the >= predicate against an accidental strict >. + d, err := s.GetDraft(userID, pageID) + require.NoError(t, err) + editors, err := s.GetPageActiveEditors(pageID, space.Id, d.LastActiveAt) + require.NoError(t, err) + require.Contains(t, editors, userID) + }) + t.Run("a different page has no editors", func(t *testing.T) { editors, err := s.GetPageActiveEditors(mmmodel.NewId(), space.Id, 0) require.NoError(t, err) @@ -874,8 +884,9 @@ func TestDeleteDraftVersion(t *testing.T) { }) } -// TestPublishDraft covers the atomic publish transaction at the store boundary: the new-page -// insert-and-delete-draft path, and the edit path's optimistic-lock CAS. +// TestPublishDraft covers the atomic publish transactions at the store boundary: the new-page +// insert-and-delete-draft path (PublishNewPageDraft), and the edit path's optimistic-lock CAS +// (PublishPageEditDraft). func TestPublishDraft(t *testing.T) { t.Run("new page inserts the page and deletes the draft", func(t *testing.T) { s := openTestDB(t) @@ -889,7 +900,7 @@ func TestPublishDraft(t *testing.T) { require.NoError(t, err) page := &model.Page{Id: pageID, SpaceId: space.Id, Title: "Published", Body: `{"type":"doc","content":[]}`, UserId: userID} - published, err := s.PublishDraft(true, page, userID, space.Id, false, testDefaultMaxDepth, draft.UpdateAt) + published, err := s.PublishNewPageDraft(page, userID, space.Id, testDefaultMaxDepth, draft.UpdateAt) require.NoError(t, err) require.Equal(t, pageID, published.Id) @@ -915,12 +926,12 @@ func TestPublishDraft(t *testing.T) { draft, _, err := s.UpsertDraft(d, nil, nil, nil) require.NoError(t, err) - edit := *created - edit.Title = "Edited" - edit.Body = `{"type":"doc","content":[]}` - edit.EditAt = created.EditAt - 1 // stale baseline + title := "Edited" + body := `{"type":"doc","content":[]}` + searchText := "" + patch := &model.PagePatch{Title: &title, Body: &body, SearchText: &searchText} - _, err = s.PublishDraft(false, &edit, userID, space.Id, false, testDefaultMaxDepth, draft.UpdateAt) + _, err = s.PublishPageEditDraft(created.Id, space.Id, patch, created.EditAt-1, false, userID, draft.UpdateAt) // stale baseline require.True(t, store.IsErrConflict(err), "a stale baseline must conflict, got %v", err) }) @@ -938,12 +949,12 @@ func TestPublishDraft(t *testing.T) { draft, _, err := s.UpsertDraft(d2, nil, nil, nil) require.NoError(t, err) - edit := *created - edit.Title = "Edited" - edit.Body = `{"type":"doc","content":[]}` - edit.EditAt = created.EditAt // matching baseline + title := "Edited" + body := `{"type":"doc","content":[]}` + searchText := "" + patch := &model.PagePatch{Title: &title, Body: &body, SearchText: &searchText} - published, err := s.PublishDraft(false, &edit, userID, space.Id, false, testDefaultMaxDepth, draft.UpdateAt) + published, err := s.PublishPageEditDraft(created.Id, space.Id, patch, created.EditAt, false, userID, draft.UpdateAt) // matching baseline require.NoError(t, err) require.Equal(t, "Edited", published.Title) require.Greater(t, published.EditAt, created.EditAt, "publish advances EditAt") @@ -974,12 +985,12 @@ func TestPublishDraft(t *testing.T) { require.NoError(t, err) require.Greater(t, newer.UpdateAt, stale.UpdateAt, "the autosave must advance UpdateAt") - edit := *created - edit.Title = "Published from stale content" - edit.Body = `{"type":"doc","content":[]}` - edit.EditAt = created.EditAt + title := "Published from stale content" + body := `{"type":"doc","content":[]}` + searchText := "" + patch := &model.PagePatch{Title: &title, Body: &body, SearchText: &searchText} - _, err = s.PublishDraft(false, &edit, userID, space.Id, false, testDefaultMaxDepth, stale.UpdateAt) + _, err = s.PublishPageEditDraft(created.Id, space.Id, patch, created.EditAt, false, userID, stale.UpdateAt) require.True(t, store.IsErrConflict(err), "publishing stale draft content must conflict, got %v", err) // The page must be untouched and the newer draft must survive for the client to republish. @@ -1245,3 +1256,90 @@ func TestUpsertDraftResurrectionClassification(t *testing.T) { require.Equal(t, store.ReasonConcurrentAutosave, store.ConflictReason(err)) }) } + +// TestUpsertDraftCountsLiveAncestorDepth pins checkNoDraftCycle's live-ancestor arithmetic: the +// live parent chain's depth counts toward model.MaxPageDepth for a new draft, including when the +// caller holds an edit draft on the live ancestor. An edit draft's stored ParentId defaults to ” +// (version bookkeeping, not a hierarchy edge), and a chain walk that followed it would skip the +// ancestor's real depth and admit drafts that can never publish within the cap. +func TestUpsertDraftCountsLiveAncestorDepth(t *testing.T) { + buildLiveChain := func(t *testing.T, s *store.Store, spaceID, channelID, userID string, depth int) *model.Page { + t.Helper() + parentID := "" + var page *model.Page + var err error + for range depth { + page, err = s.CreatePage(newPage(spaceID, channelID, userID, parentID), model.MaxPageDepth) + require.NoError(t, err) + parentID = page.Id + } + return page + } + + establishEditDraft := func(t *testing.T, s *store.Store, spaceID string, page *model.Page, userID string) { + t.Helper() + d := newDraft(userID, spaceID, page.Id, "") + d.BaseEditAt = page.EditAt + _, _, err := s.UpsertDraft(d, nil, nil, nil) + require.NoError(t, err) + } + + requireTooDeep := func(t *testing.T, err error) { + t.Helper() + require.Error(t, err) + var inv *store.ErrInvalidInput + require.True(t, errors.As(err, &inv), "expected ErrInvalidInput, got %T: %v", err, err) + require.Equal(t, store.ReasonDraftTooDeep, inv.Reason) + } + + t.Run("rejects a draft under a cap-deep live chain despite an edit draft on the ancestor", func(t *testing.T) { + s := openTestDB(t) + space, err := s.CreateSpace(newSpace(mmmodel.NewId())) + require.NoError(t, err) + userID := mmmodel.NewId() + + deepest := buildLiveChain(t, s, space.Id, space.ChannelId, userID, model.MaxPageDepth) + establishEditDraft(t, s, space.Id, deepest, userID) + + parentID := deepest.Id + _, _, err = s.UpsertDraft(newDraft(userID, space.Id, mmmodel.NewId(), parentID), &parentID, nil, nil) + requireTooDeep(t, err) + }) + + t.Run("accepts a draft that lands exactly at the cap below a live chain", func(t *testing.T) { + s := openTestDB(t) + space, err := s.CreateSpace(newSpace(mmmodel.NewId())) + require.NoError(t, err) + userID := mmmodel.NewId() + + deepest := buildLiveChain(t, s, space.Id, space.ChannelId, userID, model.MaxPageDepth-1) + establishEditDraft(t, s, space.Id, deepest, userID) + + parentID := deepest.Id + _, _, err = s.UpsertDraft(newDraft(userID, space.Id, mmmodel.NewId(), parentID), &parentID, nil, nil) + require.NoError(t, err) + }) + + t.Run("combined live and draft chain depth is capped across the live boundary", func(t *testing.T) { + s := openTestDB(t) + space, err := s.CreateSpace(newSpace(mmmodel.NewId())) + require.NoError(t, err) + userID := mmmodel.NewId() + + deepest := buildLiveChain(t, s, space.Id, space.ChannelId, userID, model.MaxPageDepth-2) + establishEditDraft(t, s, space.Id, deepest, userID) + + // Two new-page drafts chained below the live ancestor land exactly at the cap. + parentID := deepest.Id + d1, _, err := s.UpsertDraft(newDraft(userID, space.Id, mmmodel.NewId(), parentID), &parentID, nil, nil) + require.NoError(t, err) + d1ID := d1.PageId + d2, _, err := s.UpsertDraft(newDraft(userID, space.Id, mmmodel.NewId(), d1ID), &d1ID, nil, nil) + require.NoError(t, err) + + // One more level would publish past the cap, so it is rejected at draft time. + d2ID := d2.PageId + _, _, err = s.UpsertDraft(newDraft(userID, space.Id, mmmodel.NewId(), d2ID), &d2ID, nil, nil) + requireTooDeep(t, err) + }) +} diff --git a/server/store/page_move.go b/server/store/page_move.go index 7167f9e..73099c4 100644 --- a/server/store/page_move.go +++ b/server/store/page_move.go @@ -463,8 +463,8 @@ func (s *Store) rewriteSubtreeSpace(tx *sqlx.Tx, ids []string, sourceSpaceID, ta if err != nil { return errors.Wrap(err, "failed to count mover drafts in target space") } - if targetDraftCount+movedDraftCount > MaxDraftsPerUserPerSpace { - return &ErrLimitExceeded{Resource: "Draft", Limit: MaxDraftsPerUserPerSpace, Reason: ReasonDraftQuotaExceeded} + if targetDraftCount+movedDraftCount > model.MaxDraftsPerUserPerSpace { + return &ErrLimitExceeded{Resource: "Draft", Limit: model.MaxDraftsPerUserPerSpace, Reason: ReasonDraftQuotaExceeded} } } diff --git a/server/store/page_move_test.go b/server/store/page_move_test.go index c45ef3d..c33d300 100644 --- a/server/store/page_move_test.go +++ b/server/store/page_move_test.go @@ -245,7 +245,7 @@ func TestMovePageToSpace_Store(t *testing.T) { spaceB, err := s.CreateSpace(newSpace(chB)) require.NoError(t, err) // Fill the mover's quota in the target space, so re-homing even one more trips the cap. - for range store.MaxDraftsPerUserPerSpace { + for range model.MaxDraftsPerUserPerSpace { _, _, err = s.UpsertDraft(newDraft(mover, spaceB.Id, mmmodel.NewId(), ""), nil, nil, nil) require.NoError(t, err) } diff --git a/server/store/page_store.go b/server/store/page_store.go index 822ef62..b47b92a 100644 --- a/server/store/page_store.go +++ b/server/store/page_store.go @@ -72,26 +72,32 @@ func (s *Store) CreatePage(page *model.Page, maxDepth int) (_ *model.Page, err e } defer s.finalizeTransaction(tx, &err) - // Lock the space row for the lifetime of this insert so it serializes with - // DeleteSpace: a racing delete blocks here and then cascades the new page, while - // a space already gone causes an immediate abort. - spaceLockQuery := s.getQueryBuilder(). - Select("ChannelId"). - From("DOCS_Space"). - Where(sq.Eq{"Id": page.SpaceId, "DeleteAt": 0}). - Suffix("FOR UPDATE") - var spaceChannelID string - if spErr := s.getBuilder(tx, &spaceChannelID, spaceLockQuery); spErr != nil { - if errors.Is(spErr, sql.ErrNoRows) { - return nil, &ErrNotFound{EntityName: "Space", ID: page.SpaceId} - } - return nil, errors.Wrap(spErr, "failed to lock space for page create") + created, insErr := s.insertPageTx(tx, page, maxDepth, "create depth") + if insErr != nil { + return nil, insErr + } + + if err = tx.Commit(); err != nil { + return nil, errors.Wrap(err, "commit_transaction") + } + + return created, nil +} + +// insertPageTx inserts a new page inside the caller's transaction, applying the invariants shared +// by CreatePage and the draft-publish path: it locks the space row (serializing with DeleteSpace / +// RestoreSpace) and derives ChannelId from it, re-verifies the parent is still live and enforces +// maxDepth against the parent's locked current depth, assigns the next sibling sort order, and +// inserts. depthContext labels the depth-cap error with the originating operation. A PK collision +// returns ErrConflict so a publish caller can adopt the concurrent winner. +func (s *Store) insertPageTx(tx *sqlx.Tx, page *model.Page, maxDepth int, depthContext string) (*model.Page, error) { + spaceChannelID, spErr := s.lockLiveSpaceChannel(tx, page.SpaceId) + if spErr != nil { + return nil, spErr } // Derive ChannelId from the locked space row (single source of truth) rather than trusting the caller-supplied value. page.ChannelId = spaceChannelID - // Re-verify the parent is still live under the same transaction, and enforce maxDepth against - // its locked, current depth — atomic with the insert, unlike a pre-transaction read. if page.ParentId != "" { if pErr := s.lockLiveParent(tx, page.ParentId, page.SpaceId, "Page"); pErr != nil { return nil, pErr @@ -100,7 +106,7 @@ func (s *Store) CreatePage(page *model.Page, maxDepth int) (_ *model.Page, err e if depthErr != nil { return nil, depthErr } - if capErr := depthCapError("Page parent_id="+page.ParentId+" (create depth)", parentDepth, 0, maxDepth); capErr != nil { + if capErr := depthCapError("Page parent_id="+page.ParentId+" ("+depthContext+")", parentDepth, 0, maxDepth); capErr != nil { return nil, capErr } } @@ -126,13 +132,8 @@ func (s *Store) CreatePage(page *model.Page, maxDepth int) (_ *model.Page, err e if isUniqueViolation(execErr) { return nil, &ErrConflict{Resource: "Page id=" + page.Id} } - return nil, errors.Wrap(execErr, "failed to save Page") + return nil, errors.Wrap(execErr, "failed to insert page") } - - if err = tx.Commit(); err != nil { - return nil, errors.Wrap(err, "commit_transaction") - } - return page, nil } @@ -187,6 +188,24 @@ func (s *Store) UpdatePage(pageID, spaceID string, patch *model.PagePatch, baseE } defer s.finalizeTransaction(tx, &err) + page, patchErr := s.applyPagePatchTx(tx, pageID, spaceID, patch, baseEditAt, force, lastModifiedBy) + if patchErr != nil { + return nil, patchErr + } + + if err = tx.Commit(); err != nil { + return nil, errors.Wrap(err, "commit_transaction") + } + + return page, nil +} + +// applyPagePatchTx merges patch into the live page inside the caller's transaction, applying the +// invariants shared by UpdatePage and the draft-publish edit path: it locks the live row, CASes on +// EditAt (baseEditAt is the value the caller last saw; force skips the CAS but still merges into +// the current row so untouched fields keep any concurrent edit), validates the merged row, and +// writes it with strictly-monotonic EditAt/UpdateAt. +func (s *Store) applyPagePatchTx(tx *sqlx.Tx, pageID, spaceID string, patch *model.PagePatch, baseEditAt int64, force bool, lastModifiedBy string) (*model.Page, error) { // Lock the live row so the read-modify-write is atomic. The lock (not an EditAt // predicate) is what makes the write safe, so both paths merge the patch into the value // read here; no concurrent writer can slip between this read and the UPDATE below. Scoped to @@ -209,7 +228,7 @@ func (s *Store) UpdatePage(pageID, spaceID string, patch *model.PagePatch, baseE } if !force && page.EditAt != baseEditAt { - return nil, &ErrConflict{Resource: "Page id=" + pageID + " (concurrent edit)"} + return nil, &ErrConflict{Resource: "Page id=" + pageID, Reason: ReasonConcurrentEdit} } page.Patch(patch) @@ -250,11 +269,6 @@ func (s *Store) UpdatePage(pageID, spaceID string, patch *model.PagePatch, baseE page.UpdateAt = now page.EditAt = now - - if err = tx.Commit(); err != nil { - return nil, errors.Wrap(err, "commit_transaction") - } - return &page, nil } @@ -426,7 +440,7 @@ func (s *Store) DeletePage(pageID, spaceID, userID string) (_ string, err error) if draftErr := s.deleteDraftsForPage(tx, pageID, spaceID); draftErr != nil { return "", draftErr } - if draftErr := s.reparentDraftsForPage(tx, pageID, deleted.ParentID, now); draftErr != nil { + if draftErr := s.reparentDraftsForPage(tx, pageID, deleted.ParentID, spaceID, now); draftErr != nil { return "", draftErr } @@ -711,15 +725,13 @@ func (s *Store) GetSpacePages(spaceID string, offset, limit int) ([]*model.PageS return pages, nil } -// PublishDraft atomically writes a page (create or update) and deletes the user's draft for -// that page in a single transaction. On the new-page path a PK collision returns ErrConflict so -// the caller can adopt the concurrent winner without a half-state. On the edit path an EditAt -// mismatch (stale optimistic-lock baseline) returns ErrConflict. In both conflict cases the whole -// transaction is rolled back. +// PublishNewPageDraft atomically inserts page and deletes the user's draft for it in a single +// transaction. A PK collision returns ErrConflict so the caller can adopt the concurrent winner +// without a half-state; the whole transaction is rolled back. // -// draftUpdateAt pins the draft delete to the version the caller read (see the delete query below +// draftUpdateAt pins the draft delete to the version the caller read (see deletePublishedDraftTx // for how a version mismatch rolls the whole publish back). -func (s *Store) PublishDraft(isNewPage bool, page *model.Page, userID, spaceID string, force bool, maxDepth int, draftUpdateAt int64) (_ *model.Page, err error) { +func (s *Store) PublishNewPageDraft(page *model.Page, userID, spaceID string, maxDepth int, draftUpdateAt int64) (_ *model.Page, err error) { if page == nil { return nil, &ErrInvalidInput{Entity: "Page", Field: "page", Value: nil} } @@ -729,9 +741,8 @@ func (s *Store) PublishDraft(isNewPage bool, page *model.Page, userID, spaceID s if spaceID == "" { return nil, &ErrInvalidInput{Entity: "Draft", Field: "spaceID", Value: spaceID} } - // spaceID is the space from the caller's request; the page must live in it. A mismatch means the page - // was relocated by a concurrent move-to-space (edit path) or the caller built it for the wrong - // space — reject rather than write under the stale/foreign space. + // spaceID is the space from the caller's request; the page must live in it. A mismatch means + // the caller built the page for the wrong space — reject rather than write under it. if page.SpaceId != spaceID { return nil, &ErrInvalidInput{Entity: "Page", Field: "SpaceId", Value: page.SpaceId} } @@ -742,155 +753,93 @@ func (s *Store) PublishDraft(isNewPage bool, page *model.Page, userID, spaceID s } defer s.finalizeTransaction(tx, &err) - var result *model.Page + result, err := s.insertPageTx(tx, page, maxDepth, "publish depth") + if err != nil { + return nil, err + } - if isNewPage { - // Lock the space row to serialize with DeleteSpace / RestoreSpace. - spaceChannelID, spErr := s.lockLiveSpaceChannel(tx, page.SpaceId) - if spErr != nil { - return nil, spErr - } - page.ChannelId = spaceChannelID + if err = s.deletePublishedDraftTx(tx, userID, page.Id, spaceID, draftUpdateAt); err != nil { + return nil, err + } - if page.ParentId != "" { - if pErr := s.lockLiveParent(tx, page.ParentId, page.SpaceId, "Page"); pErr != nil { - return nil, pErr - } - // Enforce the depth cap against the parent's locked, current depth — atomic with the - // insert, the same guard CreatePage applies. - parentDepth, depthErr := s.pageDepth(tx, page.ParentId) - if depthErr != nil { - return nil, depthErr - } - if capErr := depthCapError("Page parent_id="+page.ParentId+" (publish depth)", parentDepth, 0, maxDepth); capErr != nil { - return nil, capErr - } - } + if err = tx.Commit(); err != nil { + return nil, errors.Wrap(err, "commit_transaction") + } - sortOrder, sortErr := s.nextSortOrder(tx, page.ChannelId, page.ParentId) - if sortErr != nil { - return nil, sortErr - } - page.SortOrder = sortOrder + return result, nil +} - page.PreSave() - if validErr := page.IsValid(); validErr != nil { - return nil, &ErrInvalidInput{Entity: "Page", Field: "IsValid", Value: validErr.Error(), Reason: validErr.Id} - } +// PublishPageEditDraft atomically applies patch to the live page (pageID, spaceID) and deletes the +// user's draft for it in a single transaction, via the shared applyPagePatchTx (the same row-lock, +// EditAt CAS, and merge invariants UpdatePage applies). baseEditAt is the optimistic-lock baseline +// the caller last saw; a mismatch returns ErrConflict and rolls the whole transaction back. force +// skips the CAS, but the patch still merges into the current row, so fields it leaves untouched +// keep any concurrent edit. userID records the editor. +// +// draftUpdateAt pins the draft delete to the version the caller read (see deletePublishedDraftTx +// for how a version mismatch rolls the whole publish back). +func (s *Store) PublishPageEditDraft(pageID, spaceID string, patch *model.PagePatch, baseEditAt int64, force bool, userID string, draftUpdateAt int64) (_ *model.Page, err error) { + if pageID == "" { + return nil, &ErrInvalidInput{Entity: "Page", Field: "Id", Value: pageID} + } + if spaceID == "" { + return nil, &ErrInvalidInput{Entity: "Page", Field: "SpaceId", Value: spaceID} + } + if userID == "" { + return nil, &ErrInvalidInput{Entity: "Draft", Field: "userID", Value: userID} + } + // Validate the patch before opening the transaction (matching UpdatePage), so an invalid or + // empty patch never locks the row or bumps UpdateAt/EditAt/LastModifiedBy. + if validErr := patch.IsValid(); validErr != nil { + return nil, &ErrInvalidInput{Entity: "Page", Field: "Patch", Value: validErr.Error(), Reason: validErr.Id} + } - insertQ := s.getQueryBuilder(). - Insert("DOCS_Page"). - Columns(pageColumnList...). - Values(pageToSlice(page)...) + tx, err := s.db.Beginx() + if err != nil { + return nil, errors.Wrap(err, "begin_transaction") + } + defer s.finalizeTransaction(tx, &err) - if _, execErr := s.execBuilder(tx, insertQ); execErr != nil { - if isUniqueViolation(execErr) { - return nil, &ErrConflict{Resource: "Page id=" + page.Id} - } - return nil, errors.Wrap(execErr, "failed to insert page on publish") - } - result = page - } else { - // Edit path: lock the live row, apply the draft's content, CAS on EditAt. - selectQ := s.getQueryBuilder(). - Select(pageColumnList...). - From("DOCS_Page"). - Where(sq.Eq{"Id": page.Id, "SpaceId": page.SpaceId}). - Where(liveNonSnapshotFilter("")). - Suffix("FOR UPDATE") - - var current model.Page - if txErr := s.getBuilder(tx, ¤t, selectQ); txErr != nil { - if errors.Is(txErr, sql.ErrNoRows) { - return nil, &ErrNotFound{EntityName: "Page", ID: page.Id} - } - return nil, errors.Wrap(txErr, "failed to lock page for publish") - } + result, err := s.applyPagePatchTx(tx, pageID, spaceID, patch, baseEditAt, force, userID) + if err != nil { + return nil, err + } - // Optimistic-lock: page.EditAt carries the baseline the caller last saw. Unless force, - // a mismatch against the locked current row is a concurrent-edit conflict. - if !force && current.EditAt != page.EditAt { - return nil, &ErrConflict{Resource: "Page id=" + page.Id, Reason: ReasonConcurrentEdit} - } + if err = s.deletePublishedDraftTx(tx, userID, pageID, spaceID, draftUpdateAt); err != nil { + return nil, err + } - // Apply only the fields the draft carried against the locked row via the shared Page.Patch - // merge, so the "which fields, how they merge" logic lives in exactly one place (model.Page.Patch - // / PagePatch) rather than being re-implemented here. Empty is the unset marker (a cleared - // document is EmptyTipTapJSON, not ""), so a partial autosave never wipes an untouched field and - // a force-publish cannot revert a concurrent edit to a field this draft did not change. Body and - // SearchText are patched together, as PagePatch requires. - patch := &model.PagePatch{} - if page.Title != "" { - patch.Title = &page.Title - } - if page.Body != "" { - patch.Body = &page.Body - patch.SearchText = &page.SearchText - } - if len(page.Props) > 0 { - patch.Props = &page.Props - } - current.Patch(patch) - current.LastModifiedBy = page.LastModifiedBy - current.PreUpdate() - if validErr := current.IsValid(); validErr != nil { - return nil, &ErrInvalidInput{Entity: "Page", Field: "IsValid", Value: validErr.Error(), Reason: validErr.Id} - } + if err = tx.Commit(); err != nil { + return nil, errors.Wrap(err, "commit_transaction") + } - // Keep EditAt and UpdateAt strictly monotonic, matching UpdatePage: EditAt is the CAS token - // for content edits; UpdateAt may already be ahead of it from a prior structural op. - now := nextMonotonic(mmmodel.GetMillis(), max(current.EditAt, current.UpdateAt)) - updateQ := s.getQueryBuilder(). - Update("DOCS_Page"). - Set("Title", current.Title). - Set("Body", current.Body). - Set("SearchText", current.SearchText). - Set("LastModifiedBy", current.LastModifiedBy). - Set("Props", current.GetProps()). - Set("UpdateAt", now). - Set("EditAt", now). - Where(sq.Eq{"Id": page.Id, "SpaceId": page.SpaceId}). - Where(liveNonSnapshotFilter("")) + return result, nil +} - res, execErr := s.execBuilder(tx, updateQ) - if execErr != nil { - return nil, errors.Wrap(execErr, "failed to update page on publish") - } - if raErr := checkRowsAffected(res, "Page", page.Id); raErr != nil { - return nil, raErr - } - current.UpdateAt = now - current.EditAt = now - result = ¤t - } - - // Delete the draft atomically with the page write, but only if it still holds the content the - // caller published. A concurrent autosave bumps UpdateAt, so it matches no row and the publish - // is rolled back — the newer draft survives and the client can publish it. - // - // UpdateAt is the only version token, so a bulk maintenance write that moves it without changing - // content (a page delete reparenting a pending child draft, a move-to-space re-homing it) also - // trips this CAS and surfaces a ReasonConcurrentAutosave conflict when no autosave occurred. The - // failure is safe — clean rollback, no data loss, self-heals when the client republishes — so we - // accept it rather than add a separate content-only version column for this narrow race. +// deletePublishedDraftTx deletes the just-published draft atomically with the page write in the +// caller's transaction, but only if it still holds the content the caller published. A concurrent +// autosave bumps UpdateAt, so it matches no row and the publish is rolled back — the newer draft +// survives and the client can publish it. +// +// UpdateAt is the only version token, so a bulk maintenance write that moves it without changing +// content (a page delete reparenting a pending child draft, a move-to-space re-homing it) also +// trips this CAS and surfaces a ReasonConcurrentAutosave conflict when no autosave occurred. The +// failure is safe — clean rollback, no data loss, self-heals when the client republishes — so we +// accept it rather than add a separate content-only version column for this narrow race. +func (s *Store) deletePublishedDraftTx(tx *sqlx.Tx, userID, pageID, spaceID string, draftUpdateAt int64) error { deleteDraftQ := s.getQueryBuilder(). Delete("DOCS_Draft"). - Where(sq.Eq{"UserId": userID, "PageId": page.Id, "SpaceId": spaceID, "UpdateAt": draftUpdateAt}) + Where(sq.Eq{"UserId": userID, "PageId": pageID, "SpaceId": spaceID, "UpdateAt": draftUpdateAt}) dRes, dErr := s.execBuilder(tx, deleteDraftQ) if dErr != nil { - return nil, errors.Wrap(dErr, "failed to delete draft on publish") + return errors.Wrap(dErr, "failed to delete draft on publish") } dRows, dRowsErr := dRes.RowsAffected() if dRowsErr != nil { - return nil, errors.Wrap(dRowsErr, "failed to read rows affected deleting draft on publish") + return errors.Wrap(dRowsErr, "failed to read rows affected deleting draft on publish") } if dRows == 0 { - return nil, &ErrConflict{Resource: "Draft page_id=" + page.Id, Reason: ReasonConcurrentAutosave} + return &ErrConflict{Resource: "Draft page_id=" + pageID, Reason: ReasonConcurrentAutosave} } - - if err = tx.Commit(); err != nil { - return nil, errors.Wrap(err, "commit_transaction") - } - - return result, nil + return nil } diff --git a/server/store/store.go b/server/store/store.go index e4c1749..7d009f5 100644 --- a/server/store/store.go +++ b/server/store/store.go @@ -316,9 +316,11 @@ const ( ReasonDraftCycle = "draft_cycle" ReasonDraftTooDeep = "draft_too_deep" ReasonDraftQuotaExceeded = "draft_quota_exceeded" - // ReasonPageNotLive marks an autosave whose target page was deleted, snapshotted, or moved out - // of the draft's space between the app-layer pre-check and the locked read — a concurrent state - // change, not bad input, so the caller maps it to 404 rather than a generic 400. + // ReasonPageNotLive marks an autosave whose target is not addressable in the request's space: + // the page was deleted, snapshotted, or moved out of it, the existing draft belongs to another + // space, or the page id was never reserved at all (no page row and no draft). The caller maps + // it to 404 rather than a generic 400 — from the requester's view the page does not exist in + // that space. ReasonPageNotLive = "page_not_live" ) From 81e731d51baddd3b717b9ffc127afb2fa75e0391 Mon Sep 17 00:00:00 2001 From: "Catalin I. Tomai" Date: Tue, 28 Jul 2026 11:34:46 +0200 Subject: [PATCH 35/36] update comments + move structure to model --- assets/i18n/en.json | 8 +- server/api.go | 22 +++-- server/api_handler_test.go | 12 +++ server/api_page.go | 4 + server/api_page_drafts.go | 6 +- server/api_page_drafts_test.go | 1 + server/app/page.go | 6 +- server/app/page_draft.go | 148 +++++++++++++++------------------ server/app/page_draft_test.go | 6 +- server/app/page_hierarchy.go | 6 +- server/app/page_presence.go | 17 ++-- server/app/service.go | 5 +- server/model/draft.go | 38 ++++++++- server/model/page_presence.go | 17 ++++ server/store/draft_store.go | 17 ++-- 15 files changed, 187 insertions(+), 126 deletions(-) create mode 100644 server/model/page_presence.go diff --git a/assets/i18n/en.json b/assets/i18n/en.json index 305a448..7112364 100644 --- a/assets/i18n/en.json +++ b/assets/i18n/en.json @@ -327,10 +327,6 @@ "id": "app.page_draft.update.edit_conflict.app_error", "translation": "A concurrent edit has been published; please reload the page to continue editing." }, - { - "id": "app.page_draft.update.invalid_file_id.app_error", - "translation": "One or more file IDs are invalid." - }, { "id": "app.page_draft.update.invalid_page_id.app_error", "translation": "Invalid page ID." @@ -543,6 +539,10 @@ "id": "model.draft.is_valid.create_at.app_error", "translation": "Invalid draft creation time." }, + { + "id": "model.draft.is_valid.file_id.app_error", + "translation": "One or more file IDs are invalid." + }, { "id": "model.draft.is_valid.file_ids.app_error", "translation": "The draft has too many file attachments." diff --git a/server/api.go b/server/api.go index 2c21ac1..c128f36 100644 --- a/server/api.go +++ b/server/api.go @@ -129,21 +129,25 @@ func (p *Plugin) writeAppError(w http.ResponseWriter, appErr *mmmodel.AppError) if appErr.StatusCode >= http.StatusInternalServerError { p.API.LogError("Docs API request failed", "where", appErr.Where, "id", appErr.Id, "status_code", appErr.StatusCode, "err", appErr.Error()) } + if appErr.StatusCode == http.StatusConflict { + p.writeConflictWithPage(w, appErr, nil) + return + } safe := *appErr safe.WipeDetailed() writeJSON(w, appErr.StatusCode, &safe) } -// conflictResponse is the 409 body for an edit conflict on publish: the scrubbed AppError plus the -// current server page. It lets a client diff and re-baseline against the live page (its EditAt) in -// one round-trip instead of following up with a GET. The whole page is returned rather than a -// curated snapshot — it is the complete source of truth, and the client renders whatever it needs. +// conflictResponse is the body every 409 carries: the scrubbed AppError plus the current server +// page. One shape across all conflicts means a client parses a 409 the same way whichever endpoint +// produced it, rather than branching on the route. // -// This is intentionally richer than the other optimistic-lock 409s (handleUpdatePage, handleMovePage, -// handleMovePageToSpace), which return a bare AppError and expect the client to re-read via GET. -// Publish embeds the page because it is the one conflict where the client needs the full current -// content immediately to diff its pending draft against; the edit/move conflicts only need the caller -// to retry against a fresh baseline. New page-mutation 409s should align with one of these two shapes. +// current_page is null when the handler has no page to offer — the conflict was not about a page, or +// the re-read that would have produced it failed — so a client treats it as an optional shortcut and +// falls back to a GET. Where it is populated (publish and page-update conflicts, which already read +// the live page to build the error) it saves that round-trip: the client diffs and re-baselines +// against the returned EditAt directly. The whole page is returned rather than a curated snapshot, +// so the client renders whatever it needs. type conflictResponse struct { Error *mmmodel.AppError `json:"error"` CurrentPage *model.Page `json:"current_page"` diff --git a/server/api_handler_test.go b/server/api_handler_test.go index 0c2b888..fc0d569 100644 --- a/server/api_handler_test.go +++ b/server/api_handler_test.go @@ -561,6 +561,18 @@ func TestHandler_UpdatePage(t *testing.T) { // The first update bumped EditAt, so the same baseline is now stale. rec = h.do(t, http.MethodPatch, "/api/v1/spaces/"+space.Id+"/pages/"+page.Id, user, body) require.Equal(t, http.StatusConflict, rec.Code) + + // Every 409 carries the same shape, so a client parses it without branching on the route. This + // one populates current_page, letting the caller re-baseline without a follow-up read. + var conflict struct { + Error *mmmodel.AppError `json:"error"` + CurrentPage *model.Page `json:"current_page"` + } + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &conflict)) + require.NotNil(t, conflict.Error) + require.Empty(t, conflict.Error.DetailedError, "the conflict body must not carry internal error detail") + require.NotNil(t, conflict.CurrentPage, "the update conflict must carry the current server page") + require.Greater(t, conflict.CurrentPage.EditAt, page.EditAt, "current page must carry the advanced baseline") } // TestHandler_UpdatePage_BaselineRequired verifies a PATCH that omits base_edit_at without force diff --git a/server/api_page.go b/server/api_page.go index 2b22f5d..0328908 100644 --- a/server/api_page.go +++ b/server/api_page.go @@ -100,6 +100,10 @@ func (p *Plugin) handleUpdatePage(w http.ResponseWriter, r *http.Request) { updated, appErr := p.service.UpdatePage(vars["page_id"], vars["space_id"], patch, req.BaseEditAt, req.Force, userID) if appErr != nil { + if updated != nil { + p.writeConflictWithPage(w, appErr, updated) + return + } p.writeAppError(w, appErr) return } diff --git a/server/api_page_drafts.go b/server/api_page_drafts.go index 7f8c48f..38441e0 100644 --- a/server/api_page_drafts.go +++ b/server/api_page_drafts.go @@ -165,8 +165,8 @@ func (p *Plugin) handleCreateSpaceDraft(w http.ResponseWriter, r *http.Request) } // handlePublishPageDraft handles POST /api/v1/spaces/{space_id}/pages/{page_id}/draft/publish -// It publishes the calling user's draft, atomically writing the page row and deleting the draft in -// one transaction. +// It publishes the calling user's draft as a page, creating it on first publish and updating it +// otherwise. // // The optimistic-lock baseline for an edit-publish is not a field on this request: it travels with // the draft, stored in its write-once BaseEditAt column (sent as the top-level base_edit_at field on @@ -197,8 +197,6 @@ func (p *Plugin) handlePublishPageDraft(w http.ResponseWriter, r *http.Request) page, wasCreated, appErr := p.service.PublishPageDraft(userID, spaceID, pageID, req.Force) if appErr != nil { - // An edit conflict returns the current server page alongside the error so the client can - // diff and re-baseline without a follow-up read; every other error carries no page. if page != nil { p.writeConflictWithPage(w, appErr, page) return diff --git a/server/api_page_drafts_test.go b/server/api_page_drafts_test.go index 1bc0340..1012acf 100644 --- a/server/api_page_drafts_test.go +++ b/server/api_page_drafts_test.go @@ -288,6 +288,7 @@ func TestHandler_PublishConflict409(t *testing.T) { } require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &conflict)) require.NotNil(t, conflict.Error, "conflict body must include the error") + require.Empty(t, conflict.Error.DetailedError, "the conflict body must not carry internal error detail") require.NotNil(t, conflict.CurrentPage, "conflict body must include the current server page") require.Equal(t, "Edit by B", conflict.CurrentPage.Title, "current page must reflect the winning edit") require.Greater(t, conflict.CurrentPage.EditAt, editAt, "current page must carry the advanced baseline") diff --git a/server/app/page.go b/server/app/page.go index 14c9bc7..77c703d 100644 --- a/server/app/page.go +++ b/server/app/page.go @@ -95,6 +95,8 @@ func (s *Service) GetPage(pageID string) (*model.Page, *mmmodel.AppError) { // UpdatePage patches a page, optimistic-locked on baseEditAt; a nil baseEditAt without force is // rejected. spaceID scopes the write: a page moved to another space since the caller's last // check returns not-found instead of updating the wrong copy. +// On a 409 the returned page is the current server page (nil if the re-read failed), so the caller +// can re-baseline without a follow-up read; on every other error it is nil. func (s *Service) UpdatePage(pageID, spaceID string, patch *model.PagePatch, baseEditAt *int64, force bool, userID string) (*model.Page, *mmmodel.AppError) { if !mmmodel.IsValidId(pageID) { return nil, mmmodel.NewAppError("UpdatePage", "app.page.update.invalid_id.app_error", nil, "", http.StatusBadRequest) @@ -138,7 +140,9 @@ func (s *Service) UpdatePage(pageID, spaceID string, patch *model.PagePatch, bas return nil, mmmodel.NewAppError("UpdatePage", "app.page.update.conflict.app_error", nil, "conflict", http.StatusConflict).Wrap(storeErr) } - return nil, mmmodel.NewAppError("UpdatePage", "app.page.update.conflict.app_error", + // The page travels back alongside the conflict so the caller can re-baseline without a + // follow-up read, matching PublishPageDraft's edit-conflict contract. + return fresh, mmmodel.NewAppError("UpdatePage", "app.page.update.conflict.app_error", map[string]any{"ModifiedBy": fresh.LastModifiedBy, "ModifiedAt": fresh.EditAt}, "conflict", http.StatusConflict).Wrap(storeErr) } diff --git a/server/app/page_draft.go b/server/app/page_draft.go index 690d404..c963d3b 100644 --- a/server/app/page_draft.go +++ b/server/app/page_draft.go @@ -7,7 +7,6 @@ import ( "errors" "maps" "net/http" - "unicode/utf8" mmmodel "github.com/mattermost/mattermost/server/public/model" @@ -23,12 +22,9 @@ import ( // not imply the page is published — a draft may exist first. The space must exist and be live. The // caller owns the draft: draft.UserId is always sourced from the request, never the request body. // -// An autosave may omit fields the editor didn't change; omitted fields are preserved, so concurrent -// heartbeats cannot clobber each other's changes. -// parentID encodes the write intent for ParentId: nil preserves the stored value, a pointer to "" -// clears to root, and a pointer to a valid ID sets the parent. -// props encodes the write intent for Props: nil preserves the stored map, a non-nil pointer replaces -// it wholesale (an empty map clears all keys); its serialized size is validated here. +// An autosave may omit fields the editor didn't change, and an omitted field keeps its stored +// value. parentID, fileIDs, and props signal omission with a nil pointer; the draft struct's own +// fields signal it by being empty. func (s *Service) UpdatePageDraft(draft *model.Draft, parentID *string, fileIDs *mmmodel.StringArray, props *mmmodel.StringInterface, channelID string) (*model.Draft, *mmmodel.AppError) { if draft == nil { return nil, mmmodel.NewAppError("UpdatePageDraft", "app.page_draft.update.nil_draft.app_error", nil, "", http.StatusBadRequest) @@ -65,30 +61,6 @@ func (s *Service) UpdatePageDraft(draft *model.Draft, parentID *string, fileIDs draft.Body = normalizedBody } - // Validate the written Props size here: props is passed to the store separately (pointer intent), - // so it is not the draft.Props field IsValid checks — without this the PagePropsMaxBytes bound - // would be silently bypassed on the write path. Mirrors the fileIDs size guard below. - if props != nil { - if propsErr := model.ValidatePropsSize("UpdatePageDraft", "page_id="+draft.PageId, *props, model.PagePropsMaxBytes); propsErr != nil { - return nil, propsErr - } - } - - // Validate fileIDs size here because fileIDs is passed to the store separately and is not placed - // into draft.FileIds before IsValid runs — the store's UpsertDraft merges it in SQL. - if fileIDs != nil && len(*fileIDs) > 0 { - if utf8.RuneCountInString(mmmodel.ArrayToJSON([]string(*fileIDs))) > model.DraftFileIdsMaxRunes { - return nil, mmmodel.NewAppError("UpdatePageDraft", "model.draft.is_valid.file_ids.app_error", nil, "", http.StatusBadRequest) - } - for _, fileID := range *fileIDs { - // Reject "" too: an empty slice clears the list, but an empty entry is malformed and - // would otherwise be merged verbatim into FileIds. - if !mmmodel.IsValidId(fileID) { - return nil, mmmodel.NewAppError("UpdatePageDraft", "app.page_draft.update.invalid_file_id.app_error", nil, "", http.StatusBadRequest) - } - } - } - // AutosaveDraft enforces the autosave-path guards itself (see its godoc), so no separate // pre-check reads are needed here. saved, savedPageWasLive, err := s.store.AutosaveDraft(draft, parentID, fileIDs, props) @@ -285,8 +257,9 @@ func (s *Service) DeletePageDraft(userID, spaceID, pageID, channelID string) *mm return nil } -// GetPageDraftsForSpace returns a page of the calling user's unpublished drafts for a space, newest -// first. Draft bodies are omitted from the listing. +// GetPageDraftsForSpace lists the user's unpublished drafts in a space, most-recently-updated +// first, one pagination page at a time; the second return reports whether further pages exist. +// Draft bodies are omitted from the listing. func (s *Service) GetPageDraftsForSpace(userID, spaceID string, page, perPage int) ([]*model.DraftSummary, bool, *mmmodel.AppError) { if !mmmodel.IsValidId(userID) { return nil, false, mmmodel.NewAppError("GetPageDraftsForSpace", "app.page_draft.list.invalid_user_id.app_error", nil, "", http.StatusBadRequest) @@ -309,8 +282,8 @@ func (s *Service) GetPageDraftsForSpace(userID, spaceID string, page, perPage in // page on first publish or updating it if it already exists. pageID is the id reserved when editing // began (see CreateSpaceDraft) and is stable across the draft → publish lifecycle, so its presence // does not imply a published page yet: whether this is a create or an edit is re-derived from the -// database (no client trust). The draft is validated, and the page write + draft delete are -// committed in a single store transaction. +// database (no client trust). The draft is validated, and the page write and the draft's removal +// either both take effect or neither does. // Returns (page, wasCreated, appErr): // - wasCreated=true → a new page was inserted by this call (handler should return 201) // - wasCreated=false → an existing page was updated, or a concurrent create was adopted (return 200) @@ -362,9 +335,9 @@ func (s *Service) PublishPageDraft(userID, spaceID, pageID string, force bool) ( } // 4/5/6. Validate & normalise the draft, build the store write (new-page insert vs edit patch; - // see helpers), and commit page + draft-delete in one transaction. draft.UpdateAt is passed - // through so a concurrent autosave rolls this publish back as a conflict rather than shipping - // older content — see store.deletePublishedDraftTx. + // see helpers), and hand it to the store. draft.UpdateAt is passed through so a concurrent + // autosave rolls this publish back as a conflict rather than shipping older content — see + // store.deletePublishedDraftTx. var page *model.Page var storeErr error if isNewPage { @@ -387,51 +360,7 @@ func (s *Service) PublishPageDraft(userID, spaceID, pageID string, force bool) ( page, storeErr = s.store.PublishPageEditDraft(pageID, spaceID, patch, draft.BaseEditAt, force, userID, draft.UpdateAt) } if storeErr != nil { - switch { - // The draft moved under this publish: the caller's own editor autosaved after this call read it, - // so the whole write was rolled back rather than committing the older content. Distinct from the - // conflicts below — the client republishes to ship the newer draft, it does not re-baseline. - case store.ConflictReason(storeErr) == store.ReasonConcurrentAutosave: - return nil, false, mmmodel.NewAppError("PublishPageDraft", "app.page_draft.publish.draft_changed.app_error", - nil, "", http.StatusConflict).Wrap(storeErr) - - // Someone else edited the page since the baseline was captured. The draft's baseline is - // write-once (see store.UpsertDraft), so the client cannot re-baseline the existing draft: - // it recovers by publishing with force, or by discarding the draft and reopening the edit - // session against the current page. Return the current server page alongside the conflict so - // the client can diff and choose in one round-trip rather than a follow-up GET. - // The pre-lock `existing` snapshot is stale by definition here, so re-read the live page. - case store.ConflictReason(storeErr) == store.ReasonConcurrentEdit: - editConflictErr := mmmodel.NewAppError("PublishPageDraft", "app.page_draft.publish.edit_conflict.app_error", - nil, "", http.StatusConflict).Wrap(storeErr) - current, getErr := s.GetPageInSpace("PublishPageDraft", pageID, spaceID, false) - if getErr != nil { - // A concurrent delete or cross-space move can make the page unreadable here; fall - // back to a bare conflict and let the client GET the page itself. - s.log.Warn("failed to re-read page for edit-conflict body", - "page_id", pageID, "user_id", userID, "err", getErr) - return nil, false, editConflictErr - } - return current, false, editConflictErr - - case store.IsErrConflict(storeErr): - // PK collision on the new-page path: a concurrent publish won this page id. Adopt the - // winner and return 200 without broadcasting; a winner that is not this caller's to read - // falls through to a plain conflict (see adoptPublishRaceWinner). - if isNewPage { - if raced, adopted := s.adoptPublishRaceWinner(userID, pageID, spaceID, draft.UpdateAt); adopted { - return raced, false, nil - } - } - return nil, false, mmmodel.NewAppError("PublishPageDraft", "app.page_draft.publish.conflict.app_error", - nil, "", http.StatusConflict).Wrap(storeErr) - case store.IsErrNotFound(storeErr): - // A concurrent delete removed the page or its parent between the pre-checks and the lock. - return nil, false, mmmodel.NewAppError("PublishPageDraft", "app.page_draft.publish.page_deleted.app_error", - nil, "", http.StatusConflict).Wrap(storeErr) - default: - return nil, false, storeAppError("PublishPageDraft", storeErr) - } + return s.translatePublishStoreError(storeErr, isNewPage, userID, spaceID, pageID, draft.UpdateAt) } // 7. Broadcast the write with the same shape and channel scope as the direct-CRUD page events. @@ -457,6 +386,59 @@ func (s *Service) PublishPageDraft(userID, spaceID, pageID string, force bool) ( return page, isNewPage, nil } +// translatePublishStoreError maps a failed publish write to the caller-facing result, so +// PublishPageDraft stays a sequence of steps and every publish-failure condition lands in one place. +// It returns the same triple as PublishPageDraft: the adopted page with a nil error when a concurrent +// publish already created this page, the current server page alongside a 409 on an edit conflict, and +// a nil page otherwise. +func (s *Service) translatePublishStoreError(storeErr error, isNewPage bool, userID, spaceID, pageID string, draftUpdateAt int64) (*model.Page, bool, *mmmodel.AppError) { + switch { + // The draft moved under this publish: the caller's own editor autosaved after this call read it, + // so the whole write was rolled back rather than committing the older content. Distinct from the + // conflicts below — the client republishes to ship the newer draft, it does not re-baseline. + case store.ConflictReason(storeErr) == store.ReasonConcurrentAutosave: + return nil, false, mmmodel.NewAppError("PublishPageDraft", "app.page_draft.publish.draft_changed.app_error", + nil, "", http.StatusConflict).Wrap(storeErr) + + // Someone else edited the page since the baseline was captured. The draft's baseline is + // write-once (see store.UpsertDraft), so the client cannot re-baseline the existing draft: + // it recovers by publishing with force, or by discarding the draft and reopening the edit + // session against the current page. Return the current server page alongside the conflict so + // the client can diff and choose in one round-trip rather than a follow-up GET. + // The pre-lock page snapshot is stale by definition here, so re-read the live page. + case store.ConflictReason(storeErr) == store.ReasonConcurrentEdit: + editConflictErr := mmmodel.NewAppError("PublishPageDraft", "app.page_draft.publish.edit_conflict.app_error", + nil, "", http.StatusConflict).Wrap(storeErr) + current, getErr := s.GetPageInSpace("PublishPageDraft", pageID, spaceID, false) + if getErr != nil { + // A concurrent delete or cross-space move can make the page unreadable here; fall + // back to a bare conflict and let the client GET the page itself. + s.log.Warn("failed to re-read page for edit-conflict body", + "page_id", pageID, "user_id", userID, "err", getErr) + return nil, false, editConflictErr + } + return current, false, editConflictErr + + case store.IsErrConflict(storeErr): + // PK collision on the new-page path: a concurrent publish won this page id. Adopt the + // winner and return 200 without broadcasting; a winner that is not this caller's to read + // falls through to a plain conflict (see adoptPublishRaceWinner). + if isNewPage { + if raced, adopted := s.adoptPublishRaceWinner(userID, pageID, spaceID, draftUpdateAt); adopted { + return raced, false, nil + } + } + return nil, false, mmmodel.NewAppError("PublishPageDraft", "app.page_draft.publish.conflict.app_error", + nil, "", http.StatusConflict).Wrap(storeErr) + case store.IsErrNotFound(storeErr): + // A concurrent delete removed the page or its parent between the pre-checks and the lock. + return nil, false, mmmodel.NewAppError("PublishPageDraft", "app.page_draft.publish.page_deleted.app_error", + nil, "", http.StatusConflict).Wrap(storeErr) + default: + return nil, false, storeAppError("PublishPageDraft", storeErr) + } +} + // derivePublishTarget classifies a publish target from the GetPageWithDeleted read: no page means // a new-page publish, a live page in the caller's space means an edit-publish. A page in another // space reports 404 rather than confirming the id exists elsewhere. A deleted page reports 409: diff --git a/server/app/page_draft_test.go b/server/app/page_draft_test.go index 8115b48..ae370e2 100644 --- a/server/app/page_draft_test.go +++ b/server/app/page_draft_test.go @@ -957,7 +957,9 @@ func TestCreateSpaceDraftEnforcesQuota(t *testing.T) { _, appErr := h.svc.CreateSpaceDraft(userID, space.Id, "One Too Many", "") require.NotNil(t, appErr) - require.Equal(t, http.StatusTooManyRequests, appErr.StatusCode) + // 422, not 429: the cap is a standing per-space quota, so retrying cannot clear it. + require.Equal(t, http.StatusUnprocessableEntity, appErr.StatusCode) + require.Equal(t, "app.page_draft.quota_exceeded.app_error", appErr.Id) } // TestGetPageDraftRejectsInvalidIDs exercises GetPageDraft's three input-validation branches at @@ -1087,7 +1089,7 @@ func TestUpdatePageDraftRejectsInvalidFileId(t *testing.T) { _, appErr := h.svc.UpdatePageDraft(&model.Draft{UserId: userID, SpaceId: space.Id, PageId: mmmodel.NewId(), Title: "x"}, nil, &ids, nil, "") require.NotNil(t, appErr) require.Equal(t, http.StatusBadRequest, appErr.StatusCode) - require.Equal(t, "app.page_draft.update.invalid_file_id.app_error", appErr.Id) + require.Equal(t, "model.draft.is_valid.file_id.app_error", appErr.Id) }) } } diff --git a/server/app/page_hierarchy.go b/server/app/page_hierarchy.go index 4e064f8..1314c06 100644 --- a/server/app/page_hierarchy.go +++ b/server/app/page_hierarchy.go @@ -161,9 +161,9 @@ func (s *Service) reparentWithinSpace(where, pageID, spaceID string, newParentID // store.MovePageToSpace and surface through storeAppError's shared message keys. // A nil expectedUpdateAt without force is rejected: the mutation must supply a baseline. // sourceSpace and targetSpace are the caller's already-fetched records (from its membership -// gates), so no re-read happens here. userID is the acting user, recorded in logs only — a -// move does not change the page's LastModifiedBy. Per-page restrictions and redirects are not -// handled yet. +// gates), so no re-read happens here. userID is the acting user and must be a valid ID: the store +// scopes the target-space draft quota to the drafts it owns. It does not change the page's +// LastModifiedBy. Per-page restrictions and redirects are not handled yet. func (s *Service) MovePageToSpace(pageID string, sourceSpace, targetSpace *model.Space, parentPageID *string, expectedUpdateAt *int64, force bool, userID string) (*model.Page, *mmmodel.AppError) { if !mmmodel.IsValidId(pageID) { return nil, mmmodel.NewAppError("MovePageToSpace", "app.page.move_to_space.invalid_id.app_error", nil, "", http.StatusBadRequest) diff --git a/server/app/page_presence.go b/server/app/page_presence.go index 32422a7..5800e80 100644 --- a/server/app/page_presence.go +++ b/server/app/page_presence.go @@ -7,6 +7,8 @@ import ( "net/http" mmmodel "github.com/mattermost/mattermost/server/public/model" + + "github.com/mattermost/mattermost-plugin-docs/server/model" ) // ActiveEditorTimeoutMs is the window within which a draft autosave keeps a user counted as an @@ -95,7 +97,7 @@ func (s *Service) publishSelfPresence(userID, pageID, spaceID string, editors [] } // broadcastPagePresence fans a page_presence_updated event out to the space audience on channelID -// (the space's backing channel), carrying the current active-editor set, snapshot_at, and active_timeout_ms. +// (the space's backing channel), carrying the current presence snapshot. // Best-effort: failures are logged, never surfaced. The return value reports whether a snapshot was // actually published, so a throttling caller can release its claimed slot on failure; a nil client // (store-only unit tests) is a deliberate no-op, not a failure. @@ -173,20 +175,11 @@ func (s *Service) endDraftPresenceSession(pageWasLive bool, pageID, userID, spac s.broadcastPagePresence(pageID, spaceID, channelID) } -// PageActiveEditors is the editor-presence snapshot returned by the REST active-editors endpoint. Its -// fields mirror the page_presence_updated WebSocket payload (active_editors, snapshot_at, active_timeout_ms) -// so a client sees the same presence contract whether it resyncs over REST or receives a live event. -type PageActiveEditors struct { - ActiveEditors []string `json:"active_editors"` - SnapshotAt int64 `json:"snapshot_at"` - ActiveTimeoutMs int64 `json:"active_timeout_ms"` -} - // GetPageActiveEditors returns the editor-presence snapshot for the given page in the given space, // after confirming the page exists in that space. Returns 404 if the page is unknown or belongs to // another space; store failures are propagated (unlike the best-effort getActiveEditors, this backs // a REST read that must not report "nobody editing" when the query actually failed). -func (s *Service) GetPageActiveEditors(pageID, spaceID string) (*PageActiveEditors, *mmmodel.AppError) { +func (s *Service) GetPageActiveEditors(pageID, spaceID string) (*model.PageActiveEditors, *mmmodel.AppError) { if !mmmodel.IsValidId(pageID) { return nil, mmmodel.NewAppError("GetPageActiveEditors", "app.page.presence.invalid_page_id.app_error", nil, "", http.StatusBadRequest) } @@ -206,7 +199,7 @@ func (s *Service) GetPageActiveEditors(pageID, spaceID string) (*PageActiveEdito if storeErr != nil { return nil, storeAppError("GetPageActiveEditors", storeErr) } - return &PageActiveEditors{ + return &model.PageActiveEditors{ ActiveEditors: editors, SnapshotAt: snapshotAt, ActiveTimeoutMs: ActiveEditorTimeoutMs, diff --git a/server/app/service.go b/server/app/service.go index d2e69be..6091b95 100644 --- a/server/app/service.go +++ b/server/app/service.go @@ -149,7 +149,10 @@ func storeAppError(where string, err error) *mmmodel.AppError { case store.ReasonSubtreeMaxDepthExceeded: return mmmodel.NewAppError(where, "app.page.subtree_max_depth_exceeded.app_error", map[string]any{"MaxDepth": limitErr.Limit}, "", http.StatusBadRequest).Wrap(err) case store.ReasonDraftQuotaExceeded: - return mmmodel.NewAppError(where, "app.page_draft.quota_exceeded.app_error", nil, "", http.StatusTooManyRequests).Wrap(err) + // 422, not 429: the caller is over a standing per-space draft cap, not sending too many + // requests. Retrying the same request never succeeds until a draft is discarded, so a + // rate-limit code would send clients into a wait-and-retry loop. + return mmmodel.NewAppError(where, "app.page_draft.quota_exceeded.app_error", nil, "", http.StatusUnprocessableEntity).Wrap(err) } return mmmodel.NewAppError(where, "app.store.too_large.app_error", map[string]any{"Limit": limitErr.Limit}, "", http.StatusUnprocessableEntity).Wrap(err) default: diff --git a/server/model/draft.go b/server/model/draft.go index 03c12b1..9ad4400 100644 --- a/server/model/draft.go +++ b/server/model/draft.go @@ -158,8 +158,8 @@ func (d *Draft) IsValid() *mmmodel.AppError { return mmmodel.NewAppError("Draft.IsValid", "model.draft.is_valid.body_size.app_error", nil, "page_id="+d.PageId, http.StatusBadRequest) } - if utf8.RuneCountInString(mmmodel.ArrayToJSON(d.FileIds)) > DraftFileIdsMaxRunes { - return mmmodel.NewAppError("Draft.IsValid", "model.draft.is_valid.file_ids.app_error", nil, "page_id="+d.PageId, http.StatusBadRequest) + if err := ValidateDraftFileIds("Draft.IsValid", "page_id="+d.PageId, d.FileIds); err != nil { + return err } if err := ValidatePropsSize("Draft.IsValid", "page_id="+d.PageId, d.Props, PagePropsMaxBytes); err != nil { @@ -169,6 +169,40 @@ func (d *Draft) IsValid() *mmmodel.AppError { return nil } +// ValidateDraftFileIds checks that the serialized file-id list stays within DraftFileIdsMaxRunes and +// that every entry is a well-formed ID. An empty list is valid; an empty entry is not, since a blank +// ID names no file and would be stored verbatim. +func ValidateDraftFileIds(where, detail string, fileIDs mmmodel.StringArray) *mmmodel.AppError { + if utf8.RuneCountInString(mmmodel.ArrayToJSON(fileIDs)) > DraftFileIdsMaxRunes { + return mmmodel.NewAppError(where, "model.draft.is_valid.file_ids.app_error", nil, detail, http.StatusBadRequest) + } + for _, fileID := range fileIDs { + if !mmmodel.IsValidId(fileID) { + return mmmodel.NewAppError(where, "model.draft.is_valid.file_id.app_error", nil, detail, http.StatusBadRequest) + } + } + return nil +} + +// ValidateDraftWriteIntent applies the FileIds and Props bounds to the pointer-carried write-intent +// values a draft upsert takes alongside the Draft struct. Those values never reach the struct's own +// fields — the upsert merges them in SQL — so IsValid cannot see them, and without this the bounds +// would hold only for callers that remembered to check them separately. A nil pointer means the +// field was omitted and the stored value is preserved, so there is nothing to bound. +func ValidateDraftWriteIntent(where, detail string, fileIDs *mmmodel.StringArray, props *mmmodel.StringInterface) *mmmodel.AppError { + if fileIDs != nil { + if err := ValidateDraftFileIds(where, detail, *fileIDs); err != nil { + return err + } + } + if props != nil { + if err := ValidatePropsSize(where, detail, *props, PagePropsMaxBytes); err != nil { + return err + } + } + return nil +} + // GetProps returns Props, or an empty map if Props is nil. func (d *Draft) GetProps() mmmodel.StringInterface { return ensureProps(d.Props) diff --git a/server/model/page_presence.go b/server/model/page_presence.go new file mode 100644 index 0000000..23873a9 --- /dev/null +++ b/server/model/page_presence.go @@ -0,0 +1,17 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package model + +// PageActiveEditors is the editor-presence snapshot for a page: the users counted as editing it, +// when the snapshot was taken, and how long an entry stays current without a further save. A client +// that receives no newer snapshot expires the list itself once ActiveTimeoutMs has elapsed since +// SnapshotAt. +// +// The REST active-editors endpoint and the page_presence_updated WebSocket payload carry these same +// fields, so a client reads one presence contract whether it resyncs or receives a live event. +type PageActiveEditors struct { + ActiveEditors []string `json:"active_editors"` + SnapshotAt int64 `json:"snapshot_at"` + ActiveTimeoutMs int64 `json:"active_timeout_ms"` +} diff --git a/server/store/draft_store.go b/server/store/draft_store.go index 554b900..47fbbdf 100644 --- a/server/store/draft_store.go +++ b/server/store/draft_store.go @@ -230,8 +230,9 @@ FROM chain`, model.MaxPageDepth, model.MaxPageDepth) // props encodes the write intent for the Props column: nil means "omitted — preserve the existing // stored map", and a non-nil pointer replaces the whole map with the pointed-to value (an empty map // clears all keys). This is a whole-value replace, not a key-wise merge, mirroring parentID/fileIDs. -// The written value's serialized size must be validated by the caller (App layer): the struct's own -// Props field — the only one IsValid checks — is not what gets written. +// +// The pointed-to fileIDs and props values are bounded by model.ValidateDraftWriteIntent here, since +// the struct's own fields — the only ones IsValid checks — are not what gets written. func (s *Store) UpsertDraft(draft *model.Draft, parentID *string, fileIDs *mmmodel.StringArray, props *mmmodel.StringInterface) (*model.Draft, bool, error) { return s.upsertDraft(draft, parentID, fileIDs, props, false) } @@ -281,6 +282,11 @@ func (s *Store) upsertDraft(draft *model.Draft, parentID *string, fileIDs *mmmod if validErr := draft.IsValid(); validErr != nil { return nil, false, &ErrInvalidInput{Entity: "Draft", Field: "IsValid", Value: validErr.Error(), Reason: validErr.Id} } + // The pointer-carried values are merged in SQL below and never populate the struct's own fields, + // so IsValid does not cover them; bound them here, where every caller of the upsert passes. + if validErr := model.ValidateDraftWriteIntent("Draft.IsValid", "page_id="+draft.PageId, fileIDs, props); validErr != nil { + return nil, false, &ErrInvalidInput{Entity: "Draft", Field: "IsValid", Value: validErr.Error(), Reason: validErr.Id} + } tx, err := s.db.Beginx() if err != nil { @@ -678,9 +684,10 @@ func (s *Store) GetDraftsForSpace(userID, spaceID string, offset, limit int) ([] // a draft at the same reserved page id would otherwise appear in each other's presence set. Scoping // on the draft's own SpaceId keeps a broadcast to one space from disclosing the other space's editor. // -// The filter is LastActiveAt, not UpdateAt: UpdateAt also moves when a bulk maintenance write -// touches the row (a page delete reparents its pending child drafts; a move-to-space re-homes -// them), which would report the draft's owner as editing a page they never opened. +// The filter is LastActiveAt, not UpdateAt (see model.Draft): the maintenance writes that bump +// UpdateAt without the user touching the draft — a page delete reparenting its pending child +// drafts, a move-to-space re-homing them — would otherwise report the draft's owner as editing a +// page they never opened. func (s *Store) GetPageActiveEditors(pageID, spaceID string, minActiveAt int64) ([]string, error) { if pageID == "" { return nil, &ErrInvalidInput{Entity: "Draft", Field: "pageId", Value: pageID} From 8cfb94f9b4ec36fd93f21a81c9a151eaa4a53403 Mon Sep 17 00:00:00 2001 From: "Catalin I. Tomai" Date: Tue, 28 Jul 2026 12:07:00 +0200 Subject: [PATCH 36/36] address coderabbitai comments --- server/api_page_drafts_test.go | 12 ++++++------ server/app/page_content_test.go | 30 ++++++++++++++++++++++++++++++ server/app/page_draft_test.go | 6 +++--- server/model/page_content.go | 16 +++++++++++++++- server/model/page_content_test.go | 20 +++++++++++--------- 5 files changed, 65 insertions(+), 19 deletions(-) diff --git a/server/api_page_drafts_test.go b/server/api_page_drafts_test.go index 1012acf..ccc765a 100644 --- a/server/api_page_drafts_test.go +++ b/server/api_page_drafts_test.go @@ -117,14 +117,14 @@ func TestHandler_PublishMalformedBodyReturns400(t *testing.T) { require.Equal(t, http.StatusOK, rec.Code, "a malformed publish body must not publish or delete the draft") } -// TestHandler_UpdatePageDraftRequiresExistingDraft confirms the update-only guard: PUT on a page id +// TestHandler_UpdatePageDraftRequiresExistingDraft confirms the update-only guard: PATCH on a page id // that has no existing draft must return 404 rather than silently creating one. func TestHandler_UpdatePageDraftRequiresExistingDraft(t *testing.T) { h := openTestPlugin(t, nil) space := seedSpace(t, h.store, mmmodel.NewId()) userID := mmmodel.NewId() - // No draft has been created for this page id — the PUT must be rejected. + // No draft has been created for this page id — the PATCH must be rejected. rec := h.do(t, http.MethodPatch, "/api/v1/spaces/"+space.Id+"/pages/"+mmmodel.NewId()+"/draft", userID, map[string]any{ "title": "ghost", }) @@ -132,7 +132,7 @@ func TestHandler_UpdatePageDraftRequiresExistingDraft(t *testing.T) { } // TestHandler_UpdatePageDraftClearsParentWhenParentIdIsEmptyString covers the null-vs-empty -// distinction: sending parent_id: "" in the PUT body must clear an existing parent (set it to +// distinction: sending parent_id: "" in the PATCH body must clear an existing parent (set it to // root), while omitting parent_id entirely must leave the parent unchanged. func TestHandler_UpdatePageDraftClearsParentWhenParentIdIsEmptyString(t *testing.T) { h := openTestPlugin(t, nil) @@ -156,7 +156,7 @@ func TestHandler_UpdatePageDraftClearsParentWhenParentIdIsEmptyString(t *testing require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &childDraft)) require.Equal(t, parentDraft.PageId, childDraft.ParentId) - // PUT with parent_id omitted must preserve the existing parent. + // PATCH with parent_id omitted must preserve the existing parent. rec = h.do(t, http.MethodPatch, base+"/pages/"+childDraft.PageId+"/draft", userID, map[string]any{ "title": "Child updated", }) @@ -165,7 +165,7 @@ func TestHandler_UpdatePageDraftClearsParentWhenParentIdIsEmptyString(t *testing require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &saved)) require.Equal(t, parentDraft.PageId, saved.ParentId, "omitting parent_id must preserve the existing parent") - // PUT with parent_id: "" must clear the parent to root. + // PATCH with parent_id: "" must clear the parent to root. rec = h.do(t, http.MethodPatch, base+"/pages/"+childDraft.PageId+"/draft", userID, map[string]any{ "parent_id": "", }) @@ -198,7 +198,7 @@ func TestHandler_UpdatePageDraftCreatesForExistingPage(t *testing.T) { require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &page)) require.Equal(t, pageID, page.Id) - // Step 2: open an edit session — first PUT creates the draft for an existing page. + // Step 2: open an edit session — first PATCH creates the draft for an existing page. rec = h.do(t, http.MethodPatch, base+"/pages/"+pageID+"/draft", userID, map[string]any{ "title": "Original", "base_edit_at": page.EditAt, diff --git a/server/app/page_content_test.go b/server/app/page_content_test.go index 2bdfc2d..6c8da30 100644 --- a/server/app/page_content_test.go +++ b/server/app/page_content_test.go @@ -115,6 +115,36 @@ func TestNormalizePatchContent(t *testing.T) { }) } +// TestNormalizeContentBody covers the draft autosave entry point, the only one that sanitizes a body +// without deriving SearchText — publish derives it later from the same content. +func TestNormalizeContentBody(t *testing.T) { + t.Run("an empty body is left alone", func(t *testing.T) { + body, appErr := normalizeContentBody("test", "") + require.Nil(t, appErr) + require.Empty(t, body) + }) + + t.Run("valid TipTap content is normalized", func(t *testing.T) { + body, appErr := normalizeContentBody("test", + `{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"hello"}]}]}`) + require.Nil(t, appErr) + require.Contains(t, body, "hello") + }) + + t.Run("dangerous URLs are stripped", func(t *testing.T) { + body, appErr := normalizeContentBody("test", + `{"type":"doc","content":[{"type":"text","text":"x","marks":[{"type":"link","attrs":{"href":"javascript:alert(1)"}}]}]}`) + require.Nil(t, appErr) + require.NotContains(t, body, "javascript:alert") + }) + + t.Run("an unlisted node type is rejected", func(t *testing.T) { + _, appErr := normalizeContentBody("test", `{"type":"doc","content":[{"type":"script"}]}`) + require.NotNil(t, appErr) + require.Equal(t, http.StatusBadRequest, appErr.StatusCode) + }) +} + func TestNormalizeContentRejectsOversizedBodyBeforeParsing(t *testing.T) { // The size gate must run before json.Unmarshal, so the stored-body limit — not the larger // request-transport cap — bounds the parse allocation. diff --git a/server/app/page_draft_test.go b/server/app/page_draft_test.go index ae370e2..d8db064 100644 --- a/server/app/page_draft_test.go +++ b/server/app/page_draft_test.go @@ -804,9 +804,9 @@ func TestUpdatePageDraftRejectsDraftHierarchyTooDeep(t *testing.T) { // Build a chain of model.MaxPageDepth+1 drafts. The first draft is the root // (no parent). Each subsequent draft sets its parent to the previous one. - // model.MaxPageDepth is 10; a chain of 10 drafts fills the limit, so - // adding one more child is the first rejection. - const chainLen = 10 + // A chain of model.MaxPageDepth drafts fills the limit, so adding one more + // child is the first rejection. + const chainLen = model.MaxPageDepth drafts := make([]*model.Draft, chainLen) for i := range chainLen { d, appErr := h.svc.CreateSpaceDraft(userID, space.Id, fmt.Sprintf("D%d", i), "") diff --git a/server/model/page_content.go b/server/model/page_content.go index 1135580..e4f45a9 100644 --- a/server/model/page_content.go +++ b/server/model/page_content.go @@ -258,11 +258,20 @@ func stripDangerousKeys(m map[string]any) { // Trim leading/trailing whitespace and control characters before matching: an HTML // tokenizer treats "\tonclick" as the onclick attribute, so a key that fails to match // here because of such a prefix would carry its payload through every check below. - lower := strings.ToLower(trimBrowserIgnoredChars(key)) + trimmed := trimBrowserIgnoredChars(key) + lower := strings.ToLower(trimmed) if _, dangerous := dangerousAttrKeys[lower]; strings.HasPrefix(lower, "on") || dangerous { delete(m, key) continue } + // A padded key is not a name any editor schema defines — the padding exists only to slip + // past a matcher that does not trim. Drop it outright instead of sanitizing its value in + // place, so a renderer that resolves the trimmed name cannot find both a padded and a + // canonical entry for the same attribute. + if trimmed != key { + delete(m, key) + continue + } // A data-* attribute may carry a URL a lenient client renderer treats as a navigation // target, but it is not necessarily one: an editor extension may store a ratio ("16:9"), a // timestamp ("12:30"), or any other colon-bearing value under a data-* key. The strict URL @@ -350,6 +359,11 @@ func sanitizeObjAttrsAndFlatKeys(obj map[string]any, attrsErrMsg string, skipKey if !ok { return errors.New(attrsErrMsg) } + // The attrs subtree restarts the depth budget at 0, while the flat-key values below inherit + // the node's depth and so get what remains of it. Both fail closed at maxTipTapDepth, and + // attrs nesting is a property of the attribute value rather than of the document tree, so + // charging it against the node's remaining depth would bound it by where the node happens + // to sit. The asymmetry is deliberate. if err := sanitizeAttrs(attrs, 0); err != nil { return err } diff --git a/server/model/page_content_test.go b/server/model/page_content_test.go index 5e9c55b..edc00c0 100644 --- a/server/model/page_content_test.go +++ b/server/model/page_content_test.go @@ -128,11 +128,11 @@ func TestParseTipTapDocumentSanitizesURLs(t *testing.T) { } } -// TestParseTipTapDocumentRejectsForbiddenTypes pins the node/mark denylist — the strongest -// defense in the sanitizer — by asserting that a document carrying a forbidden type is rejected +// TestParseTipTapDocumentRejectsForbiddenTypes pins the node/mark allowlist — the strongest +// defense in the sanitizer — by asserting that a document carrying an unlisted type is rejected // outright rather than stripped and accepted. func TestParseTipTapDocumentRejectsForbiddenTypes(t *testing.T) { - // Every forbidden node type, plus a couple of allowed ones as a control. + // Types a lenient renderer could execute, plus a couple of allowed ones as a control. nodeCases := []struct { nodeType string rejected bool @@ -151,7 +151,7 @@ func TestParseTipTapDocumentRejectsForbiddenTypes(t *testing.T) { {"animatetransform", true}, {"foreignobject", true}, {"maction", true}, - {"SCRIPT", true}, // denylist is case-insensitive + {"SCRIPT", true}, // the allowlist is case-sensitive, so a cased variant is not listed {"IFrame", true}, {"paragraph", false}, // control: allowed } @@ -172,7 +172,7 @@ func TestParseTipTapDocumentRejectsForbiddenTypes(t *testing.T) { }) } - // Forbidden mark types on an otherwise-valid text node. "link" is intentionally absent — it is a + // Unlisted mark types on an otherwise-valid text node. "link" is intentionally absent — it is a // valid mark whose href is sanitized rather than blocked (see TestParseTipTapDocumentSanitizesURLs). markCases := []struct { markType string @@ -183,7 +183,7 @@ func TestParseTipTapDocumentRejectsForbiddenTypes(t *testing.T) { {"style", true}, {"svg", true}, {"foreignobject", true}, - {"MAction", true}, // case-insensitive + {"MAction", true}, // cased variant of an unlisted type is still unlisted {"link", false}, // control: allowed as a mark {"bold", false}, // control: ordinary formatting mark {"", true}, // a mark with an empty type is rejected, matching node-type strictness @@ -719,8 +719,9 @@ func TestBuildSearchText(t *testing.T) { func TestParseTipTapDocumentSanitizesWhitespacePrefixedAttrKeys(t *testing.T) { // An HTML tokenizer treats "\tonclick" as the onclick attribute, so keys carrying leading or - // trailing whitespace/control characters must match the handler denylist and URL allowlist the - // same as their clean forms. + // trailing whitespace/control characters must be recognized the same as their clean forms. No + // editor schema defines a padded name, so every one of them is dropped rather than kept with a + // sanitized value — a renderer resolving the trimmed name must not find two entries for it. raw := map[string]any{ "type": "doc", "content": []any{ @@ -746,6 +747,7 @@ func TestParseTipTapDocumentSanitizesWhitespacePrefixedAttrKeys(t *testing.T) { require.NotContains(t, attrs, " onclick", "whitespace-prefixed event handler must be stripped") require.NotContains(t, attrs, "\tonerror", "control-char-prefixed event handler must be stripped") - require.Equal(t, "", attrs[" href"], "whitespace-prefixed URL key must pass through sanitizeURL") + require.NotContains(t, attrs, " href", "whitespace-prefixed URL key must be dropped, not kept sanitized") + require.NotContains(t, attrs, "href", "dropping the padded key must not introduce a canonical one") require.Equal(t, "a cat", attrs["alt"], "clean attr must survive") }