feat(16.2-07): purge the host site cache after a category admin update

- classes.SiteCachePurger posts {kind, slug} with a bearer token to
  golem15.journal.site_cache.purge_url, 2 s timeout, logged, never fails a save
- PurgeSiteCacheAfterCommit runs it through lagoon.AfterCommit
- Boot builds and publishes the purger from the two config keys
- categories FormAfterUpdate registers the purge; Postgres test proves
  one authorized request after the commit
This commit is contained in:
2026-10-10 18:19:58 +02:00
parent 0efaa04be5
commit f3b223f131
5 changed files with 376 additions and 0 deletions

139
classes/site_cache.go Normal file
View File

@@ -0,0 +1,139 @@
package classes
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"time"
"git.golem15.com/golem15/summercms/modules/lagoon"
"gorm.io/gorm"
)
const (
// SiteCachePurgeURLKey is the config key of the host site's cache purge
// URL (env SUMMER_GOLEM15__JOURNAL__SITE_CACHE__PURGE_URL).
SiteCachePurgeURLKey = "golem15.journal.site_cache.purge_url"
// SiteCachePurgeTokenKey is the config key of the bearer token the purge
// request carries (env SUMMER_GOLEM15__JOURNAL__SITE_CACHE__PURGE_TOKEN).
SiteCachePurgeTokenKey = "golem15.journal.site_cache.purge_token"
// SiteCachePurgeTimeout bounds one purge request.
SiteCachePurgeTimeout = 2 * time.Second
// siteCacheAnswerLimit is how much of the purge answer is read.
siteCacheAnswerLimit = 4 << 10
)
// SiteCachePurger asks the host site to drop its page caches after an admin
// save of a Journal post or category. A nil *SiteCachePurger is disabled
// and does nothing.
type SiteCachePurger struct {
url string
token string
client *http.Client
}
// NewSiteCachePurger returns the purger for purgeURL and token. With both
// empty the purge is disabled and it returns nil, nil.
func NewSiteCachePurger(purgeURL, token string) (*SiteCachePurger, error) {
if purgeURL == "" && token == "" {
return nil, nil
}
u, err := url.Parse(purgeURL)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
return nil, fmt.Errorf("%s must be an http or https URL with a host", SiteCachePurgeURLKey)
}
if token == "" {
return nil, errors.New(SiteCachePurgeTokenKey + " must be set")
}
return &SiteCachePurger{url: purgeURL, token: token, client: &http.Client{}}, nil
}
type siteCachePurgeBody struct {
Kind string `json:"kind"`
Slug string `json:"slug"`
}
// Purge sends the purge request for one saved entity: a POST of
// {"kind":kind,"slug":slug} with the bearer token, bounded by
// SiteCachePurgeTimeout and detached from ctx's cancellation. It logs the
// outcome and returns nothing: a purge never fails a save. The token is
// never logged.
func (p *SiteCachePurger) Purge(ctx context.Context, kind, slug string) {
if p == nil {
return
}
if ctx == nil {
ctx = context.Background()
}
ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), SiteCachePurgeTimeout)
defer cancel()
log := slog.Default()
raw, err := json.Marshal(siteCachePurgeBody{Kind: kind, Slug: slug})
if err != nil {
log.Warn("journal: site cache purge failed", "kind", kind, "slug", slug, "error", err.Error())
return
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.url, bytes.NewReader(raw))
if err != nil {
log.Warn("journal: site cache purge failed", "kind", kind, "slug", slug, "error", "invalid request")
return
}
req.Header.Set("Authorization", "Bearer "+p.token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
res, err := p.client.Do(req)
if err != nil {
log.Warn("journal: site cache purge failed", "kind", kind, "slug", slug, "error", purgeTransportError(err))
return
}
defer res.Body.Close()
body, _ := io.ReadAll(io.LimitReader(res.Body, siteCacheAnswerLimit))
if res.StatusCode < 200 || res.StatusCode > 299 {
log.Warn("journal: site cache purge failed", "kind", kind, "slug", slug, "status", res.StatusCode)
return
}
attrs := []any{"kind", kind, "slug", slug, "status", res.StatusCode}
var answer struct {
Purged *int `json:"purged"`
}
if json.Unmarshal(body, &answer) == nil && answer.Purged != nil {
attrs = append(attrs, "purged", *answer.Purged)
}
log.Info("journal: site cache purged", attrs...)
}
// purgeTransportError describes a failed request without the request URL
// or headers.
func purgeTransportError(err error) string {
var uerr *url.Error
if errors.As(err, &uerr) {
err = uerr.Err
}
if errors.Is(err, context.DeadlineExceeded) {
return "timeout"
}
return err.Error()
}
// PurgeSiteCacheAfterCommit registers p.Purge(kind, slug) to run after the
// transaction db belongs to commits, through lagoon.AfterCommit: a failed
// save never purges, and a concurrent page view cannot re-cache the old
// content between the purge and the commit. A nil p does nothing.
func PurgeSiteCacheAfterCommit(ctx context.Context, db *gorm.DB, p *SiteCachePurger, kind, slug string) {
if p == nil {
return
}
lagoon.AfterCommit(ctx, db, func(ctx context.Context, _ *gorm.DB) {
p.Purge(ctx, kind, slug)
})
}

