feat(19.3-04): Journal purges through the framework's cooler.SiteCachePurger
- classes.SiteCachePurger, NewSiteCachePurger, PurgeSiteCacheAfterCommit and the key and limit constants stay as deprecated aliases for one release - wireSiteCache: framework keys site_cache.* win (Journal keys ignored with a warning); with only golem15.journal.site_cache.* set it publishes the purger from them with a deprecation warning, so menu and FAQ saves purge through it too - controllers take the purger from cooler.SiteCacheFromApp - TestSiteCacheFrameworkKeysWin; README documents both key sets
This commit is contained in:
35
README.md
35
README.md
@@ -266,21 +266,30 @@ 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. With the two keys below set, 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.
|
||||
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:
|
||||
|
||||
| Key | Environment variable | Value |
|
||||
|-----|----------------------|-------|
|
||||
| `golem15.journal.site_cache.purge_url` | `SUMMER_GOLEM15__JOURNAL__SITE_CACHE__PURGE_URL` | The host site's purge route, for example `http://127.0.0.1:3000/api/__journal__/cache/purge`. |
|
||||
| `golem15.journal.site_cache.purge_token` | `SUMMER_GOLEM15__JOURNAL__SITE_CACHE__PURGE_TOKEN` | The bearer token the route expects, at least 32 characters. Keep it in the server's environment file, never in a committed config file. |
|
||||
| `site_cache.purge_url` | `SUMMER_SITE_CACHE__PURGE_URL` | The host site's purge route, for example `http://127.0.0.1:3000/api/__site__/cache/purge`. |
|
||||
| `site_cache.purge_token` | `SUMMER_SITE_CACHE__PURGE_TOKEN` | The bearer token the route expects, at least 32 characters. Keep it in the server's environment file, never in a committed config file. |
|
||||
|
||||
With neither key set, nothing is purged and no request is sent. Boot fails, naming the key, when only one of them is set, when the URL is not http or https with a host, when it carries user info or a fragment, when an `http` URL points at a host other than loopback (`127.0.0.0/8`, `::1`, `localhost`; the token would cross the network in clear, so use `https` for any other host), or when the token is shorter than 32 characters. When the purge is enabled, Boot logs the URL's host and path once.
|
||||
With neither key set, nothing is purged and no request is sent. `serve` fails at boot, naming the key, when only one of them is set, when the URL is not http or https with a host, when it carries user info or a fragment, when an `http` URL points at a host other than loopback (`127.0.0.0/8`, `::1`, `localhost`; the token would cross the network in clear, so use `https` for any other host), or when the token is shorter than 32 characters. When the purge is enabled, start-up logs the URL's host and path once.
|
||||
|
||||
**Deprecated Journal keys (one release).** The Journal's own keys still work when the framework keys are unset:
|
||||
|
||||
| Deprecated key | Environment variable |
|
||||
|----------------|----------------------|
|
||||
| `golem15.journal.site_cache.purge_url` | `SUMMER_GOLEM15__JOURNAL__SITE_CACHE__PURGE_URL` |
|
||||
| `golem15.journal.site_cache.purge_token` | `SUMMER_GOLEM15__JOURNAL__SITE_CACHE__PURGE_TOKEN` |
|
||||
|
||||
With only these set, Boot validates them with the same rules (errors name the Journal keys), publishes the purger they describe, so menu and FAQ saves purge through it as well, and logs one deprecation warning naming `site_cache.purge_url` and `site_cache.purge_token`. With the framework keys set, the Journal keys are ignored with a warning. Move the values to the framework keys before the next release removes them.
|
||||
|
||||
The request is a `POST` to the URL with `Authorization: Bearer <token>`, `Content-Type: application/json` and the body `{"kind":"post","slug":"<slug>"}` or `{"kind":"category","slug":"<slug>"}`. The receiver answers `200` with `{"purged":<n>}`, 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.
|
||||
- **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 (`journal: site cache purge failed`) and the save response is unchanged; a successful one logs `journal: site cache purged` with the kind, slug, status and purged count. The token and the Authorization header are never logged.
|
||||
- **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.
|
||||
- **Receiver.** The host's Nuxt app built on vue-journal-layer provides the route (`POST /api/__journal__/cache/purge`, token in `NUXT_JOURNAL_CACHE_PURGE_TOKEN`). Generate one token on the server and put the same value in both environments.
|
||||
- **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
|
||||
|
||||
@@ -329,13 +338,13 @@ The request is a `POST` to the URL with `Authorization: Bearer <token>`, `Conten
|
||||
| `classes.HTMLRemovals` | Removed element and attribute names (distinct, first seen, at most ten each) with exact `ElementCount` and `AttributeCount`, removed `Comments`, and `Other` for any other change; `Empty` reports no removal. |
|
||||
| `classes.FormatTranslation` | Sets `content_html` in one locale's translated attributes: rendered from a non-blank `content` with `FormatHTML`, otherwise a supplied `content_html` sanitized with `SanitizeHTML`. |
|
||||
| `classes.CheckHTML` | Predicate: an error when the `FormatHTML` allow-list would strip or change the HTML, including any tag that repeats an attribute. No write path refuses with it any more (admin saves strip and report); it is the counterpart of the site layer's gate and is used by the policy tests. |
|
||||
| `classes.SiteCachePurger` | Sends the host site cache purge request; a nil purger does nothing. |
|
||||
| `classes.NewSiteCachePurger` | Builds the purger from the purge URL and token and validates them; both empty disables the purge (nil, nil). |
|
||||
| `classes.SiteCachePurger` | Deprecated alias of `cooler.SiteCachePurger`, the framework's purger; a nil purger does nothing. |
|
||||
| `classes.NewSiteCachePurger` | Deprecated: builds the framework purger from values of the Journal's keys (`cooler.NewSiteCachePurgerFor`), errors naming those keys; both empty disables the purge (nil, nil). |
|
||||
| `classes.SiteCachePurger.Purge` | One purge request for a kind and slug, with a 2 s timeout, logged and never returned as an error. |
|
||||
| `classes.SiteCachePurger.Endpoint` | The purge URL's host and path, for log lines. |
|
||||
| `classes.PurgeSiteCacheAfterCommit` | Registers a purge to run after the surrounding save transaction commits. |
|
||||
| `classes.SiteCachePurgeURLKey`, `classes.SiteCachePurgeTokenKey` | The config keys `golem15.journal.site_cache.purge_url` and `purge_token`. |
|
||||
| `classes.SiteCachePurgeMinTokenLength`, `classes.SiteCachePurgeTimeout` | The shortest accepted token (32) and the request timeout (2 s). |
|
||||
| `classes.PurgeSiteCacheAfterCommit` | Deprecated alias of `cooler.PurgeSiteCacheAfterCommit`: registers a purge to run after the surrounding save transaction commits. |
|
||||
| `classes.SiteCachePurgeURLKey`, `classes.SiteCachePurgeTokenKey` | The deprecated config keys `golem15.journal.site_cache.purge_url` and `purge_token` (framework keys: `cooler.SiteCachePurgeURLKey`, `cooler.SiteCachePurgeTokenKey`). |
|
||||
| `classes.SiteCachePurgeMinTokenLength`, `classes.SiteCachePurgeTimeout` | Deprecated aliases of cooler's shortest accepted token (32) and request timeout (2 s). |
|
||||
|
||||
## CLI commands
|
||||
|
||||
@@ -354,7 +363,7 @@ Both commands write JSON files (one per post slug), matching the PHP `journal:ex
|
||||
|
||||
## Dependencies
|
||||
|
||||
- SummerCMS modules: backpack, beachcomber, bonfire, cabana, lagoon/attach, pact, party, surf; signpost in tests.
|
||||
- SummerCMS modules: backpack, beachcomber, bonfire, cabana, cooler (site-cache purge), lagoon/attach, pact, party, surf; signpost in tests.
|
||||
- Compiled plugin: `golem15.translate`.
|
||||
- Third-party: `gorm.io/gorm`, `github.com/go-gormigrate/gormigrate/v2`, `github.com/yuin/goldmark`, `github.com/microcosm-cc/bluemonday`, `golang.org/x/net/html`, `gocloud.dev`. Test-only: `github.com/testcontainers/testcontainers-go`.
|
||||
- Blog Editor JS build (not needed by a host application): `@tiptap/core`, `@tiptap/pm`, `@tiptap/starter-kit`, `@tiptap/extension-table` and `@tiptap/extension-image` 3.31.4, bundled into `assets/dist`; dev-only `vite`, `vitest`, `happy-dom` and `typescript`, pinned exactly in `package.json`.
|
||||
@@ -371,7 +380,7 @@ Personal API token coverage: `TestResolveErrorMapping` (Docker-free, `controller
|
||||
|
||||
`TestMenuItemTypesSmoke` (root package, Postgres) checks the five registered types and resolves each through the registry with host and item pattern overrides, ordered alternates, nesting, the published filter and the `x-default` rule.
|
||||
|
||||
The site cache purge has unit tests in `classes` (`TestSiteCachePurgeConfig`, `TestSiteCachePurgeRequest`) and Postgres tests through the admin API (`TestCategoriesAdminPurgesSiteCache`, `TestPostsAdminPurgesSiteCache`) that point the plugin at a local receiver and check one authorized request after each commit.
|
||||
The site cache purge has unit tests of the deprecated keys in `classes` (`TestSiteCachePurgeConfig`, `TestSiteCachePurgeRequest`; the request rules are tested in the framework's cooler) and Postgres tests through the admin API (`TestCategoriesAdminPurgesSiteCache`, `TestPostsAdminPurgesSiteCache` on the deprecated keys, `TestSiteCacheFrameworkKeysWin` with both sets) that point the plugin at a local receiver and check one authorized request after each commit.
|
||||
|
||||
The Blog Editor has its own suite and drift gate:
|
||||
|
||||
|
||||
@@ -1,184 +1,60 @@
|
||||
package classes
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||
"git.golem15.com/golem15/summercms/modules/cooler"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// The site-cache purge moved into the framework (cooler.SiteCachePurger,
|
||||
// configured by site_cache.purge_url and site_cache.purge_token). The names
|
||||
// below stay for one release as aliases, so existing deployments and
|
||||
// importers keep working.
|
||||
const (
|
||||
// SiteCachePurgeURLKey is the config key of the host site's cache purge
|
||||
// URL (env SUMMER_GOLEM15__JOURNAL__SITE_CACHE__PURGE_URL).
|
||||
// SiteCachePurgeURLKey is the Journal's old config key of the host
|
||||
// site's cache purge URL (env SUMMER_GOLEM15__JOURNAL__SITE_CACHE__PURGE_URL).
|
||||
// It is read only when the framework keys are unset.
|
||||
//
|
||||
// Deprecated: set cooler.SiteCachePurgeURLKey (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 is the Journal's old config key of the purge
|
||||
// bearer token (env SUMMER_GOLEM15__JOURNAL__SITE_CACHE__PURGE_TOKEN). It
|
||||
// is read only when the framework keys are unset.
|
||||
//
|
||||
// Deprecated: set cooler.SiteCachePurgeTokenKey (site_cache.purge_token).
|
||||
SiteCachePurgeTokenKey = "golem15.journal.site_cache.purge_token"
|
||||
|
||||
// SiteCachePurgeTimeout bounds one purge request.
|
||||
SiteCachePurgeTimeout = 2 * time.Second
|
||||
//
|
||||
// Deprecated: use cooler.SiteCachePurgeTimeout.
|
||||
SiteCachePurgeTimeout = cooler.SiteCachePurgeTimeout
|
||||
|
||||
// siteCacheAnswerLimit is how much of the purge answer is read.
|
||||
siteCacheAnswerLimit = 4 << 10
|
||||
// SiteCachePurgeMinTokenLength is the shortest purge token accepted.
|
||||
//
|
||||
// Deprecated: use cooler.SiteCachePurgeMinTokenLength.
|
||||
SiteCachePurgeMinTokenLength = cooler.SiteCachePurgeMinTokenLength
|
||||
)
|
||||
|
||||
// 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
|
||||
endpoint string
|
||||
token string
|
||||
client *http.Client
|
||||
}
|
||||
// SiteCachePurger is the framework's site-cache purger.
|
||||
//
|
||||
// Deprecated: use cooler.SiteCachePurger.
|
||||
type SiteCachePurger = cooler.SiteCachePurger
|
||||
|
||||
// SiteCachePurgeMinTokenLength is the shortest purge token accepted, the
|
||||
// same minimum the host site's purge route applies.
|
||||
const SiteCachePurgeMinTokenLength = 32
|
||||
|
||||
// NewSiteCachePurger returns the purger for purgeURL and token. With both
|
||||
// empty the purge is disabled and it returns nil, nil. Otherwise both must
|
||||
// be set, the URL must be http or https with a host and without user info
|
||||
// or a fragment, an http URL must point at a loopback host (127.0.0.0/8,
|
||||
// ::1 or localhost) so the bearer token never crosses the network in
|
||||
// clear, and the token must hold at least SiteCachePurgeMinTokenLength
|
||||
// characters. The error names the offending key and never the token.
|
||||
// NewSiteCachePurger returns the purger for purgeURL and token read from the
|
||||
// Journal's old keys, whose names its errors carry; the rules are those of
|
||||
// cooler.NewSiteCachePurgerFor. With both empty it returns nil, nil.
|
||||
//
|
||||
// Deprecated: configure site_cache.purge_url and site_cache.purge_token and
|
||||
// use cooler.SiteCacheFromApp.
|
||||
func NewSiteCachePurger(purgeURL, token string) (*SiteCachePurger, error) {
|
||||
purgeURL = strings.TrimSpace(purgeURL)
|
||||
if purgeURL == "" && token == "" {
|
||||
return nil, nil
|
||||
}
|
||||
if purgeURL == "" {
|
||||
return nil, fmt.Errorf("%s is set but %s is not; set both or neither", SiteCachePurgeTokenKey, SiteCachePurgeURLKey)
|
||||
}
|
||||
if token == "" {
|
||||
return nil, fmt.Errorf("%s is set but %s is not; set both or neither", SiteCachePurgeURLKey, SiteCachePurgeTokenKey)
|
||||
}
|
||||
u, err := url.Parse(purgeURL)
|
||||
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" || u.Hostname() == "" {
|
||||
return nil, fmt.Errorf("%s must be an http or https URL with a host", SiteCachePurgeURLKey)
|
||||
}
|
||||
if u.User != nil {
|
||||
return nil, fmt.Errorf("%s must not carry user info", SiteCachePurgeURLKey)
|
||||
}
|
||||
if u.Fragment != "" || strings.Contains(purgeURL, "#") {
|
||||
return nil, fmt.Errorf("%s must not carry a fragment", SiteCachePurgeURLKey)
|
||||
}
|
||||
if u.Scheme == "http" && !loopbackHost(u.Hostname()) {
|
||||
return nil, fmt.Errorf("%s: an http URL must point at a loopback host (127.0.0.0/8, ::1 or localhost); use https for any other host", SiteCachePurgeURLKey)
|
||||
}
|
||||
if len(token) < SiteCachePurgeMinTokenLength {
|
||||
return nil, fmt.Errorf("%s must hold at least %d characters", SiteCachePurgeTokenKey, SiteCachePurgeMinTokenLength)
|
||||
}
|
||||
return &SiteCachePurger{url: u.String(), endpoint: u.Host + u.Path, token: token, client: &http.Client{}}, nil
|
||||
}
|
||||
|
||||
// Endpoint returns the purge URL's host and path, without the scheme, user
|
||||
// info or query, for log lines.
|
||||
func (p *SiteCachePurger) Endpoint() string {
|
||||
if p == nil {
|
||||
return ""
|
||||
}
|
||||
return p.endpoint
|
||||
}
|
||||
|
||||
func loopbackHost(host string) bool {
|
||||
if strings.EqualFold(host, "localhost") {
|
||||
return true
|
||||
}
|
||||
ip := net.ParseIP(host)
|
||||
return ip != nil && ip.IsLoopback()
|
||||
}
|
||||
|
||||
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()
|
||||
return cooler.NewSiteCachePurgerFor(SiteCachePurgeURLKey, SiteCachePurgeTokenKey, purgeURL, token)
|
||||
}
|
||||
|
||||
// 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.
|
||||
// transaction db belongs to commits. A nil p does nothing.
|
||||
//
|
||||
// Deprecated: use cooler.PurgeSiteCacheAfterCommit.
|
||||
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)
|
||||
})
|
||||
cooler.PurgeSiteCacheAfterCommit(ctx, db, p, kind, slug)
|
||||
}
|
||||
|
||||
@@ -8,11 +8,9 @@ import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -164,6 +162,9 @@ func assertNoToken(t *testing.T, logs *syncBuffer) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSiteCachePurgeRequest checks that a purger built from the Journal's
|
||||
// deprecated keys sends the framework's contract request; cooler's own tests
|
||||
// cover failures, timeouts and cancellation.
|
||||
func TestSiteCachePurgeRequest(t *testing.T) {
|
||||
t.Run("sends the contract request and logs the purged count", func(t *testing.T) {
|
||||
logs := captureLogs(t)
|
||||
@@ -187,7 +188,7 @@ func TestSiteCachePurgeRequest(t *testing.T) {
|
||||
t.Fatalf("body = %#v", h.body)
|
||||
}
|
||||
lines := logs.lines()
|
||||
if len(lines) != 1 || !strings.Contains(lines[0], "level=INFO") || !strings.Contains(lines[0], `msg="journal: site cache purged"`) ||
|
||||
if len(lines) != 1 || !strings.Contains(lines[0], "level=INFO") || !strings.Contains(lines[0], `msg="cooler: site cache purged"`) ||
|
||||
!strings.Contains(lines[0], "kind=category") || !strings.Contains(lines[0], "slug=cordyceps") ||
|
||||
!strings.Contains(lines[0], "status=200") || !strings.Contains(lines[0], "purged=7") {
|
||||
t.Fatalf("log = %q", lines)
|
||||
@@ -195,83 +196,6 @@ func TestSiteCachePurgeRequest(t *testing.T) {
|
||||
assertNoToken(t, logs)
|
||||
})
|
||||
|
||||
t.Run("an answer without purged logs the status only", func(t *testing.T) {
|
||||
logs := captureLogs(t)
|
||||
srv, _ := purgeReceiver(t, func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) })
|
||||
newTestPurger(t, srv).Purge(context.Background(), "post", "hello")
|
||||
lines := logs.lines()
|
||||
if len(lines) != 1 || !strings.Contains(lines[0], "level=INFO") || !strings.Contains(lines[0], "status=204") || strings.Contains(lines[0], "purged=") {
|
||||
t.Fatalf("log = %q", lines)
|
||||
}
|
||||
})
|
||||
|
||||
for _, status := range []int{http.StatusInternalServerError, http.StatusUnauthorized} {
|
||||
t.Run("a "+http.StatusText(status)+" answer logs one warning", func(t *testing.T) {
|
||||
logs := captureLogs(t)
|
||||
srv, hits := purgeReceiver(t, func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(status) })
|
||||
newTestPurger(t, srv).Purge(context.Background(), "post", "hello")
|
||||
if len(hits()) != 1 {
|
||||
t.Fatalf("%d requests, want 1", len(hits()))
|
||||
}
|
||||
lines := logs.lines()
|
||||
if len(lines) != 1 || !strings.Contains(lines[0], "level=WARN") || !strings.Contains(lines[0], `msg="journal: site cache purge failed"`) ||
|
||||
!strings.Contains(lines[0], "kind=post") || !strings.Contains(lines[0], "slug=hello") {
|
||||
t.Fatalf("log = %q", lines)
|
||||
}
|
||||
if !strings.Contains(lines[0], "status="+strconv.Itoa(status)) {
|
||||
t.Fatalf("log %q lacks status=%d", lines[0], status)
|
||||
}
|
||||
assertNoToken(t, logs)
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("a slow receiver is abandoned after the timeout", func(t *testing.T) {
|
||||
logs := captureLogs(t)
|
||||
release := make(chan struct{})
|
||||
srv, _ := purgeReceiver(t, func(_ http.ResponseWriter, r *http.Request) {
|
||||
select {
|
||||
case <-release:
|
||||
case <-r.Context().Done():
|
||||
}
|
||||
})
|
||||
defer close(release)
|
||||
start := time.Now()
|
||||
newTestPurger(t, srv).Purge(context.Background(), "post", "slow")
|
||||
elapsed := time.Since(start)
|
||||
if elapsed < SiteCachePurgeTimeout-200*time.Millisecond || elapsed > SiteCachePurgeTimeout+1500*time.Millisecond {
|
||||
t.Fatalf("Purge returned after %s, want about %s", elapsed, SiteCachePurgeTimeout)
|
||||
}
|
||||
lines := logs.lines()
|
||||
if len(lines) != 1 || !strings.Contains(lines[0], "level=WARN") || !strings.Contains(lines[0], "error=timeout") {
|
||||
t.Fatalf("log = %q", lines)
|
||||
}
|
||||
assertNoToken(t, logs)
|
||||
})
|
||||
|
||||
t.Run("an unreachable receiver logs one warning without the URL's token", func(t *testing.T) {
|
||||
logs := captureLogs(t)
|
||||
srv, _ := purgeReceiver(t, func(http.ResponseWriter, *http.Request) {})
|
||||
p := newTestPurger(t, srv)
|
||||
srv.Close()
|
||||
p.Purge(context.Background(), "category", "gone")
|
||||
lines := logs.lines()
|
||||
if len(lines) != 1 || !strings.Contains(lines[0], "level=WARN") || !strings.Contains(lines[0], "kind=category") {
|
||||
t.Fatalf("log = %q", lines)
|
||||
}
|
||||
assertNoToken(t, logs)
|
||||
})
|
||||
|
||||
t.Run("a cancelled parent context still sends", func(t *testing.T) {
|
||||
captureLogs(t)
|
||||
srv, hits := purgeReceiver(t, func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(`{"purged":1}`)) })
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
newTestPurger(t, srv).Purge(ctx, "post", "after-cancel")
|
||||
if got := hits(); len(got) != 1 || got[0].body["slug"] != "after-cancel" {
|
||||
t.Fatalf("requests = %#v, want one", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a nil purger sends nothing", func(t *testing.T) {
|
||||
logs := captureLogs(t)
|
||||
var p *SiteCachePurger
|
||||
|
||||
@@ -6,13 +6,16 @@ import (
|
||||
"git.golem15.com/golem15/sm-journal-plugin/classes"
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/cabana"
|
||||
"git.golem15.com/golem15/summercms/modules/cooler"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// siteCachePurger returns the published *classes.SiteCachePurger and the
|
||||
// save transaction of ctx, for classes.PurgeSiteCacheAfterCommit. Without a
|
||||
// purger (no purge configured) or outside a save transaction it returns a
|
||||
// nil purger, which PurgeSiteCacheAfterCommit ignores.
|
||||
// siteCachePurger returns the application's site-cache purger
|
||||
// (cooler.SiteCacheFromApp: the framework keys, or the purger the plugin
|
||||
// published from its deprecated keys) and the save transaction of ctx, for
|
||||
// classes.PurgeSiteCacheAfterCommit. Without a purger (no purge configured)
|
||||
// or outside a save transaction it returns a nil purger, which
|
||||
// PurgeSiteCacheAfterCommit ignores.
|
||||
func siteCachePurger(ctx context.Context, app func() *backpack.App) (*classes.SiteCachePurger, *gorm.DB) {
|
||||
if app == nil {
|
||||
return nil, nil
|
||||
@@ -21,13 +24,13 @@ func siteCachePurger(ctx context.Context, app func() *backpack.App) (*classes.Si
|
||||
if a == nil {
|
||||
return nil, nil
|
||||
}
|
||||
purger, ok := a.Lookup[*classes.SiteCachePurger]()
|
||||
if !ok || purger == nil {
|
||||
return nil, nil
|
||||
}
|
||||
tx, ok := cabana.TxFromContext(ctx)
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
purger := cooler.SiteCacheFromApp(a)
|
||||
if purger == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return purger, tx
|
||||
}
|
||||
|
||||
41
plugin.go
41
plugin.go
@@ -7,6 +7,7 @@ import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/sm-journal-plugin/classes"
|
||||
@@ -16,6 +17,7 @@ import (
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/bonfire"
|
||||
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||
"git.golem15.com/golem15/summercms/modules/cooler"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"git.golem15.com/golem15/summercms/modules/party"
|
||||
"git.golem15.com/golem15/summercms/modules/surf"
|
||||
@@ -57,28 +59,49 @@ func (p *Plugin) Boot(app *backpack.App) error {
|
||||
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.
|
||||
// wireSiteCache keeps the Journal's deprecated purge keys working for one
|
||||
// release. When the framework keys site_cache.purge_url or purge_token are
|
||||
// set, the framework owns the purger (cooler.Install, cooler.SiteCacheFromApp)
|
||||
// and the Journal keys are ignored with a warning. Otherwise, when
|
||||
// golem15.journal.site_cache.purge_url and purge_token are set, it builds
|
||||
// the purger from them, publishes it, so the framework and every plugin's
|
||||
// admin saves purge through it, and logs a deprecation warning. An invalid
|
||||
// configuration of the Journal keys fails boot, naming the key.
|
||||
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),
|
||||
)
|
||||
cfg := app.Config
|
||||
log := slog.Default()
|
||||
aliasURL := cfg.String(classes.SiteCachePurgeURLKey)
|
||||
aliasToken := cfg.String(classes.SiteCachePurgeTokenKey)
|
||||
aliasSet := strings.TrimSpace(aliasURL) != "" || aliasToken != ""
|
||||
if strings.TrimSpace(cfg.String(cooler.SiteCachePurgeURLKey)) != "" || cfg.String(cooler.SiteCachePurgeTokenKey) != "" {
|
||||
if aliasSet {
|
||||
log.Warn("journal: golem15.journal.site_cache.purge_url and purge_token are ignored because the framework keys are set",
|
||||
"use", cooler.SiteCachePurgeURLKey+", "+cooler.SiteCachePurgeTokenKey)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !aliasSet {
|
||||
return nil
|
||||
}
|
||||
purger, err := classes.NewSiteCachePurger(aliasURL, aliasToken)
|
||||
if err != nil {
|
||||
return fmt.Errorf("golem15.journal: site cache: %w", err)
|
||||
}
|
||||
if purger == nil {
|
||||
return nil
|
||||
}
|
||||
if existing, ok := app.Lookup[*cooler.SiteCachePurger](); ok && existing != nil {
|
||||
return nil
|
||||
}
|
||||
if err := app.Publish(purger); err != nil {
|
||||
return err
|
||||
}
|
||||
slog.Default().Info("journal: site cache purge enabled", "endpoint", purger.Endpoint())
|
||||
log.Warn("journal: golem15.journal.site_cache.purge_url and purge_token are deprecated and will be removed; set "+
|
||||
cooler.SiteCachePurgeURLKey+" and "+cooler.SiteCachePurgeTokenKey+" instead",
|
||||
"endpoint", purger.Endpoint())
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -329,3 +329,28 @@ func TestPostsAdminPurgesSiteCache(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestSiteCacheFrameworkKeysWin proves the framework keys site_cache.* take
|
||||
// precedence over the Journal's deprecated keys: with both set, a category
|
||||
// save purges through the framework target only.
|
||||
func TestSiteCacheFrameworkKeysWin(t *testing.T) {
|
||||
framework := newPurgeReceiver(t)
|
||||
alias := newPurgeReceiver(t)
|
||||
env := newPostsEnv(t, append(alias.env(),
|
||||
"SUMMER_SITE_CACHE__PURGE_URL="+framework.srv.URL+"/api/__site__/cache/purge",
|
||||
"SUMMER_SITE_CACHE__PURGE_TOKEN="+purgeTestToken,
|
||||
)...)
|
||||
token := env.admin(t, controllers.PermissionAccessCategories)
|
||||
env.expect(t, http.StatusCreated, http.MethodPost, adminAPI(adminCategoriesPath), token, map[string]any{
|
||||
"name": map[string]any{"en": "Framework keys"},
|
||||
"slug": map[string]any{"en": "framework-keys"},
|
||||
})
|
||||
got := framework.take()
|
||||
if len(got) != 1 || got[0].Path != "/api/__site__/cache/purge" || got[0].Body["kind"] != "category" || got[0].Body["slug"] != "framework-keys" ||
|
||||
got[0].Authorization != "Bearer "+purgeTestToken {
|
||||
t.Fatalf("framework receiver got %+v, want one category purge", got)
|
||||
}
|
||||
if got := alias.take(); len(got) != 0 {
|
||||
t.Fatalf("the deprecated Journal target got %d requests: %+v", len(got), got)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user