From aa1515e7e9785a9d5b584209daa283c76f49b2a7 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 23 Jan 2025 13:00:11 -0600 Subject: [PATCH 1/4] Remove accidental HSTS header from packs/ route --- src/app.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/app.ts b/src/app.ts index b18ba281..5b12b223 100644 --- a/src/app.ts +++ b/src/app.ts @@ -414,8 +414,7 @@ app.get('/instance/*', publicFiles); // Packs contains immutable static files app.get('/packs/*', async (c, next) => { - c.header('Cache-Control', 'public, max-age=31536000, immutable'); - c.header('Strict-Transport-Security', '"max-age=31536000" always'); + c.header('Cache-Control', 'max-age=31536000, public, immutable'); await next(); }, publicFiles); From b8dbc432abd3beba92629f0bcb3e638bae92ab3d Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 23 Jan 2025 13:00:43 -0600 Subject: [PATCH 2/4] Add Cache-Control headers to nostr.json responses --- src/controllers/well-known/nostr.ts | 38 ++++++++++++++++++++--------- 1 file changed, 26 insertions(+), 12 deletions(-) diff --git a/src/controllers/well-known/nostr.ts b/src/controllers/well-known/nostr.ts index b6b7af09..5a2017bb 100644 --- a/src/controllers/well-known/nostr.ts +++ b/src/controllers/well-known/nostr.ts @@ -1,36 +1,50 @@ +import { NostrJson } from '@nostrify/nostrify'; import { z } from 'zod'; import { AppController } from '@/app.ts'; import { localNip05Lookup } from '@/utils/nip05.ts'; -const nameSchema = z.string().min(1).regex(/^\w+$/); +const nameSchema = z.string().min(1).regex(/^[\w.-]+$/); +const emptyResult: NostrJson = { names: {}, relays: {} }; /** * Serves NIP-05's nostr.json. * https://github.com/nostr-protocol/nips/blob/master/05.md */ const nostrController: AppController = async (c) => { + // If there are no query parameters, this will always return an empty result. + if (!Object.entries(c.req.queries()).length) { + c.header('Cache-Control', 'max-age=31536000, public, immutable'); + return c.json(emptyResult); + } + const store = c.get('store'); const result = nameSchema.safeParse(c.req.query('name')); const name = result.success ? result.data : undefined; - const pointer = name ? await localNip05Lookup(store, name) : undefined; if (!name || !pointer) { - return c.json({ names: {}, relays: {} }); + // Not found, cache for 5 minutes. + c.header('Cache-Control', 'max-age=300, public'); + return c.json(emptyResult); } - const { pubkey, relays } = pointer; + const { pubkey, relays = [] } = pointer; - return c.json({ - names: { - [name]: pubkey, - }, - relays: { - [pubkey]: relays, - }, - }); + // It's found, so cache for 12 hours. + c.header('Cache-Control', 'max-age=43200, public'); + + return c.json( + { + names: { + [name]: pubkey, + }, + relays: { + [pubkey]: relays, + }, + } satisfies NostrJson, + ); }; export { nostrController }; From 66f7853c3b76204ff243cb7b392ccfa583d9f370 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 23 Jan 2025 13:04:40 -0600 Subject: [PATCH 3/4] Add cacheControlMiddleware --- src/app.ts | 6 +- src/middleware/cacheControlMiddleware.test.ts | 33 ++++++ src/middleware/cacheControlMiddleware.ts | 102 ++++++++++++++++++ 3 files changed, 137 insertions(+), 4 deletions(-) create mode 100644 src/middleware/cacheControlMiddleware.test.ts create mode 100644 src/middleware/cacheControlMiddleware.ts diff --git a/src/app.ts b/src/app.ts index 5b12b223..98119602 100644 --- a/src/app.ts +++ b/src/app.ts @@ -131,6 +131,7 @@ import { nodeInfoController, nodeInfoSchemaController } from '@/controllers/well import { nostrController } from '@/controllers/well-known/nostr.ts'; import { DittoTranslator } from '@/interfaces/DittoTranslator.ts'; import { auth98Middleware, requireProof, requireRole } from '@/middleware/auth98Middleware.ts'; +import { cacheControlMiddleware } from '@/middleware/cacheControlMiddleware.ts'; import { cspMiddleware } from '@/middleware/cspMiddleware.ts'; import { metricsMiddleware } from '@/middleware/metricsMiddleware.ts'; import { notActivitypubMiddleware } from '@/middleware/notActivitypubMiddleware.ts'; @@ -413,10 +414,7 @@ app.get('/images/*', publicFiles, staticFiles); app.get('/instance/*', publicFiles); // Packs contains immutable static files -app.get('/packs/*', async (c, next) => { - c.header('Cache-Control', 'max-age=31536000, public, immutable'); - await next(); -}, publicFiles); +app.get('/packs/*', cacheControlMiddleware({ maxAge: 31536000, public: true, immutable: true }), publicFiles); // Site index app.get('/', frontendController, indexController); diff --git a/src/middleware/cacheControlMiddleware.test.ts b/src/middleware/cacheControlMiddleware.test.ts new file mode 100644 index 00000000..dd3e0acf --- /dev/null +++ b/src/middleware/cacheControlMiddleware.test.ts @@ -0,0 +1,33 @@ +import { Hono } from '@hono/hono'; +import { assertEquals } from '@std/assert'; + +import { cacheControlMiddleware } from '@/middleware/cacheControlMiddleware.ts'; + +Deno.test('cacheControlMiddleware with multiple options', async () => { + const app = new Hono(); + + app.use(cacheControlMiddleware({ + maxAge: 31536000, + public: true, + immutable: true, + })); + + app.get('/', (c) => c.text('OK')); + + const response = await app.request('/'); + const cacheControl = response.headers.get('Cache-Control'); + + assertEquals(cacheControl, 'max-age=31536000, public, immutable'); +}); + +Deno.test('cacheControlMiddleware with no options does not add header', async () => { + const app = new Hono(); + + app.use(cacheControlMiddleware({})); + app.get('/', (c) => c.text('OK')); + + const response = await app.request('/'); + const cacheControl = response.headers.get('Cache-Control'); + + assertEquals(cacheControl, null); +}); diff --git a/src/middleware/cacheControlMiddleware.ts b/src/middleware/cacheControlMiddleware.ts new file mode 100644 index 00000000..59557e4f --- /dev/null +++ b/src/middleware/cacheControlMiddleware.ts @@ -0,0 +1,102 @@ +import { MiddlewareHandler } from '@hono/hono'; + +/** + * Options for the `cacheControlMiddleware` middleware. + * + * NOTE: All numerical values are in **seconds**. + * + * See the definitions of [fresh](https://developer.mozilla.org/en-US/docs/Web/HTTP/Caching#fresh_and_stale_based_on_age) and [stale](https://developer.mozilla.org/en-US/docs/Web/HTTP/Caching#fresh_and_stale_based_on_age). + */ +export interface CacheControlMiddlewareOpts { + /** Indicates that the response remains fresh until _N_ seconds after the response is generated. */ + maxAge?: number; + /** Indicates how long the response remains fresh in a shared cache. */ + sMaxAge?: number; + /** Indicates that the response can be stored in caches, but the response must be validated with the origin server before each reuse, even when the cache is disconnected from the origin server. */ + noCache?: boolean; + /** Indicates that the response can be stored in caches and can be reused while fresh. */ + mustRevalidate?: boolean; + /** Equivalent of `must-revalidate`, but specifically for shared caches only. */ + proxyRevalidate?: boolean; + /** Indicates that any caches of any kind (private or shared) should not store this response. */ + noStore?: boolean; + /** Indicates that the response can be stored only in a private cache (e.g. local caches in browsers). */ + private?: boolean; + /** Indicates that the response can be stored in a shared cache. */ + public?: boolean; + /** Indicates that a cache should store the response only if it understands the requirements for caching based on status code. */ + mustUnderstand?: boolean; + /** Indicates that any intermediary (regardless of whether it implements a cache) shouldn't transform the response contents. */ + noTransform?: boolean; + /** Indicates that the response will not be updated while it's fresh. */ + immutable?: boolean; + /** Indicates that the cache could reuse a stale response while it revalidates it to a cache. */ + staleWhileRevalidate?: number; + /** indicates that the cache can reuse a stale response when an upstream server generates an error, or when the error is generated locally. */ + staleIfError?: number; +} + +/** Adds a [`Cache-Control`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control) header to the response. */ +export function cacheControlMiddleware(opts: CacheControlMiddlewareOpts): MiddlewareHandler { + return async (c, next) => { + const directives: string[] = []; + + if (typeof opts.maxAge === 'number') { + directives.push(`max-age=${opts.maxAge}`); + } + + if (typeof opts.sMaxAge === 'number') { + directives.push(`s-maxage=${opts.sMaxAge}`); + } + + if (opts.noCache) { + directives.push('no-cache'); + } + + if (opts.mustRevalidate) { + directives.push('must-revalidate'); + } + + if (opts.proxyRevalidate) { + directives.push('proxy-revalidate'); + } + + if (opts.noStore) { + directives.push('no-store'); + } + + if (opts.private) { + directives.push('private'); + } + + if (opts.public) { + directives.push('public'); + } + + if (opts.mustUnderstand) { + directives.push('must-understand'); + } + + if (opts.noTransform) { + directives.push('no-transform'); + } + + if (opts.immutable) { + directives.push('immutable'); + } + + if (typeof opts.staleWhileRevalidate === 'number') { + directives.push(`stale-while-revalidate=${opts.staleWhileRevalidate}`); + } + + if (typeof opts.staleIfError === 'number') { + directives.push(`stale-if-error=${opts.staleIfError}`); + } + + if (directives.length) { + c.header('Cache-Control', directives.join(', ')); + } + + await next(); + }; +} From 871222ee4e631ba1017a9f0e4be0fa7619ecea9a Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 23 Jan 2025 14:11:10 -0600 Subject: [PATCH 4/4] Add Cache-Control headers to a bunch of routes --- src/app.ts | 98 ++++++++++++++++++++++++----- src/controllers/api/fallback.ts | 13 +++- src/controllers/frontend.ts | 1 + src/controllers/well-known/nostr.ts | 8 +-- 4 files changed, 98 insertions(+), 22 deletions(-) diff --git a/src/app.ts b/src/app.ts index 98119602..24d9afe8 100644 --- a/src/app.ts +++ b/src/app.ts @@ -199,15 +199,39 @@ app.use( app.get('/metrics', metricsController); -app.get('/.well-known/nodeinfo', nodeInfoController); +app.get( + '/.well-known/nodeinfo', + cacheControlMiddleware({ maxAge: 300, staleWhileRevalidate: 300, staleIfError: 21600, public: true }), + nodeInfoController, +); app.get('/.well-known/nostr.json', nostrController); -app.get('/nodeinfo/:version', nodeInfoSchemaController); -app.get('/manifest.webmanifest', manifestController); +app.get( + '/nodeinfo/:version', + cacheControlMiddleware({ maxAge: 300, staleWhileRevalidate: 300, staleIfError: 21600, public: true }), + nodeInfoSchemaController, +); +app.get( + '/manifest.webmanifest', + cacheControlMiddleware({ maxAge: 5, staleWhileRevalidate: 5, staleIfError: 21600, public: true }), + manifestController, +); -app.get('/api/v1/instance', instanceV1Controller); -app.get('/api/v2/instance', instanceV2Controller); -app.get('/api/v1/instance/extended_description', instanceDescriptionController); +app.get( + '/api/v1/instance', + cacheControlMiddleware({ maxAge: 5, staleWhileRevalidate: 5, staleIfError: 21600, public: true }), + instanceV1Controller, +); +app.get( + '/api/v2/instance', + cacheControlMiddleware({ maxAge: 5, staleWhileRevalidate: 5, staleIfError: 21600, public: true }), + instanceV2Controller, +); +app.get( + '/api/v1/instance/extended_description', + cacheControlMiddleware({ maxAge: 5, staleWhileRevalidate: 5, staleIfError: 21600, public: true }), + instanceDescriptionController, +); app.get('/api/v1/apps/verify_credentials', appCredentialsController); app.post('/api/v1/apps', createAppController); @@ -296,12 +320,28 @@ app.get('/api/v1/preferences', preferencesController); app.get('/api/v1/search', searchController); app.get('/api/v2/search', searchController); -app.get('/api/pleroma/frontend_configurations', frontendConfigController); +app.get( + '/api/pleroma/frontend_configurations', + cacheControlMiddleware({ maxAge: 5, staleWhileRevalidate: 5, staleIfError: 21600, public: true }), + frontendConfigController, +); app.get('/api/v1/trends/statuses', rateLimitMiddleware(8, Time.seconds(30)), trendingStatusesController); -app.get('/api/v1/trends/links', trendingLinksController); -app.get('/api/v1/trends/tags', trendingTagsController); -app.get('/api/v1/trends', trendingTagsController); +app.get( + '/api/v1/trends/links', + cacheControlMiddleware({ maxAge: 300, staleWhileRevalidate: 300, staleIfError: 21600, public: true }), + trendingLinksController, +); +app.get( + '/api/v1/trends/tags', + cacheControlMiddleware({ maxAge: 300, staleWhileRevalidate: 300, staleIfError: 21600, public: true }), + trendingTagsController, +); +app.get( + '/api/v1/trends', + cacheControlMiddleware({ maxAge: 300, staleWhileRevalidate: 300, staleIfError: 21600, public: true }), + trendingTagsController, +); app.get('/api/v1/suggestions', suggestionsV1Controller); app.get('/api/v2/suggestions', suggestionsV2Controller); @@ -345,7 +385,11 @@ app.post( captchaVerifyController, ); -app.get('/api/v1/ditto/zap_splits', getZapSplitsController); +app.get( + '/api/v1/ditto/zap_splits', + cacheControlMiddleware({ maxAge: 5, staleWhileRevalidate: 5, public: true }), + getZapSplitsController, +); app.get('/api/v1/ditto/:id{[0-9a-f]{64}}/zap_splits', statusZapSplitsController); app.put('/api/v1/admin/ditto/zap_splits', requireRole('admin'), updateZapSplitsController); @@ -409,12 +453,36 @@ app.get('/timeline/*', frontendController); // Known static file routes app.get('/sw.js', publicFiles); -app.get('/favicon.ico', publicFiles, staticFiles); -app.get('/images/*', publicFiles, staticFiles); -app.get('/instance/*', publicFiles); +app.get( + '/favicon.ico', + cacheControlMiddleware({ maxAge: 5, staleWhileRevalidate: 5, staleIfError: 21600, public: true }), + publicFiles, + staticFiles, +); +app.get( + '/images/*', + cacheControlMiddleware({ maxAge: 5, staleWhileRevalidate: 5, staleIfError: 21600, public: true }), + publicFiles, + staticFiles, +); +app.get( + '/instance/*', + cacheControlMiddleware({ maxAge: 5, staleWhileRevalidate: 5, staleIfError: 21600, public: true }), + publicFiles, +); // Packs contains immutable static files -app.get('/packs/*', cacheControlMiddleware({ maxAge: 31536000, public: true, immutable: true }), publicFiles); +app.get( + '/packs/*', + cacheControlMiddleware({ + maxAge: 31536000, + staleWhileRevalidate: 86400, + staleIfError: 21600, + public: true, + immutable: true, + }), + publicFiles, +); // Site index app.get('/', frontendController, indexController); diff --git a/src/controllers/api/fallback.ts b/src/controllers/api/fallback.ts index 0e98ac79..5794c544 100644 --- a/src/controllers/api/fallback.ts +++ b/src/controllers/api/fallback.ts @@ -1,6 +1,13 @@ -import { type Context } from '@hono/hono'; +import { Handler } from '@hono/hono'; -const emptyArrayController = (c: Context) => c.json([]); -const notImplementedController = (c: Context) => Promise.resolve(c.json({ error: 'Not implemented' }, 404)); +const emptyArrayController: Handler = (c) => { + c.header('Cache-Control', 'max-age=300, public, stale-while-revalidate=60'); + return c.json([]); +}; + +const notImplementedController: Handler = (c) => { + c.header('Cache-Control', 'max-age=300, public, stale-while-revalidate=60'); + return c.json({ error: 'Not implemented' }, 404); +}; export { emptyArrayController, notImplementedController }; diff --git a/src/controllers/frontend.ts b/src/controllers/frontend.ts index b1a3bba4..31a19b92 100644 --- a/src/controllers/frontend.ts +++ b/src/controllers/frontend.ts @@ -31,6 +31,7 @@ export const frontendController: AppMiddleware = async (c, next) => { try { const entities = await getEntities(params ?? {}); const meta = renderMetadata(c.req.url, entities); + c.header('Cache-Control', 'max-age=30, public, stale-while-revalidate=30'); return c.html(content.replace(META_PLACEHOLDER, meta)); } catch (e) { console.log(`Error building meta tags: ${e}`); diff --git a/src/controllers/well-known/nostr.ts b/src/controllers/well-known/nostr.ts index 5a2017bb..4fd366e7 100644 --- a/src/controllers/well-known/nostr.ts +++ b/src/controllers/well-known/nostr.ts @@ -14,7 +14,7 @@ const emptyResult: NostrJson = { names: {}, relays: {} }; const nostrController: AppController = async (c) => { // If there are no query parameters, this will always return an empty result. if (!Object.entries(c.req.queries()).length) { - c.header('Cache-Control', 'max-age=31536000, public, immutable'); + c.header('Cache-Control', 'max-age=31536000, public, immutable, stale-while-revalidate=86400'); return c.json(emptyResult); } @@ -26,14 +26,14 @@ const nostrController: AppController = async (c) => { if (!name || !pointer) { // Not found, cache for 5 minutes. - c.header('Cache-Control', 'max-age=300, public'); + c.header('Cache-Control', 'max-age=300, public, stale-while-revalidate=30'); return c.json(emptyResult); } const { pubkey, relays = [] } = pointer; - // It's found, so cache for 12 hours. - c.header('Cache-Control', 'max-age=43200, public'); + // It's found, so cache for 6 hours. + c.header('Cache-Control', 'max-age=21600, public, stale-while-revalidate=3600'); return c.json( {