View File

@@ -60,6 +60,17 @@ func (categoriesAdminController) FormBeforeUpdate(ctx context.Context, model any
return prepareCategoryWrite(cat)
}
// FormAfterUpdate purges the host site's page caches once the save
// transaction commits (G-16.2-2, D-19).
func (c categoriesAdminController) FormAfterUpdate(ctx context.Context, model any) error {
cat, ok := model.(*models.Category)
if !ok || cat == nil {
return errors.New("categories admin: form model is not a category")
}
purgeSiteCache(ctx, c.app, "category", cat.Slug)
return nil
}
// prepareCategoryWrite stores blank category content (white space only) as
// NULL and accepts any other content it receives. Since Phase 16.2 the
// content field is the blogeditor type, whose Decode already stored the HTML
@@ -84,5 +95,6 @@ var (
_ pact.AdminRecordSource = categoriesAdminController{}
_ pact.FormBeforeCreate = categoriesAdminController{}
_ pact.FormBeforeUpdate = categoriesAdminController{}
_ pact.FormAfterUpdate = categoriesAdminController{}
_ cabana.FieldRelationProvider = categoriesAdminController{}
)

32
controllers/site_cache.go Normal file
View File

@@ -0,0 +1,32 @@
package controllers
import (
"context"
"git.golem15.com/golem15/sm-journal-plugin/classes"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/cabana"
)
// purgeSiteCache registers a site cache purge for one saved entity to run
// after the save transaction commits. Without a published
// *classes.SiteCachePurger (no purge configured) or outside a save
// transaction it does nothing.
func purgeSiteCache(ctx context.Context, app func() *backpack.App, kind, slug string) {
if app == nil {
return
}
a := app()
if a == nil {
return
}
purger, ok := a.Lookup[*classes.SiteCachePurger]()
if !ok || purger == nil {
return
}
tx, ok := cabana.TxFromContext(ctx)
if !ok {
return
}
classes.PurgeSiteCacheAfterCommit(ctx, tx, purger, kind, slug)
}

View File

