diff --git a/README.md b/README.md index e1130d9..ab1597a 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ It requires `golem15.translate`: every FAQ belongs to one translate locale throu - **Table.** `golem15_faq_faqs` keeps the PHP columns: id, name, slug, locale_id (NOT NULL), is_active (default false), description (text), content (text holding the JSON list of `{question, answer}` rows) and timestamps, with a non-unique index on (slug, locale_id). One gormigrate step creates it; AutoMigrate is not used. - **Model.** `models.FAQ` stores `content` as `lagoon.Jsonable` (`models.Content`) and belongs to the translate `Locale`. Fillable: name, slug, locale_id, is_active, description, content. A name, a slug and a locale are required. - **Admin.** A top-level FAQ item (icon `circle-question-mark`, order 500) with the side item FAQs, behind the permission `golem15.faq.faqs` (developer and publisher roles). The list shows id, name, slug, locale (by name, so rows of the same slug are told apart), active and the timestamps. The form has name, slug (preset from name), locale (relation, by name), is_active, description (rich editor) and content (a repeater, "Add question", each row a required question and a rich-editor answer). Rich text is sanitized on save by the framework's policy (`seaglass`), and the save answers with a notice naming each field it cleaned, such as `content.1.answer`. +- **Site cache.** A FAQ create, update or delete in the admin asks the host site to drop its page caches once the save commits. See [Site cache](#site-cache). - **Public API.** `GET /_faq/api/v1/faqs/{slug}` serves an active FAQ in one locale. See [Usage](#usage). - **Import.** `faq:import-winter` copies a WinterCMS site's FAQs from the JSON Lines `scripts/winter-export.sql` produces. See [CLI commands](#cli-commands). - **Phrasebooks.** `lang/en/lang.yaml` and `lang/pl/lang.yaml` keep the PHP key names. @@ -67,6 +68,10 @@ cors: allowed_methods: ["GET"] ``` +### Site cache + +A FAQ create, update or delete in the admin sends one purge request (`{"kind":"faq","slug":""}`) to the host site after the save commits, through the framework's site-cache purger in `cooler`. Configure it once for the application with the framework keys `site_cache.purge_url` and `site_cache.purge_token` (`SUMMER_SITE_CACHE__PURGE_URL`, `SUMMER_SITE_CACHE__PURGE_TOKEN`); a site that extends `vue-faq-layer` 0.2.0 or later serves the matching route `POST /api/__site__/cache/purge` with `NUXT_SITE_CACHE_PURGE_TOKEN`. Without the keys nothing is sent. A save refused by the locale check sends nothing, and a failing or slow site never fails the save. + ### Winter import 1. On the WinterCMS database, check the FAQ and translate locales table names named in the header of `scripts/winter-export.sql`, then run the read-only export: @@ -111,13 +116,14 @@ cors: - Form: the PHP `span: none` and `cssClass: col-sm-4` layout becomes left and right spans in one flat form; the locale relation's `select: name` is `nameFrom: name`; a question is required in each content row. - List: a locale column is added, since several rows share a slug. +- Saves: a committed save or delete purges the host site's page caches (the PHP plugin had no such hook; a site's cache held edits until its TTL ran out). - Saves: a FAQ without a locale is refused with 422 `errors.locale_required` (the locale relation is written after the model rules run, so the check follows the write and rolls it back). - API: the public JSON API replaces the PHP component; rich text is sanitized on save and again on output. - Timestamps are `TIMESTAMPTZ`. ## Dependencies -- SummerCMS modules: backpack, bonfire, cabana, lagoon, pact, party, seaglass, surf, wire. +- SummerCMS modules: backpack, bonfire, cabana, cooler, lagoon, pact, party, seaglass, surf, wire. - Compiled plugin: `golem15.translate` (`git.golem15.com/golem15/sm-translate-plugin`). - Third-party: `gorm.io/gorm`, `github.com/go-gormigrate/gormigrate/v2`. Test-only: `github.com/testcontainers/testcontainers-go` with its Postgres module, `github.com/jackc/pgx/v5`. @@ -126,5 +132,5 @@ cors: ```sh go vet ./... go test -short ./... # no Docker -go test ./... # TestFaqAdminSmoke, TestFaqImportSmoke, TestFaqAPISmoke on a Postgres container +go test ./... # TestFaqAdminSmoke, TestFaqImportSmoke, TestFaqAPISmoke, TestFaqAdminPurgesSiteCache on a Postgres container ``` diff --git a/controllers/faqs.go b/controllers/faqs.go index f772b90..eb283c5 100644 --- a/controllers/faqs.go +++ b/controllers/faqs.go @@ -49,16 +49,39 @@ func (faqsAdminController) AdminFieldRelations() []cabana.FieldRelationContract // errLocaleRequired is the field message of a FAQ saved without a locale. const errLocaleRequired = "golem15.faq::lang.errors.locale_required" +// siteCacheKind is the kind a FAQ save's site-cache purge carries. +const siteCacheKind = "faq" + // FormAfterCreate refuses a FAQ without a locale. cabana writes locale_id // from the relation field after the model rules ran, so the check runs here, -// inside the save's transaction: the refusal rolls the row back. -func (faqsAdminController) FormAfterCreate(_ context.Context, model any) error { - return requireLocale(model) +// inside the save's transaction: the refusal rolls the row back. A FAQ that +// passes purges the host site's page caches once the save commits. +func (c faqsAdminController) FormAfterCreate(ctx context.Context, model any) error { + return c.afterSave(ctx, model) } -// FormAfterUpdate applies the same check to an update. -func (faqsAdminController) FormAfterUpdate(_ context.Context, model any) error { - return requireLocale(model) +// FormAfterUpdate applies the same check and purge to an update. +func (c faqsAdminController) FormAfterUpdate(ctx context.Context, model any) error { + return c.afterSave(ctx, model) +} + +// FormAfterDelete purges the host site's page caches once the delete +// commits. +func (c faqsAdminController) FormAfterDelete(ctx context.Context, model any) error { + if faq, ok := model.(*models.FAQ); ok && faq != nil { + purgeSiteCache(ctx, c.app, siteCacheKind, faq.Slug) + } + return nil +} + +func (c faqsAdminController) afterSave(ctx context.Context, model any) error { + if err := requireLocale(model); err != nil { + return err + } + if faq, ok := model.(*models.FAQ); ok && faq != nil { + purgeSiteCache(ctx, c.app, siteCacheKind, faq.Slug) + } + return nil } func requireLocale(model any) error { @@ -72,6 +95,7 @@ func requireLocale(model any) error { var ( _ pact.FormAfterCreate = faqsAdminController{} _ pact.FormAfterUpdate = faqsAdminController{} + _ pact.FormAfterDelete = faqsAdminController{} _ pact.AdminController = faqsAdminController{} _ pact.AdminPermissioned = faqsAdminController{} _ pact.AdminRecordSource = faqsAdminController{} diff --git a/controllers/site_cache.go b/controllers/site_cache.go new file mode 100644 index 0000000..3e13abd --- /dev/null +++ b/controllers/site_cache.go @@ -0,0 +1,32 @@ +package controllers + +import ( + "context" + + "git.golem15.com/golem15/summercms/modules/backpack" + "git.golem15.com/golem15/summercms/modules/cabana" + "git.golem15.com/golem15/summercms/modules/cooler" +) + +// purgeSiteCache registers one site-cache purge of kind and slug, sent by +// the framework's purger (site_cache.purge_url and purge_token) after the +// save transaction of ctx commits. Without a purge configured, or outside a +// save transaction, it does nothing. A purge never fails the save. +func purgeSiteCache(ctx context.Context, app func() *backpack.App, kind, slug string) { + if app == nil { + return + } + a := app() + if a == nil { + return + } + tx, ok := cabana.TxFromContext(ctx) + if !ok { + return + } + purger := cooler.SiteCacheFromApp(a) + if purger == nil { + return + } + cooler.PurgeSiteCacheAfterCommit(ctx, tx, purger, kind, slug) +} diff --git a/site_cache_admin_test.go b/site_cache_admin_test.go new file mode 100644 index 0000000..981fd41 --- /dev/null +++ b/site_cache_admin_test.go @@ -0,0 +1,208 @@ +package faq_test + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + + "git.golem15.com/golem15/sm-faq-plugin/models" + "git.golem15.com/golem15/summercms/modules/cooler" +) + +// purgeTestToken is a 40-character purge token for the receiver. +const purgeTestToken = "faq-purge-test-token-0123456789abcdefghi" + +const purgeTestPath = "/api/__site__/cache/purge" + +// purgeRequest is one request the purge receiver got. +type purgeRequest struct { + Method string + Path string + Authorization 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{req.Method, req.URL.Path, req.Header.Get("Authorization"), body, seen}) + r.mu.Unlock() + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + if status == http.StatusOK { + _, _ = w.Write([]byte(`{"purged":2}`)) + } + })) + t.Cleanup(r.srv.Close) + return r +} + +func (r *purgeReceiver) take() []purgeRequest { + r.mu.Lock() + defer r.mu.Unlock() + got := r.got + r.got = nil + return got +} + +func (r *purgeReceiver) setStatus(status int) { + r.mu.Lock() + r.status = status + r.mu.Unlock() +} + +func (r *purgeReceiver) setProbe(probe func() string) { + r.mu.Lock() + r.probe = probe + r.mu.Unlock() +} + +// expectOnePurge asserts exactly one authorized purge of kind faq and slug +// arrived since the last take, and returns it. +func (r *purgeReceiver) expectOnePurge(t *testing.T, stage, 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 req.Body["kind"] != "faq" || req.Body["slug"] != slug || len(req.Body) != 2 { + t.Fatalf("%s: purge body = %#v, want kind faq and slug %q", stage, req.Body, slug) + } + return req +} + +// TestFaqAdminPurgesSiteCache proves a FAQ create, update and delete in the +// admin each send one authorized purge of kind faq through the framework's +// site_cache keys after the save commits, that a create refused by the +// locale check sends none, and that a failing site never fails the save. +func TestFaqAdminPurgesSiteCache(t *testing.T) { + recv := newPurgeReceiver(t) + e := bootFAQ(t) + // SiteCacheFromApp reads the keys on the first save, so they can be set + // after boot. + if err := e.app.Config.Set(cooler.SiteCachePurgeURLKey, recv.srv.URL+purgeTestPath); err != nil { + t.Fatal(err) + } + if err := e.app.Config.Set(cooler.SiteCachePurgeTokenKey, purgeTestToken); err != nil { + t.Fatal(err) + } + token := e.admin(t, permFAQs) + en := e.localeID(t, "en") + const base = "/golem15/faq/faqs" + + // The probe reads the committed row through the pool while the purge + // request is in flight: its name, or "missing". + recv.setProbe(func() string { + var faq models.FAQ + if err := e.gdb.Where("slug = ?", "purge-faq").Limit(1).Find(&faq).Error; err != nil { + return "error: " + err.Error() + } + if faq.ID == 0 { + return "missing" + } + return faq.Name + }) + body := func(name string) map[string]any { + return map[string]any{ + "name": name, + "slug": "purge-faq", + "locale": en, + "is_active": true, + "content": []any{map[string]any{"question": "Why?", "answer": "

Because.

"}}, + } + } + + rec := e.call(t, http.MethodPost, base, token, body("Before")) + if rec.Code != http.StatusCreated { + t.Fatalf("create: %d %s", rec.Code, rec.Body.String()) + } + var created adminRecord + if err := json.Unmarshal(rec.Body.Bytes(), &created); err != nil { + t.Fatal(err) + } + id, _ := created.Data["id"].(float64) + if id == 0 { + t.Fatalf("create answered no id: %s", rec.Body.String()) + } + itemPath := fmt.Sprintf("%s/%d", base, int64(id)) + if req := recv.expectOnePurge(t, "create", "purge-faq"); req.Seen != "Before" { + t.Fatalf("create: the purge saw %q, want the committed row", req.Seen) + } + + t.Run("update", func(t *testing.T) { + if rec := e.call(t, http.MethodPut, itemPath, token, body("After")); rec.Code != http.StatusOK { + t.Fatalf("update: %d %s", rec.Code, rec.Body.String()) + } + if req := recv.expectOnePurge(t, "update", "purge-faq"); req.Seen != "After" { + t.Fatalf("update: the purge saw %q, want the committed %q (purge before commit)", req.Seen, "After") + } + }) + + t.Run("a create refused by the locale check sends none", func(t *testing.T) { + rec := e.call(t, http.MethodPost, base, token, map[string]any{"name": "No locale", "slug": "no-locale"}) + if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), "locale_required") { + t.Fatalf("create without locale: %d %s", rec.Code, rec.Body.String()) + } + if got := recv.take(); len(got) != 0 { + t.Fatalf("a refused create sent %d purge requests: %+v", len(got), got) + } + }) + + t.Run("a failing site never fails the save", func(t *testing.T) { + recv.setStatus(http.StatusInternalServerError) + defer recv.setStatus(http.StatusOK) + if rec := e.call(t, http.MethodPut, itemPath, token, body("While failing")); rec.Code != http.StatusOK { + t.Fatalf("update with a failing purge: %d %s", rec.Code, rec.Body.String()) + } + recv.expectOnePurge(t, "failing purge", "purge-faq") + var stored models.FAQ + if err := e.gdb.First(&stored, int64(id)).Error; err != nil || stored.Name != "While failing" { + t.Fatalf("stored = %+v (%v), want the name saved", stored, err) + } + }) + + t.Run("delete", func(t *testing.T) { + if rec := e.call(t, http.MethodDelete, itemPath, token, nil); rec.Code != http.StatusOK { + t.Fatalf("delete: %d %s", rec.Code, rec.Body.String()) + } + if req := recv.expectOnePurge(t, "delete", "purge-faq"); req.Seen != "missing" { + t.Fatalf("delete: the purge saw %q, want the row already deleted", req.Seen) + } + }) +}