feat(cache): single-owner caching across nginx, edge and content edits
Gitea Actions Runner Test / test-job (push) Successful in 0s
CI / check (push) Successful in 28s
CI / tests-unit (push) Successful in 1m39s
CI / tests-integration (push) Successful in 1m41s
CI / tests-ui (push) Successful in 2m27s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 3m35s
Gitea Actions Runner Test / test-job (push) Successful in 0s
CI / check (push) Successful in 28s
CI / tests-unit (push) Successful in 1m39s
CI / tests-integration (push) Successful in 1m41s
CI / tests-ui (push) Successful in 2m27s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 3m35s
Rebuild production nginx from the repo (deployment/proxy/*) with a single Cache-Control owner per route: the app stays the source, nginx only manages headers, and Cloudflare stores the public API allowlist at the edge. - deployment/proxy: nginx.conf, mime.types, nginx-cms.conf and the blue/green upstream snippet; config backed by scripts/nginx-sync.sh (idempotent install + reload, --check/--force). - nginx serves Cache-Tag headers on the public allowlist (cms-public), gamedata, client and camera responses so the edge and purge stay in sync. - src/lib/edge-cache.ts + tests: coalesced, fire-and-forget edge purges that no-op unless Cloudflare is configured; scripts/cf-purge.sh and cf-setup-cache.sh create and purge the cache rule. - src/lib/cloudflare-api.ts: purgeCacheByTags/purgeCacheByUrls. - Purge hooks after catalog exports (public + gamedata) and on shop, team, guild, photo and rare-values edits; ci-deploy purges after each release. - src/proxy.ts excludes the imaging/images docs from the middleware matcher.
This commit is contained in:
1 parent
30dcecd530
commit
7697728d07
18 files changed
+1001
-1
No files matched your search
Executable
+64
@@ -0,0 +1,64 @@
|
||||
#!/usr/bin/env bash
|
||||
# Purge the Cloudflare edge cache for one or more Cache-Tags.
|
||||
#
|
||||
# These tags are emitted by nginx (deployment/proxy/nginx-cms.conf):
|
||||
# cms-public - de publieke API-allowlist (staff/teams/guilds/shop/values/…)
|
||||
# cms-gamedata - /gamedata/ (furnidata, config)
|
||||
# cms-client - /client/ + /nitro-client/ (game assets)
|
||||
# cms-camera - /camera/
|
||||
#
|
||||
# Usage:
|
||||
# scripts/cf-purge.sh cms-public
|
||||
# scripts/cf-purge.sh cms-public cms-gamedata cms-client cms-camera
|
||||
#
|
||||
# Reads CLOUDFLARE_API_TOKEN / CLOUDFLARE_ZONE_ID from the environment or the
|
||||
# repository .env. Fails loudly with a clear message when they are missing or
|
||||
# still placeholders, so a pipeline either purges or aborts — never silently
|
||||
# pretends it did.
|
||||
set -euo pipefail
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ENV_FILE="$SCRIPT_DIR/../.env"
|
||||
BASE="https://api.cloudflare.com/client/v4"
|
||||
|
||||
[[ $# -ge 1 ]] || { echo "usage: $0 <tag> [tag ...]" >&2; exit 64; }
|
||||
TAGS=("$@")
|
||||
|
||||
load_env() {
|
||||
local name="$1"
|
||||
if [[ -n "${!name:-}" ]]; then
|
||||
printf -v "$name" '%s' "${!name}"
|
||||
return 0
|
||||
fi
|
||||
if [[ -f "$ENV_FILE" ]]; then
|
||||
local line
|
||||
line="$(grep -m1 "^$name=" "$ENV_FILE" | cut -d= -f2- | tr -d "'\"")" || true
|
||||
if [[ -n "$line" ]]; then
|
||||
printf -v "$name" '%s' "$line"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
load_env CLOUDFLARE_API_TOKEN || { echo "error: CLOUDFLARE_API_TOKEN not configured" >&2; exit 1; }
|
||||
load_env CLOUDFLARE_ZONE_ID || { echo "error: CLOUDFLARE_ZONE_ID not configured" >&2; exit 1; }
|
||||
|
||||
# Placeholder guard: the repo .env historically carried 2-char dummy values.
|
||||
if [[ "${#CLOUDFLARE_API_TOKEN}" -lt 16 || "${#CLOUDFLARE_ZONE_ID}" -lt 16 ]]; then
|
||||
echo "error: Cloudflare credentials look like placeholders; add a real token to .env" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
body="$(python3 -c 'import json,sys; print(json.dumps({"tags": sys.argv[1:]}))' "${TAGS[@]}")"
|
||||
|
||||
resp="$(curl -sS -m 20 -X POST \
|
||||
-H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
--data "$body" \
|
||||
"$BASE/zones/$CLOUDFLARE_ZONE_ID/purge_cache")"
|
||||
|
||||
if ! python3 -c 'import json,sys; sys.exit(0 if json.load(sys.stdin).get("success") else 1)' <<<"$resp"; then
|
||||
echo "error: Cloudflare purge failed: $resp" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "purged tags: ${TAGS[*]}"
|
||||
Executable
+125
@@ -0,0 +1,125 @@
|
||||
#!/usr/bin/env bash
|
||||
# Create/update the Cloudflare Cache Rule that stores the CMS public API
|
||||
# allowlist at the edge (the routes nginx tags with `Cache-Tag: cms-public`).
|
||||
#
|
||||
# Why a rule is required: Cloudflare only caches a handful of file extensions
|
||||
# by default; `/api/*` responses are served `cf-cache-status: DYNAMIC` even
|
||||
# though their `Cache-Control: s-maxage` says they are cacheable. A Cache Rule
|
||||
# with "Cache Everything" turns those the other way.
|
||||
#
|
||||
# What the rule does:
|
||||
# - edge_ttl bypass_by_default : edge cachet volgens de s-maxage van nginx;
|
||||
# zonder (publieke) header (bv. errorresponses) juist NIET cachen.
|
||||
# - browser_ttl respect_origin : de zone heeft "Browser Cache TTL = 1 jaar" en
|
||||
# overschrijft daarmee het max-age dat nginx per klasse stuurt. Deze rule
|
||||
# herstelt dat voor de publieke API's: browsers krijgen de korte
|
||||
# max-age van nginx terug (10/60/300s) i.p.v. een jaar stale data.
|
||||
#
|
||||
# Idempotent: vergelijkt de bestaande rule (op description + inhoud) en zet
|
||||
# alleen bij als die verschilt. Re-running is veilig.
|
||||
#
|
||||
# Usage (after putting a real token + zone id in .env):
|
||||
# scripts/cf-setup-cache.sh
|
||||
#
|
||||
# Requires a token with Zone > Cache Rules (edit) permission.
|
||||
set -euo pipefail
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ENV_FILE="$SCRIPT_DIR/../.env"
|
||||
BASE="https://api.cloudflare.com/client/v4"
|
||||
PHASE="http_request_cache_settings"
|
||||
DESCRIPTION="EpicNabbo CMS public API edge cache (cms-public)"
|
||||
|
||||
# Cache de allowlist exact zoals nginx hem tagt (deployment/proxy/nginx-cms.conf).
|
||||
# Geen regex: `matches` vereist Business; vrije operators zijn `in` en
|
||||
# `starts_with()`.
|
||||
EXPRESSION='(http.request.method eq "GET") and (http.request.uri.path in { "/api/staff" "/api/teams" "/api/guilds" "/api/photos" "/api/leaderboard" "/api/online" "/api/online/count" "/api/shop" "/api/shop/categories" "/api/values" "/api/values/categories" "/api/radio/current-dj" "/api/radio/points/leaderboard" } or starts_with(http.request.uri.path, "/api/values/"))'
|
||||
|
||||
load_env() {
|
||||
local name="$1"
|
||||
if [[ -n "${!name:-}" ]]; then
|
||||
printf -v "$name" '%s' "${!name}"
|
||||
return 0
|
||||
fi
|
||||
if [[ -f "$ENV_FILE" ]]; then
|
||||
local line
|
||||
line="$(grep -m1 "^$name=" "$ENV_FILE" | cut -d= -f2- | tr -d "'\"")" || true
|
||||
if [[ -n "$line" ]]; then
|
||||
printf -v "$name" '%s' "$line"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
load_env CLOUDFLARE_API_TOKEN || { echo "error: CLOUDFLARE_API_TOKEN not configured" >&2; exit 1; }
|
||||
load_env CLOUDFLARE_ZONE_ID || { echo "error: CLOUDFLARE_ZONE_ID not configured" >&2; exit 1; }
|
||||
if [[ "${#CLOUDFLARE_API_TOKEN}" -lt 16 || "${#CLOUDFLARE_ZONE_ID}" -lt 16 ]]; then
|
||||
echo "error: Cloudflare credentials look like placeholders; add a real token to .env" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
api() {
|
||||
curl -sS -m 30 -X "$1" \
|
||||
-H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
--data "${2:-}" \
|
||||
"$BASE/zones/$CLOUDFLARE_ZONE_ID${3:-}"
|
||||
}
|
||||
|
||||
echo "--- reading existing cache-settings ruleset ---"
|
||||
existing="$(api GET "" "/rulesets/phases/$PHASE/entrypoint")"
|
||||
if ! python3 -c 'import json,sys; sys.exit(0 if json.load(sys.stdin).get("success") else 1)' <<<"$existing"; then
|
||||
echo "error: could not read ruleset: $existing" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
rule_json="$(DESCRIPTION="$DESCRIPTION" EXPRESSION="$EXPRESSION" python3 - <<'PY'
|
||||
import json, os
|
||||
print(json.dumps({
|
||||
"description": os.environ["DESCRIPTION"],
|
||||
"expression": os.environ["EXPRESSION"],
|
||||
"action": "set_cache_settings",
|
||||
"action_parameters": {
|
||||
"cache": True,
|
||||
"edge_ttl": {"mode": "bypass_by_default"},
|
||||
"browser_ttl": {"mode": "respect_origin"},
|
||||
},
|
||||
}))
|
||||
PY
|
||||
)"
|
||||
|
||||
out="$(EXISTING_JSON="$existing" RULE_JSON="$rule_json" python3 - <<'PY'
|
||||
import json, os
|
||||
existing = json.loads(os.environ["EXISTING_JSON"])
|
||||
rule = json.loads(os.environ["RULE_JSON"])
|
||||
result = existing.get("result") or {}
|
||||
rules = list(result.get("rules") or [])
|
||||
|
||||
def check(r):
|
||||
return {k: r.get(k) for k in ("description", "expression", "action", "action_parameters")}
|
||||
|
||||
keep = [r for r in rules if r.get("description") != rule["description"]]
|
||||
present = [r for r in rules if r.get("description") == rule["description"]]
|
||||
if present and check(present[0]) == check(rule):
|
||||
print("same")
|
||||
else:
|
||||
keep.append(rule)
|
||||
print("changed")
|
||||
print(json.dumps({"rules": keep}))
|
||||
PY
|
||||
)"
|
||||
|
||||
status="$(sed -n '1p' <<<"$out")"
|
||||
if [ "$status" = "same" ]; then
|
||||
echo "rule already present en identiek — geen wijzigingen"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
payload="$(sed -n '2,$p' <<<"$out")"
|
||||
echo "--- ${DESCRIPTION}: rule bijwerken ---"
|
||||
resp="$(api PUT "$payload" "/rulesets/phases/$PHASE/entrypoint")"
|
||||
if ! python3 -c 'import json,sys; sys.exit(0 if json.load(sys.stdin).get("success") else 1)' <<<"$resp"; then
|
||||
echo "error: could not save ruleset: $resp" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "cache rule live. Verify: curl -s https://epicnabbo.nl/api/shop -o /dev/null -D - | grep -i cf-cache-status"
|
||||
@@ -396,6 +396,12 @@ if [ "$secondary_backup_created" -eq 1 ]; then docker rm "$secondary_backup" ||
|
||||
|
||||
|
||||
echo "Deployment verified: $sha"
|
||||
# Een deploy kan de game client, furnidata (gamedata), camera en public API data
|
||||
# verversen. Laat de Cloudflare edge-cache van die tags los (best-effort: alleen
|
||||
# wanneer er een echte token + zone-id geconfigureerd is; no-op anders).
|
||||
if [ -x "$deploy_dir/scripts/cf-purge.sh" ]; then
|
||||
bash "$deploy_dir/scripts/cf-purge.sh" cms-public cms-gamedata cms-client cms-camera || true
|
||||
fi
|
||||
# Retain the current and previous releases; do not remove arbitrary named tags.
|
||||
while IFS= read -r tag; do
|
||||
if [[ "$tag" =~ ^epicnext-cms:(verified-)?[0-9a-f]{40}$ ]] && [ "$tag" != "$image" ] && [ "$tag" != "epicnext-cms:verified-$sha" ]; then
|
||||
|
||||
Executable
+110
@@ -0,0 +1,110 @@
|
||||
#!/usr/bin/env bash
|
||||
# Sync the nginx config from this repository to /etc/nginx and reload it.
|
||||
#
|
||||
# Background: on 2026-09-26 /etc/nginx and /var/log/nginx disappeared from the
|
||||
# host while nginx kept serving its in-memory config; any restart would have
|
||||
# taken the CMS down. This script makes the repo the source of truth so that
|
||||
# cannot happen again. It is idempotent and only reloads nginx when the config
|
||||
# actually changed.
|
||||
#
|
||||
# Usage:
|
||||
# sudo scripts/nginx-sync.sh # install + test + reload if changed
|
||||
# sudo scripts/nginx-sync.sh --force # always reload after a passing test
|
||||
# scripts/nginx-sync.sh --check # just diff repo vs live, no writes
|
||||
#
|
||||
# Files installed (see also deployment/proxy/):
|
||||
# nginx.conf -> /etc/nginx/nginx.conf
|
||||
# nginx-mime.types -> /etc/nginx/mime.types
|
||||
# nginx-cms.conf -> /etc/nginx/sites-available/cms.conf
|
||||
# cms_upstream_servers.conf -> /etc/nginx/snippets/cms_upstream_servers.conf
|
||||
# symlink sites-enabled/cms.conf -> ../sites-available/cms.conf
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROXY_DIR="$SCRIPT_DIR/../deployment/proxy"
|
||||
NGINX_DIR=/etc/nginx
|
||||
BACKUP_DIR="/var/backups/nginx-$(date +%Y%m%d-%H%M%S)"
|
||||
MODE="sync"
|
||||
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--force) MODE="force" ;;
|
||||
--check) MODE="check" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
install_file() {
|
||||
local src="$1" dst="$2"
|
||||
if [[ ! -f "$src" ]]; then
|
||||
echo "error: $src not found in repo" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ -f "$dst" ]] && cmp -s "$src" "$dst"; then
|
||||
echo "= $dst up to date"
|
||||
return 1
|
||||
fi
|
||||
if [[ "$MODE" == "check" ]]; then
|
||||
echo "- $dst differs from repo"
|
||||
return 0
|
||||
fi
|
||||
mkdir -p "$(dirname "$dst")"
|
||||
if [[ -f "$dst" ]]; then
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
cp -a "$dst" "$BACKUP_DIR/"
|
||||
fi
|
||||
cp -a "$src" "$dst"
|
||||
echo "+ installed $dst"
|
||||
return 0
|
||||
}
|
||||
|
||||
if [[ "$MODE" != "check" && "$(id -u)" -ne 0 ]]; then
|
||||
echo "error: run as root (sudo scripts/nginx-sync.sh)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
changed=0
|
||||
if install_file "$PROXY_DIR/nginx.conf" "$NGINX_DIR/nginx.conf"; then changed=1; fi
|
||||
if install_file "$PROXY_DIR/nginx-mime.types" "$NGINX_DIR/mime.types"; then changed=1; fi
|
||||
if install_file "$PROXY_DIR/nginx-cms.conf" "$NGINX_DIR/sites-available/cms.conf"; then changed=1; fi
|
||||
if install_file "$PROXY_DIR/cms_upstream_servers.conf" "$NGINX_DIR/snippets/cms_upstream_servers.conf"; then changed=1; fi
|
||||
|
||||
if [[ ! -f "$NGINX_DIR/sites-enabled/cms.conf" ]]; then
|
||||
if [[ "$MODE" == "check" ]]; then
|
||||
echo "- sites-enabled/cms.conf missing"
|
||||
changed=1
|
||||
else
|
||||
ln -sf ../sites-available/cms.conf "$NGINX_DIR/sites-enabled/cms.conf"
|
||||
echo "+ linked sites-enabled/cms.conf"
|
||||
changed=1
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ "$MODE" == "check" ]]; then
|
||||
[[ "$changed" -eq 0 ]]
|
||||
exit
|
||||
fi
|
||||
|
||||
if [[ "$MODE" == "force" ]]; then
|
||||
changed=1
|
||||
fi
|
||||
|
||||
if [[ ! -d /var/log/nginx ]]; then
|
||||
install -d -o root -g adm -m 750 /var/log/nginx
|
||||
fi
|
||||
for f in /var/log/nginx/access.log /var/log/nginx/error.log; do
|
||||
[[ -f "$f" ]] || touch "$f"
|
||||
done
|
||||
|
||||
echo "--- nginx -t ---"
|
||||
nginx -t
|
||||
|
||||
if [[ "$changed" -eq 1 ]]; then
|
||||
echo "--- reloading nginx ---"
|
||||
nginx -s reload
|
||||
else
|
||||
echo "no changes; nginx reload skipped"
|
||||
fi
|
||||
|
||||
echo "--- health check ---"
|
||||
curl -sf "http://127.0.0.1:3002/api/health" > /dev/null && echo "OK: CMS reachable"
|
||||
curl -skf -o /dev/null -H "Host: epicnabbo.nl" "https://127.0.0.1:9443/health" && echo "OK: nginx :9443 /health"
|
||||
Reference in new issue
Block a user