From 05e38aee5df19e45b5143cbca925ef776267f999 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Sun, 18 Oct 2026 19:37:12 +0200 Subject: [PATCH] fix: purge the host site cache on Journal API post writes Admin saves purged the site cache; the token API's create, update, delete and featured-image writes did not, so API-published posts waited for the site's cache TTL. --- README.md | 6 +++--- controllers/api/posts.go | 14 ++++++++++++++ site_cache_admin_test.go | 33 +++++++++++++++++++++++++++++++++ 3 files changed, 50 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index d173247..6630c1a 100644 --- a/README.md +++ b/README.md @@ -266,7 +266,7 @@ Category content stays on the Blog Editor (`blogeditor`) field type; it does not ### Site cache purge -A host site that caches its pages (for example a Nuxt app with `swr` route rules) would keep serving a page rendered before an admin edit until its cache entry expires. An admin save of a Journal post or category asks the host site to drop its page caches, so the edit shows on the next page view. The purge itself is the framework's site-cache purger (`cooler.SiteCachePurger`), shared with the menu and FAQ plugins, so one setting serves every plugin: +A host site that caches its pages (for example a Nuxt app with `swr` route rules) would keep serving a page rendered before an admin edit until its cache entry expires. An admin save of a Journal post or category, and every post write through the token API (create, update, delete, featured image upload and removal), asks the host site to drop its page caches, so the edit shows on the next page view. The purge itself is the framework's site-cache purger (`cooler.SiteCachePurger`), shared with the menu and FAQ plugins, so one setting serves every plugin: | Key | Environment variable | Value | |-----|----------------------|-------| @@ -286,9 +286,9 @@ With only these set, Boot validates them with the same rules (errors name the Jo The request is a `POST` to the URL with `Authorization: Bearer `, `Content-Type: application/json` and the body `{"kind":"post","slug":""}` or `{"kind":"category","slug":""}`. The receiver answers `200` with `{"purged":}`, the number of cache entries it removed. -- **Timing.** The purge is registered with `lagoon.AfterCommit` on the admin save transaction: it runs only after the save commits, and never for a save that fails, so a concurrent page view cannot re-cache the old content. It runs before the save response, so the cache is clear when the administrator sees the save confirmed. +- **Timing.** The purge is registered with `lagoon.AfterCommit` on the admin save transaction: it runs only after the save commits, and never for a save that fails, so a concurrent page view cannot re-cache the old content. It runs before the save response, so the cache is clear when the administrator sees the save confirmed. The token API writes run outside a transaction: their purge is queued once the write succeeded and sent in the background, so the response does not wait for the site. - **Never fails a save.** The request has a 2 s timeout and ignores the cancellation of the admin request. A refused, failing, slow or unreachable purge is logged as a warning (`cooler: site cache purge failed`) and the save response is unchanged; a successful one logs `cooler: site cache purged` with the kind, slug, status and purged count. The token and the Authorization header are never logged. -- **What purges.** Admin creates, updates and deletes of posts and categories, including a save that changes nothing. Tags, the posts import action and the public Journal API writes do not purge. +- **What purges.** Admin creates, updates and deletes of posts and categories, including a save that changes nothing, and the Journal API post writes (create, update, delete, featured image upload and removal). Tags and the posts import action do not purge. - **Receiver.** The host's Nuxt app built on vue-journal-layer 0.4.0 or later provides the shared route `POST /api/__site__/cache/purge` with its token in `NUXT_SITE_CACHE_PURGE_TOKEN`; the old route `POST /api/__journal__/cache/purge` with `NUXT_JOURNAL_CACHE_PURGE_TOKEN` stays for one release. Generate one token on the server and put the same value in both environments. ## API reference diff --git a/controllers/api/posts.go b/controllers/api/posts.go index 3507def..01975a4 100644 --- a/controllers/api/posts.go +++ b/controllers/api/posts.go @@ -13,6 +13,7 @@ import ( "git.golem15.com/golem15/summercms/modules/backpack" "git.golem15.com/golem15/summercms/modules/bouncer" "git.golem15.com/golem15/summercms/modules/cabana" + "git.golem15.com/golem15/summercms/modules/cooler" "git.golem15.com/golem15/summercms/modules/lagoon" "git.golem15.com/golem15/summercms/modules/lagoon/attach" "git.golem15.com/golem15/summercms/modules/wire" @@ -230,6 +231,7 @@ func Store(app *backpack.App) http.HandlerFunc { writeAPIError(w, http.StatusInternalServerError, "Internal server error") return } + purgeSiteCache(r, app, post) wire.WriteJSON(w, http.StatusCreated, map[string]any{"data": serializePost(post, db.WithContext(r.Context()))}) } } @@ -283,6 +285,7 @@ func Update(app *backpack.App) http.HandlerFunc { writeAPIError(w, http.StatusInternalServerError, "Internal server error") return } + purgeSiteCache(r, app, post) wire.WriteJSON(w, http.StatusOK, map[string]any{"data": serializePost(post, db.WithContext(r.Context()))}) } } @@ -315,6 +318,7 @@ func Destroy(app *backpack.App) http.HandlerFunc { writeAPIError(w, http.StatusInternalServerError, "Internal server error") return } + purgeSiteCache(r, app, post) wire.WriteJSON(w, http.StatusOK, map[string]any{"message": msgPostDeleted}) } } @@ -376,6 +380,7 @@ func UploadFeaturedImage(app *backpack.App) http.HandlerFunc { stored.AttachmentType = post.MorphName() stored.AttachmentID = idStr stored.Field = "featured_images" + purgeSiteCache(r, app, post) wire.WriteJSON(w, http.StatusCreated, map[string]any{ "data": map[string]any{ "id": stored.ID, @@ -387,6 +392,14 @@ func UploadFeaturedImage(app *backpack.App) http.HandlerFunc { } } +// purgeSiteCache queues the host site's cache purge for post, as the admin +// save hooks do. The API writes run outside a transaction, so the purge is +// queued at once and never delays the response. Without site_cache.purge_url +// it does nothing. +func purgeSiteCache(r *http.Request, app *backpack.App, post *models.Post) { + cooler.PurgeSiteCacheAfterCommit(r.Context(), nil, cooler.SiteCacheFromApp(app), "post", post.Slug) +} + // DeleteFeaturedImage is DELETE /_journal/api/v1/posts/{id}/featured-images/{fileId}. func DeleteFeaturedImage(app *backpack.App) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { @@ -430,6 +443,7 @@ func DeleteFeaturedImage(app *backpack.App) http.HandlerFunc { if bucket, ok := app.Lookup[*blob.Bucket](); ok && bucket != nil { _ = attach.DeleteKeys(r.Context(), bucket, keys) } + purgeSiteCache(r, app, post) wire.WriteJSON(w, http.StatusOK, map[string]any{"message": msgImageRemoved}) } } diff --git a/site_cache_admin_test.go b/site_cache_admin_test.go index 42984e4..ffb35ef 100644 --- a/site_cache_admin_test.go +++ b/site_cache_admin_test.go @@ -387,3 +387,36 @@ func TestCategoriesAdminFrameworkKeysWin(t *testing.T) { t.Fatalf("the deprecated Journal target got %d requests: %+v", len(got), got) } } + +// TestJournalAPIPurgesSiteCache proves the token API's post writes purge +// the host site's cache like admin saves: create, update and delete each +// send one purge with kind post and the post's slug. +func TestJournalAPIPurgesSiteCache(t *testing.T) { + recv := newPurgeReceiver(t) + env := newPostsEnv(t, recv.env()...) + recv.drainFrom(t, env.app) + token := env.admin(t, controllers.PermissionAccessPosts) + + rec := env.expect(t, http.StatusCreated, http.MethodPost, "/_journal/api/v1/posts", token, map[string]any{ + "title": "Api purge", + "content": "Before", + }) + var created struct { + Data struct { + ID uint `json:"id"` + Slug string `json:"slug"` + } `json:"data"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &created); err != nil || created.Data.ID == 0 { + t.Fatalf("create body %s: %v", rec.Body.String(), err) + } + slug := created.Data.Slug + recv.expectOnePurge(t, "create", "post", slug) + + itemPath := fmt.Sprintf("/_journal/api/v1/posts/%d", created.Data.ID) + env.expect(t, http.StatusOK, http.MethodPut, itemPath, token, map[string]any{"content": "After"}) + recv.expectOnePurge(t, "update", "post", slug) + + env.expect(t, http.StatusOK, http.MethodDelete, itemPath, token, nil) + recv.expectOnePurge(t, "delete", "post", slug) +}