diff --git a/classes/site_cache.go b/classes/site_cache.go new file mode 100644 index 0000000..22b5b5b --- /dev/null +++ b/classes/site_cache.go @@ -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) + }) +} diff --git a/controllers/categories.go b/controllers/categories.go index c1851a1..6996eba 100644 --- a/controllers/categories.go +++ b/controllers/categories.go @@ -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{} ) diff --git a/controllers/site_cache.go b/controllers/site_cache.go new file mode 100644 index 0000000..adc8fba --- /dev/null +++ b/controllers/site_cache.go @@ -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) +} diff --git a/plugin.go b/plugin.go index 206b3f1..2fe0bb0 100644 --- a/plugin.go +++ b/plugin.go @@ -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() } diff --git a/site_cache_admin_test.go b/site_cache_admin_test.go new file mode 100644 index 0000000..a8728fb --- /dev/null +++ b/site_cache_admin_test.go @@ -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": "

Before

\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 = "

After the edit

\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) + } +}