diff --git a/.env.example b/.env.example index 31124d6..1f8dd3e 100644 --- a/.env.example +++ b/.env.example @@ -48,6 +48,12 @@ R2_BUCKET=splatoon3-ink-assets R2_ACCESS_KEY_ID= R2_SECRET_ACCESS_KEY= +# Optional JSON edge caching. Deploy and verify purging before enabling cache rules. +# Token permission: Zone > Cache Purge > Purge, restricted to splatoon3.ink. +CLOUDFLARE_CACHE_PURGE_ZONE_ID= +CLOUDFLARE_CACHE_PURGE_API_TOKEN= +CLOUDFLARE_CACHE_PURGE_HOSTS=splatoon3.ink,assets.splatoon3.ink,dev.splatoon3.ink + # Twitter API parameters TWITTER_CONSUMER_KEY= TWITTER_CONSUMER_SECRET= diff --git a/app/sync/CloudflareCachePurger.mjs b/app/sync/CloudflareCachePurger.mjs new file mode 100644 index 0000000..f8d7bc0 --- /dev/null +++ b/app/sync/CloudflareCachePurger.mjs @@ -0,0 +1,72 @@ +import { setTimeout as sleep } from 'node:timers/promises'; + +export default class CloudflareCachePurger +{ + constructor({ zoneId, apiToken, hosts, fetch: fetcher = globalThis.fetch, wait = sleep }) { + if (!/^[a-f0-9]{32}$/.test(zoneId ?? '') || !apiToken || !hosts?.length) { + throw new Error('Cache purging requires a zone ID, API token, and public hosts.'); + } + + if (hosts.some(host => !/^[a-z0-9.-]+$/.test(host)) || hosts.length > 100) { + throw new Error('Cache purge hosts must be hostnames without schemes or paths (at most 100).'); + } + + this.zoneId = zoneId; + this.apiToken = apiToken; + this.hosts = [...new Set(hosts)]; + this.fetch = fetcher; + this.wait = wait; + } + + async purgeData() { + // Prefix purges cover query-string variants and also recover a previous failed + // purge when the next sync has no changed objects. Images remain cached. + const body = JSON.stringify({ + prefixes: this.hosts.map(host => `${host}/data/`), + }); + + for (let attempt = 0; attempt < 3; attempt++) { + let retryDelay = 1000 * 2 ** attempt; + let failure; + + try { + const response = await this.fetch( + `https://api.cloudflare.com/client/v4/zones/${this.zoneId}/purge_cache`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${this.apiToken}`, + 'Content-Type': 'application/json', + }, + body, + signal: AbortSignal.timeout(10_000), + }, + ); + const result = await response.json(); + + if (response.ok && result.success === true) return; + + failure = new Error(`Cloudflare cache purge failed (HTTP ${response.status}).`); + + if (response.status >= 400 && response.status < 500 && response.status !== 429) { + throw Object.assign(failure, { permanent: true }); + } + + const retryAfter = Number(response.headers.get('Retry-After')); + + if (Number.isFinite(retryAfter) && retryAfter > 0) { + retryDelay = Math.min(retryAfter * 1000, 30_000); + } + } catch (error) { + if (error.permanent) throw error; + + // Do not expose response bodies or request credentials in logs. + failure = new Error('Cloudflare cache purge request failed.'); + } + + if (attempt === 2) throw failure; + + await this.wait(retryDelay); + } + } +} diff --git a/app/sync/CloudflareCachePurger.test.mjs b/app/sync/CloudflareCachePurger.test.mjs new file mode 100644 index 0000000..e313c8f --- /dev/null +++ b/app/sync/CloudflareCachePurger.test.mjs @@ -0,0 +1,71 @@ +import { describe, expect, it, vi } from 'vitest'; +import CloudflareCachePurger from './CloudflareCachePurger.mjs'; + +function setup(responses) { + const fetch = vi.fn(); + + for (const response of responses) fetch.mockResolvedValueOnce(response); + + const wait = vi.fn(); + const purger = new CloudflareCachePurger({ + zoneId: 'a'.repeat(32), + apiToken: 'test-token', + hosts: ['splatoon3.ink', 'assets.splatoon3.ink'], + fetch, + wait, + }); + + return { fetch, wait, purger }; +} + +describe('Cloudflare cache purging', () => { + it('purges only the data prefixes on each public host', async () => { + const { fetch, purger } = setup([Response.json({ success: true })]); + + await purger.purgeData(); + + const [url, options] = fetch.mock.calls[0]; + expect(url).toBe(`https://api.cloudflare.com/client/v4/zones/${'a'.repeat(32)}/purge_cache`); + expect(options.method).toBe('POST'); + expect(options.headers.Authorization).toBe('Bearer test-token'); + expect(JSON.parse(options.body)).toEqual({ + prefixes: ['splatoon3.ink/data/', 'assets.splatoon3.ink/data/'], + }); + }); + + it('retries rate limits and honors Retry-After', async () => { + const { fetch, wait, purger } = setup([ + Response.json({ success: false }, { status: 429, headers: { 'Retry-After': '12' } }), + Response.json({ success: true }), + ]); + + await purger.purgeData(); + + expect(fetch).toHaveBeenCalledTimes(2); + expect(wait).toHaveBeenCalledWith(12_000); + }); + + it('reports permanent authentication failure without retrying or leaking response details', async () => { + const { fetch, purger } = setup([ + Response.json({ success: false, errors: ['sensitive detail'] }, { status: 403 }), + ]); + + await expect(purger.purgeData()).rejects.toThrow('Cloudflare cache purge failed (HTTP 403).'); + expect(fetch).toHaveBeenCalledOnce(); + }); + + it('rejects an unsuccessful API result even with HTTP 200', async () => { + const { fetch, purger } = setup(Array.from({ length: 3 }, () => Response.json({ success: false }))); + + await expect(purger.purgeData()).rejects.toThrow('Cloudflare cache purge failed'); + expect(fetch).toHaveBeenCalledTimes(3); + }); + + it('retries network failures with a bounded attempt count', async () => { + const { fetch, purger } = setup([]); + fetch.mockRejectedValue(new Error('network failure with sensitive details')); + + await expect(purger.purgeData()).rejects.toThrow('Cloudflare cache purge request failed.'); + expect(fetch).toHaveBeenCalledTimes(3); + }); +}); diff --git a/app/sync/R2Syncer.mjs b/app/sync/R2Syncer.mjs index 4317e05..640b12b 100644 --- a/app/sync/R2Syncer.mjs +++ b/app/sync/R2Syncer.mjs @@ -17,21 +17,29 @@ function isPublicData(key) { export default class R2Syncer { - constructor({ config = {}, localPath, s3Client, syncClient } = {}) { + constructor({ config = {}, localPath, s3Client, syncClient, cachePurger } = {}) { this.config = config; this._s3Client = s3Client; this._syncClient = syncClient; this._localPath = localPath; + this.cachePurger = cachePurger; } async upload() { this.log('Uploading files...'); - return this.syncClient.sync(this.localPath, this.publicBucket, { + const result = await this.syncClient.sync(this.localPath, this.publicBucket, { filters: this.filters, relocations: this.relocations, commandInput: input => this.commandInput(input), }); + + if (this.cachePurger) { + await this.cachePurger.purgeData(); + this.log('Purged public data caches.'); + } + + return result; } commandInput(input) { @@ -42,6 +50,10 @@ export default class R2Syncer : undefined, }; + if (this.cachePurger && input.Key.startsWith('data/') && input.Key.endsWith('.json')) { + result.CacheControl = 'public, max-age=0, s-maxage=60, must-revalidate'; + } + return result; } diff --git a/app/sync/R2Syncer.test.mjs b/app/sync/R2Syncer.test.mjs index b7b6635..65b2d0d 100644 --- a/app/sync/R2Syncer.test.mjs +++ b/app/sync/R2Syncer.test.mjs @@ -3,7 +3,7 @@ import os from 'node:os'; import fs from 'node:fs/promises'; import { ListObjectsV2Command, PutObjectCommand } from '@aws-sdk/client-s3'; import { S3SyncClient } from 's3-sync-client'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import R2Syncer from './R2Syncer.mjs'; class FakeSyncClient @@ -82,6 +82,48 @@ describe('R2Syncer', () => { }))); }); + it('purges after uploads complete, including syncs with no changes', async () => { + const order = []; + const syncClient = { + sync: async () => { + order.push('uploaded'); + return { created: [], updated: [], deleted: [] }; + }, + }; + const cachePurger = { purgeData: async () => order.push('purged') }; + const syncer = new R2Syncer({ config, syncClient, cachePurger }); + + await syncer.upload(); + await syncer.upload(); + + expect(order).toEqual(['uploaded', 'purged', 'uploaded', 'purged']); + expect(syncer.commandInput({ Key: 'data/schedules.json' }).CacheControl) + .toBe('public, max-age=0, s-maxage=60, must-revalidate'); + expect(syncer.commandInput({ Key: 'splatnet/image.png' }).CacheControl).toBeUndefined(); + }); + + it('does not report success when purging fails', async () => { + const syncer = new R2Syncer({ + config, + syncClient: new FakeSyncClient, + cachePurger: { purgeData: async () => { throw new Error('purge failed'); } }, + }); + + await expect(syncer.upload()).rejects.toThrow('purge failed'); + }); + + it('does not purge before a failed upload batch has completed', async () => { + const purgeData = vi.fn(); + const syncer = new R2Syncer({ + config, + syncClient: { sync: async () => { throw new Error('upload failed'); } }, + cachePurger: { purgeData }, + }); + + await expect(syncer.upload()).rejects.toThrow('upload failed'); + expect(purgeData).not.toHaveBeenCalled(); + }); + it('uploads only public data and generated images with canonical R2 keys', async () => { let syncClient = new FakeSyncClient; let syncer = new R2Syncer({ config, syncClient }); diff --git a/app/sync/index.mjs b/app/sync/index.mjs index f3c6b4c..b8f2485 100644 --- a/app/sync/index.mjs +++ b/app/sync/index.mjs @@ -1,5 +1,20 @@ import S3Syncer from './S3Syncer.mjs'; import R2Syncer from './R2Syncer.mjs'; +import CloudflareCachePurger from './CloudflareCachePurger.mjs'; + +function cachePurger() { + const zoneId = process.env.CLOUDFLARE_CACHE_PURGE_ZONE_ID; + const apiToken = process.env.CLOUDFLARE_CACHE_PURGE_API_TOKEN; + + if (!zoneId && !apiToken) return; + + return new CloudflareCachePurger({ + zoneId, + apiToken, + hosts: (process.env.CLOUDFLARE_CACHE_PURGE_HOSTS ?? '') + .split(',').map(host => host.trim()).filter(Boolean), + }); +} export function canSyncS3() { return !!( @@ -28,13 +43,18 @@ export function canUpload() { } export async function upload() { + // Validate purge settings before starting uploads to either destination. + const purger = canUploadR2() ? cachePurger() : undefined; let uploads = []; if (canSyncS3()) { uploads.push((new S3Syncer).upload()); } if (canUploadR2()) { - uploads.push((new R2Syncer({ config: r2Configuration() })).upload()); + uploads.push((new R2Syncer({ + config: r2Configuration(), + cachePurger: purger, + })).upload()); } if (uploads.length === 0) { diff --git a/app/sync/index.test.mjs b/app/sync/index.test.mjs index 18ad37c..4f4181a 100644 --- a/app/sync/index.test.mjs +++ b/app/sync/index.test.mjs @@ -43,6 +43,9 @@ const environmentKeys = [ 'R2_BUCKET', 'R2_ENDPOINT', 'SITE_URL', + 'CLOUDFLARE_CACHE_PURGE_ZONE_ID', + 'CLOUDFLARE_CACHE_PURGE_API_TOKEN', + 'CLOUDFLARE_CACHE_PURGE_HOSTS', ]; function configureLegacy() { @@ -105,4 +108,14 @@ describe('sync orchestration', () => { expect(mocks.legacyUpload).not.toHaveBeenCalled(); expect(mocks.r2Upload).toHaveBeenCalledOnce(); }); + + it('rejects incomplete purge credentials before publishing anything', async () => { + configureLegacy(); + configureR2(); + process.env.CLOUDFLARE_CACHE_PURGE_ZONE_ID = 'a'.repeat(32); + + await expect(upload()).rejects.toThrow('Cache purging requires'); + expect(mocks.legacyUpload).not.toHaveBeenCalled(); + expect(mocks.r2Upload).not.toHaveBeenCalled(); + }); });