@@ -2,11 +2,13 @@ package journal
import (
"embed"
"fmt"
"io/fs"
"net/http"
"strconv"
"time"
"git.golem15.com/golem15/sm-journal-plugin/classes"
"git.golem15.com/golem15/sm-journal-plugin/console"
"git.golem15.com/golem15/sm-journal-plugin/models"
"git.golem15.com/golem15/sm-journal-plugin/updates"
@@ -48,9 +50,33 @@ func (p *Plugin) Register(app *backpack.App) error {
func (p *Plugin) Boot(app *backpack.App) error {
p.app = app
if err := p.wireSiteCache(app); err != nil {
return err
}
return p.wireSearch(app)
}
// wireSiteCache builds the site cache purger from
// golem15.journal.site_cache.purge_url and purge_token and publishes it, so
// admin saves of posts and categories purge the host site's page caches.
// With neither key set nothing is published.
func (p *Plugin) wireSiteCache(app *backpack.App) error {
if app == nil || app.Config == nil {
return nil
}
purger, err := classes.NewSiteCachePurger(
app.Config.String(classes.SiteCachePurgeURLKey),
app.Config.String(classes.SiteCachePurgeTokenKey),
)
if err != nil {
return fmt.Errorf("golem15.journal: site cache: %w", err)
}
if purger == nil {
return nil
}
return app.Publish(purger)
}
func (p *Plugin) LangFS() fs.FS { return langFS }
func (p *Plugin) Migrations() []*gormigrate.Migration { return updates.All() }

167
site_cache_admin_test.go Normal file
View File

@@ -0,0 +1,167 @@
package journal_test
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"git.golem15.com/golem15/sm-journal-plugin/controllers"
"git.golem15.com/golem15/sm-journal-plugin/models"
)
// purgeTestToken is a 40-character purge token for the receiver.
const purgeTestToken = "purge-test-token-0123456789abcdefghijklm"
const purgeTestPath = "/api/__journal__/cache/purge"
// purgeRequest is one request the purge receiver got.
type purgeRequest struct {
Method string
Path string
Authorization string
ContentType string
Body map[string]string
// Seen is what the receiver's probe read from the database while the
// request was in flight.
Seen string
}
// purgeReceiver stands in for the host site's purge route on 127.0.0.1.
type purgeReceiver struct {
srv *httptest.Server
mu sync.Mutex
got []purgeRequest
status int
probe func() string
}
func newPurgeReceiver(t *testing.T) *purgeReceiver {
t.Helper()
r := &purgeReceiver{status: http.StatusOK}
r.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
raw, _ := io.ReadAll(req.Body)
var body map[string]string
_ = json.Unmarshal(raw, &body)
r.mu.Lock()
probe, status := r.probe, r.status
r.mu.Unlock()
seen := ""
if probe != nil {
seen = probe()
}
r.mu.Lock()
r.got = append(r.got, purgeRequest{
Method: req.Method,
Path: req.URL.Path,
Authorization: req.Header.Get("Authorization"),
ContentType: req.Header.Get("Content-Type"),
Body: body,
Seen: seen,
})
r.mu.Unlock()
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if status == http.StatusOK {
_, _ = w.Write([]byte(`{"purged":3}`))
}
}))
t.Cleanup(r.srv.Close)
return r
}
// env returns the extra environment that points the Journal plugin at r.
func (r *purgeReceiver) env() []string {
return []string{
"SUMMER_GOLEM15__JOURNAL__SITE_CACHE__PURGE_URL=" + r.srv.URL + purgeTestPath,
"SUMMER_GOLEM15__JOURNAL__SITE_CACHE__PURGE_TOKEN=" + purgeTestToken,
}
}
// take returns the requests received so far and forgets them.
func (r *purgeReceiver) take() []purgeRequest {
r.mu.Lock()
defer r.mu.Unlock()
got := r.got
r.got = nil
return got
}
func (r *purgeReceiver) setProbe(probe func() string) {
r.mu.Lock()
r.probe = probe
r.mu.Unlock()
}
// expectOnePurge asserts exactly one contract request for kind and slug was
// received since the last take, and returns it.
func (r *purgeReceiver) expectOnePurge(t *testing.T, stage, kind, slug string) purgeRequest {
t.Helper()
got := r.take()
if len(got) != 1 {
t.Fatalf("%s: %d purge requests, want 1: %+v", stage, len(got), got)
}
req := got[0]
if req.Method != http.MethodPost || req.Path != purgeTestPath {
t.Fatalf("%s: purge request %s %s, want POST %s", stage, req.Method, req.Path, purgeTestPath)
}
if req.Authorization != "Bearer "+purgeTestToken {
t.Fatalf("%s: Authorization = %q, want the bearer token", stage, req.Authorization)
}
if !strings.HasPrefix(req.ContentType, "application/json") {
t.Fatalf("%s: Content-Type = %q, want application/json", stage, req.ContentType)
}
if req.Body["kind"] != kind || req.Body["slug"] != slug || len(req.Body) != 2 {
t.Fatalf("%s: purge body = %#v, want kind %q and slug %q", stage, req.Body, kind, slug)
}
return req
}
// TestCategoriesAdminPurgesSiteCache proves an admin update of a category
// sends one authorized purge request to the host site after the save
// transaction commits (G-16.2-2, D-19).
func TestCategoriesAdminPurgesSiteCache(t *testing.T) {
recv := newPurgeReceiver(t)
env := newPostsEnv(t, recv.env()...)
token := env.admin(t, controllers.PermissionAccessCategories)
rec := env.expect(t, http.StatusCreated, http.MethodPost, adminAPI(adminCategoriesPath), token, map[string]any{
"name": map[string]any{"en": "Purge news", "pl": "Wiadomości"},
"slug": map[string]any{"en": "purge-news", "pl": "wiadomosci"},
"content": "<p>Before</p>\n",
})
id := adminCreatedID(t, rec)
itemPath := adminAPI(fmt.Sprintf("%s/%d", adminCategoriesPath, id))
recv.take()
content := func() string {
var cat models.Category
if err := env.gdb.First(&cat, id).Error; err != nil {
return "error: " + err.Error()
}
if cat.Content == nil {
return ""
}
return *cat.Content
}
recv.setProbe(content)
rec = env.expect(t, http.StatusOK, http.MethodGet, itemPath, token, nil)
var served map[string]json.RawMessage
if err := json.Unmarshal(adminDecode(t, rec).Data, &served); err != nil {
t.Fatal(err)
}
const edited = "<p>After the edit</p>\n"
body := servedBody(served, "name", "slug")
body["content"] = edited
env.expect(t, http.StatusOK, http.MethodPut, itemPath, token, body)
req := recv.expectOnePurge(t, "update", "category", "purge-news")
if req.Seen != edited {
t.Fatalf("the purge saw content %q, want the committed %q (purge before commit)", req.Seen, edited)
}
}