From 4985fd078a43746d1214d8baf1e166937a581a39 Mon Sep 17 00:00:00 2001 From: mattshax Date: Fri, 2 Oct 2026 16:26:28 +0000 Subject: [PATCH] Brand images carry a version in their address --- server/src/routes.ts | 15 ++++++++++----- server/test/manifest.test.mjs | 11 ++++++++++- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/server/src/routes.ts b/server/src/routes.ts index 466314b..2db2fe9 100644 --- a/server/src/routes.ts +++ b/server/src/routes.ts @@ -1,3 +1,4 @@ +import crypto from 'node:crypto' import { getLibrary, requireWritable, listLibraries, publicLibrary, addLibrary, removeLibrary, probeAll } from './libraries.js' import type { FastifyInstance } from 'fastify' import { execFile } from 'node:child_process' @@ -88,10 +89,14 @@ function brandUrl(kind: 'icon' | 'favicon', dark = false): string | null { const configured = kind === 'favicon' && process.env.APP_FAVICON && !dark ? process.env.APP_FAVICON : configuredIcon(dark) - const suffix = dark ? '?variant=dark' : '' - if (isUrl(configured)) return (kind === 'icon' ? '/api/brand-icon' : '/api/favicon') + suffix - if (!configured || !fs.existsSync(configured)) return null - return (kind === 'icon' ? '/api/brand-icon' : '/api/favicon') + suffix + if (!configured || (!isUrl(configured) && !fs.existsSync(configured))) return null + // Browsers keep these for an hour, so the address carries a version: a + // different image, or the same file replaced, is a different URL and is + // fetched at once instead of after the cached copy expires. + let stamp = configured + if (!isUrl(configured)) { try { stamp += `:${fs.statSync(configured).mtimeMs}` } catch { /* existence checked above */ } } + const v = crypto.createHash('sha1').update(stamp).digest('hex').slice(0, 10) + return `${kind === 'icon' ? '/api/brand-icon' : '/api/favicon'}?${dark ? 'variant=dark&' : ''}v=${v}` } export interface WebManifestIcon { src: string; type: string; sizes: string; purpose?: string } @@ -295,7 +300,7 @@ export async function kbRoutes(app: FastifyInstance): Promise { const got = isUrl(configured) ? await fetchRemoteIcon(configured).catch(() => null) : fs.existsSync(configured) ? { body: fs.readFileSync(configured), type: mimeFor(configured) } : null - if (got) brand = { src: '/api/brand-icon', type: got.type, sizes: imageSizes(got.body, got.type) } + if (got) brand = { src: brandUrl('icon') ?? '/api/brand-icon', type: got.type, sizes: imageSizes(got.body, got.type) } } reply.header('Cache-Control', 'no-store, must-revalidate') return reply.type('application/manifest+json').send(webManifest({ name: eff.appName, dark: eff.theme === 'dark', brand })) diff --git a/server/test/manifest.test.mjs b/server/test/manifest.test.mjs index 7f5c90e..39fd897 100644 --- a/server/test/manifest.test.mjs +++ b/server/test/manifest.test.mjs @@ -33,7 +33,8 @@ test('the route names the deployment, puts its icon first at its real size, and assert.equal(m.name, 'Example Research Studio') assert.ok(m.short_name.length <= 12) assert.equal(m.display, 'standalone') - assert.deepEqual(m.icons[0], { src: '/api/brand-icon', type: 'image/png', sizes: '300x200' }) + assert.match(m.icons[0].src, /^\/api\/brand-icon\?v=[0-9a-f]{10}$/) + assert.deepEqual({ ...m.icons[0], src: 'x' }, { src: 'x', type: 'image/png', sizes: '300x200' }) assert.ok(m.icons.some(i => i.sizes === '512x512' && i.purpose === 'maskable')) }) @@ -50,3 +51,11 @@ test('icon sizes: PNG from its header, SVG and unknown as any', () => { assert.equal(imageSizes(Buffer.from(''), 'image/svg+xml'), 'any') assert.equal(imageSizes(Buffer.from('GIF89a'), 'image/gif'), 'any') }) + +test('replacing the icon file changes its address, so browsers do not keep the old one', async () => { + const first = (await app.inject({ method: 'GET', url: '/manifest.webmanifest' })).json().icons[0].src + const later = new Date(Date.now() + 5000) + fs.utimesSync(ICON, later, later) + const second = (await app.inject({ method: 'GET', url: '/manifest.webmanifest' })).json().icons[0].src + assert.notEqual(first, second) +})