From fa8c04e297dfae4a9601f4c605815fdc46f7df22 Mon Sep 17 00:00:00 2001 From: seaHi Date: Sat, 26 Sep 2026 19:28:27 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E5=AE=9E=E7=8E=B0=E5=9F=BA?= =?UTF-8?q?=E4=BA=8E=20Cache=20API=20=E4=B8=8E=20Cache-Tag=20=E7=9A=84?= =?UTF-8?q?=E7=BB=9F=E4=B8=80=E5=85=AC=E5=BC=80=E9=A1=B5=E9=9D=A2=E7=BC=93?= =?UTF-8?q?=E5=AD=98=E7=AD=96=E7=95=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks.server.test.ts | 78 ++++++++++++++++ src/hooks.server.ts | 15 ++- src/lib/server/cache.test.ts | 118 ++++++++++++++++++++++++ src/lib/server/cache.ts | 74 +++++++++++++++ src/routes/+page.server.ts | 7 +- src/routes/[slug]/+page.server.ts | 5 +- src/routes/posts/[slug]/+page.server.ts | 5 +- 7 files changed, 287 insertions(+), 15 deletions(-) create mode 100644 src/hooks.server.test.ts create mode 100644 src/lib/server/cache.test.ts create mode 100644 src/lib/server/cache.ts diff --git a/src/hooks.server.test.ts b/src/hooks.server.test.ts new file mode 100644 index 0000000..c4fbb1d --- /dev/null +++ b/src/hooks.server.test.ts @@ -0,0 +1,78 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { RequestEvent } from '@sveltejs/kit'; +import { handle } from './hooks.server'; +import { purgePublicCache } from '$lib/server/cloudflare'; + +vi.mock('$app/environment', () => ({ dev: false })); +vi.mock('$lib/server/access', () => ({ + verifyAccessJwt: vi.fn().mockResolvedValue('admin@example.com') +})); +vi.mock('$lib/server/cloudflare', () => ({ purgePublicCache: vi.fn().mockResolvedValue(null) })); +function input(path = '/admin/posts/1', method = 'POST', authorized = true) { + const url = new URL(path, 'https://blog.example.com'); + return { + event: { + url, + request: new Request(url, { + method, + headers: authorized ? { 'cf-access-jwt-assertion': 'test' } : {} + }), + route: { id: '/admin/posts/[id]' }, + locals: {}, + isDataRequest: false, + platform: { + env: { + CF_DOMAIN: 'https://blog.example.com', + CF_ACCESS_TEAM_DOMAIN: 'https://team.cloudflareaccess.com', + CF_ACCESS_AUD: 'test' + } + } + } as unknown as RequestEvent, + resolve: vi + .fn() + .mockResolvedValue(new Response(null, { status: 303, headers: { Location: '/admin/posts' } })) + }; +} +beforeEach(() => vi.clearAllMocks()); +describe('cache policy integrated with authentication and mutations', () => { + it('invalidates after a completed save and preserves the redirect', async () => { + const request = input(); + const response = await handle(request); + expect(request.resolve).toHaveBeenCalledOnce(); + expect(purgePublicCache).toHaveBeenCalledWith(request.event.platform); + expect(response.status).toBe(303); + expect(response.headers.get('Location')).toBe('/admin/posts'); + expect(response.headers.get('Cache-Control')).toBe('no-store'); + expect(response.headers.get('X-Blogflare-Cache-Purge')).toBe('accepted'); + expect(request.resolve.mock.invocationCallOrder[0]).toBeLessThan( + vi.mocked(purgePublicCache).mock.invocationCallOrder[0] + ); + }); + it('does not purge or execute handlers for an unauthenticated save', async () => { + const request = input('/admin/posts/1', 'POST', false); + vi.spyOn(console, 'error').mockImplementation(() => {}); + const response = await handle(request); + expect(response.status).toBe(401); + expect(request.resolve).not.toHaveBeenCalled(); + expect(purgePublicCache).not.toHaveBeenCalled(); + vi.restoreAllMocks(); + }); + it('does not purge for rejected writes or GET requests', async () => { + const failed = input(); + failed.resolve.mockResolvedValue(new Response('invalid', { status: 400 })); + await handle(failed); + await handle(input('/admin/posts', 'GET')); + expect(purgePublicCache).not.toHaveBeenCalled(); + }); + it('marks purge failure without claiming the content save failed', async () => { + vi.mocked(purgePublicCache).mockResolvedValueOnce('rate limited'); + const response = await handle(input()); + expect(response.status).toBe(303); + expect(response.headers.get('X-Blogflare-Cache-Purge')).toBe('failed'); + }); + it('never caches an API authentication error', async () => { + const response = await handle(input('/api/v1/posts', 'GET', false)); + expect(response.status).toBe(401); + expect(response.headers.get('Cache-Control')).toBe('no-store'); + }); +}); diff --git a/src/hooks.server.ts b/src/hooks.server.ts index f2ee656..296671c 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -1,3 +1,5 @@ +import { applyCachePolicy, isContentMutation } from '$lib/server/cache'; +import { purgePublicCache } from '$lib/server/cloudflare'; import { dev } from '$app/environment'; import { verifyAccessJwt } from '$lib/server/access'; import type { Handle } from '@sveltejs/kit'; @@ -22,7 +24,7 @@ function accessNotConfigured(): Response { }); } -export const handle: Handle = async ({ event, resolve }) => { +const authenticate: Handle = async ({ event, resolve }) => { if (event.url.pathname.startsWith('/api/v1/')) { const authHeader = event.request.headers.get('Authorization'); if (!authHeader || !authHeader.startsWith('Bearer ')) { @@ -118,3 +120,14 @@ export const handle: Handle = async ({ event, resolve }) => { return resolve(event); }; + +export const handle: Handle = async (input) => { + const response = await authenticate(input); + const result = applyCachePolicy(input.event, response); + if (!dev && isContentMutation(input.event) && response.status < 400) { + // Await invalidation before completing the save. Failed purges do not undo saved content. + const error = await purgePublicCache(input.event.platform); + result.headers.set('X-Blogflare-Cache-Purge', error ? 'failed' : 'accepted'); + } + return result; +}; diff --git a/src/lib/server/cache.test.ts b/src/lib/server/cache.test.ts new file mode 100644 index 0000000..95de5cf --- /dev/null +++ b/src/lib/server/cache.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it } from 'vitest'; +import { applyCachePolicy, configuredHostname, isContentMutation, publicCacheTag } from './cache'; + +function event( + route = '/', + path = '/', + method = 'GET', + headers: HeadersInit = {} +): Parameters[0] { + const url = new URL(path, 'https://blog.example.com'); + return { + route: { id: route as Parameters[0]['route']['id'] }, + url, + request: new Request(url, { method, headers }), + platform: { env: { CF_DOMAIN: 'https://blog.example.com' } } as unknown as App.Platform, + isDataRequest: false + }; +} + +describe('public cache policy', () => { + it.each([ + '/', + '/archive', + '/categories', + '/categories/[slug]', + '/tags/[tag]', + '/series', + '/series/[slug]' + ])('caches list route %s for five minutes', (route) => { + const result = applyCachePolicy(event(route), new Response('public')); + expect(result.headers.get('Cache-Control')).toBe('public, max-age=0, s-maxage=300'); + expect(result.headers.get('Cache-Tag')).toBe(publicCacheTag('blog.example.com')); + }); + it.each(['/[slug]', '/posts/[slug]'])('caches detail route %s for an hour', (route) => { + expect( + applyCachePolicy(event(route), new Response('post')).headers.get('Cache-Control') + ).toContain('s-maxage=3600'); + }); + it('keeps page queries distinct and tags all versions for invalidation', () => { + for (const path of [ + '/tags/test?page=1', + '/tags/test?page=2', + '/tags/test?utm_source=test&page=2' + ]) { + const input = event('/tags/[tag]', path); + expect(applyCachePolicy(input, new Response('list')).headers.get('Cache-Tag')).toBe( + publicCacheTag('blog.example.com') + ); + expect(input.url.href).toBe(`https://blog.example.com${path}`); + } + }); + it.each(['/admin', '/admin/api/images', '/api/v1/posts', '/api/slugify', '/missing'])( + 'never caches private or unknown route %s', + (path) => { + expect( + applyCachePolicy(event(path, path), new Response('private')).headers.get('Cache-Control') + ).toBe('no-store'); + } + ); + it('does not cache data responses, errors, redirects, cookies, or explicit private responses', () => { + const data = { + ...event('/posts/[slug]', '/posts/test/__data.json?x-sveltekit-invalidated=11'), + isDataRequest: true + }; + expect(applyCachePolicy(data, new Response('data')).headers.get('Cache-Control')).toBe( + 'no-store' + ); + for (const response of [ + new Response('error', { status: 500 }), + new Response('missing', { status: 404 }), + new Response(null, { status: 303 }), + new Response('cookie', { headers: { 'Set-Cookie': 'session=secret' } }), + new Response('private', { headers: { 'Cache-Control': 'private' } }) + ]) { + expect(applyCachePolicy(event(), response).headers.get('Cache-Control')).toBe('no-store'); + } + }); + it('does not cache authenticated, preview, or mutating requests', () => { + for (const input of [ + event('/', '/', 'POST'), + event('/', '/', 'GET', { Authorization: 'Bearer secret' }), + event('/', '/', 'GET', { Cookie: 'CF_Authorization=secret' }), + event('/', 'https://preview.pages.dev/') + ]) { + expect(applyCachePolicy(input, new Response('content')).headers.get('Cache-Control')).toBe( + 'no-store' + ); + } + }); + it('uses a hostname-specific tag and rejects malformed configuration', () => { + expect(configuredHostname('https://assets.example.com/')).toBe('assets.example.com'); + for (const domain of [ + 'http://example.com', + 'https://user:pass@example.com', + 'https://example.com/path', + 'https://example.com/?a=b', + undefined + ]) + expect(() => configuredHostname(domain)).toThrow(); + expect(publicCacheTag('blog.example.com')).not.toBe(publicCacheTag('other.example.com')); + }); + it('covers every content mutation entry point without purging for reads or uploads', () => { + for (const path of [ + '/admin/posts/new', + '/admin/posts/1', + '/admin/pages/1', + '/admin/categories', + '/admin/tags', + '/admin/series', + '/admin/settings', + '/api/v1/posts', + '/api/v1/posts/old-slug' + ]) + expect(isContentMutation(event('', path, 'POST'))).toBe(true); + expect(isContentMutation(event('', '/admin/posts', 'GET'))).toBe(false); + expect(isContentMutation(event('', '/admin/api/upload', 'POST'))).toBe(false); + }); +}); diff --git a/src/lib/server/cache.ts b/src/lib/server/cache.ts new file mode 100644 index 0000000..356d94e --- /dev/null +++ b/src/lib/server/cache.ts @@ -0,0 +1,74 @@ +import type { RequestEvent } from '@sveltejs/kit'; + +export function configuredHostname(value: string | undefined): string { + if (!value) throw new Error('未配置域名'); + const url = new URL(value); + if ( + url.protocol !== 'https:' || + url.username || + url.password || + url.port || + url.pathname !== '/' || + url.search || + url.hash + ) + throw new Error('域名必须是 HTTPS 根地址'); + return url.hostname; +} + +export function publicCacheTag(hostname: string): string { + return `blogflare-public:${hostname}`; +} + +const detailRoutes = new Set(['/[slug]', '/posts/[slug]']); +const listRoutes = new Set([ + '/', + '/archive', + '/categories', + '/categories/[slug]', + '/tags/[tag]', + '/series', + '/series/[slug]' +]); + +export function applyCachePolicy( + event: Pick, + response: Response +): Response { + const result = new Response(response.body, response); + const route = event.route.id || ''; + const ttl = detailRoutes.has(route) ? 3600 : listRoutes.has(route) ? 300 : 0; + let hostname = ''; + try { + hostname = configuredHostname(event.platform?.env.CF_DOMAIN); + } catch { + /* fail closed */ + } + const cacheable = + !event.isDataRequest && + ttl > 0 && + event.url.hostname === hostname && + ['GET', 'HEAD'].includes(event.request.method) && + response.status === 200 && + !response.headers.has('Set-Cookie') && + !event.request.headers.has('Authorization') && + !/(?:^|;\s*)CF_Authorization=/.test(event.request.headers.get('cookie') || '') && + !/\b(private|no-cache|no-store)\b/i.test(response.headers.get('Cache-Control') || ''); + if (cacheable) { + result.headers.set('Cache-Control', `public, max-age=0, s-maxage=${ttl}`); + result.headers.set('Cache-Tag', publicCacheTag(hostname)); + } else { + result.headers.set('Cache-Control', 'no-store'); + result.headers.delete('Cache-Tag'); + } + return result; +} + +export function isContentMutation(event: Pick): boolean { + return ( + ['POST', 'PUT', 'PATCH', 'DELETE'].includes(event.request.method) && + /^\/(?:admin\/(?:posts|pages|categories|tags|series|settings)|api\/v1\/(?:posts|categories|tags|series))(?:\/|$)/.test( + event.url.pathname + ) + ); +} diff --git a/src/routes/+page.server.ts b/src/routes/+page.server.ts index d74e6f9..d35cd5d 100644 --- a/src/routes/+page.server.ts +++ b/src/routes/+page.server.ts @@ -3,12 +3,7 @@ import { createExcerpt } from '$lib/markdown/excerpt'; import type { PageServerLoad } from './$types'; -export const load: PageServerLoad = async ({ platform, setHeaders }) => { - // Enable CDN Edge caching for 1 hour, browser cache for 1 minute - setHeaders({ - 'Cache-Control': 'public, max-age=60, s-maxage=3600' - }); - +export const load: PageServerLoad = async ({ platform }) => { const db = platform?.env?.DB; if (!db) { diff --git a/src/routes/[slug]/+page.server.ts b/src/routes/[slug]/+page.server.ts index 126abf1..c893cfd 100644 --- a/src/routes/[slug]/+page.server.ts +++ b/src/routes/[slug]/+page.server.ts @@ -4,10 +4,7 @@ import { error } from '@sveltejs/kit'; import type { PageServerLoad } from './$types'; import { renderMarkdown } from '$lib/markdown/renderer'; -export const load: PageServerLoad = async ({ params, platform, parent, setHeaders }) => { - setHeaders({ - 'Cache-Control': 'public, max-age=60, s-maxage=3600' - }); +export const load: PageServerLoad = async ({ params, platform, parent }) => { const db = platform?.env?.DB; const slug = params.slug; const { aliases } = await parent(); diff --git a/src/routes/posts/[slug]/+page.server.ts b/src/routes/posts/[slug]/+page.server.ts index f7f301d..fb748d8 100644 --- a/src/routes/posts/[slug]/+page.server.ts +++ b/src/routes/posts/[slug]/+page.server.ts @@ -18,10 +18,7 @@ interface PostRow { series_weight: number | null; } -export const load: PageServerLoad = async ({ platform, params, parent, setHeaders }) => { - setHeaders({ - 'Cache-Control': 'public, max-age=60, s-maxage=3600' - }); +export const load: PageServerLoad = async ({ platform, params, parent }) => { const db = platform?.env?.DB; const { slug } = params; const { aliases } = await parent();