From 13501352729866fefb4871999f8ebd470a5b896d Mon Sep 17 00:00:00 2001 From: Dan Mo Date: Sun, 25 Oct 2026 17:27:29 +1100 Subject: [PATCH] Keep uploads, result images and audio in the browser cache across days The service-worker cache held only result MP4s (1 GB, 64 MB each). It now keeps any original image/video/audio from /v1/artifacts or /v1/assets (thumbnails keep their own lane), checks the downloaded type matches the authorized HEAD, and holds up to 4 GB / 256 MB per file with LRU eviction. Co-Authored-By: Claude Opus 5.5 --- studio-app/src/i18n/settingsCatalogs.js | 22 ++++----- studio-app/src/media-read-gate.test.js | 17 +++++-- studio-app/src/video-cache-client.js | 6 ++- studio-app/src/video-cache-core.js | 59 ++++++++++++++++--------- studio-app/src/video-cache-store.js | 8 ++-- studio-app/src/video-cache-worker.js | 2 +- studio-app/src/video-cache.test.js | 34 ++++++++++++-- 7 files changed, 102 insertions(+), 46 deletions(-) diff --git a/studio-app/src/i18n/settingsCatalogs.js b/studio-app/src/i18n/settingsCatalogs.js index 24f32f7..f25e4a0 100644 --- a/studio-app/src/i18n/settingsCatalogs.js +++ b/studio-app/src/i18n/settingsCatalogs.js @@ -155,24 +155,24 @@ settingsCatalogs.en['settings.costDetails'] = 'Cost details'; settingsCatalogs.zh['settings.costDetails'] = '费用详情'; Object.assign(settingsCatalogs.en, { - 'settings.videoCache': 'Video cache', + 'settings.videoCache': 'Media cache', 'settings.videoCacheClear': 'Clear', - 'settings.videoCacheUnavailable': 'Video cache is unavailable in this browser.', - 'settings.videoCacheChecking': 'Checking video cache…', - 'settings.videoCacheUsage': '{amount} MB used · {count} videos · up to 1 GB', - 'settings.videoCacheUsageOne': '{amount} MB used · 1 video · up to 1 GB', + 'settings.videoCacheUnavailable': 'Media cache is unavailable in this browser.', + 'settings.videoCacheChecking': 'Checking media cache…', + 'settings.videoCacheUsage': '{amount} MB used · {count} files · up to 4 GB', + 'settings.videoCacheUsageOne': '{amount} MB used · 1 file · up to 4 GB', 'settings.videoCacheHelp': - 'Played videos up to 64 MB are kept here. Access is checked before replay. Signing out clears cached copies; originals stay online.', + 'Videos, images and audio you open are kept here so they load without downloading again. Access is checked each time. Signing out clears cached copies; originals stay online.', }); Object.assign(settingsCatalogs.zh, { - 'settings.videoCache': '视频缓存', + 'settings.videoCache': '媒体缓存', 'settings.videoCacheClear': '清空', - 'settings.videoCacheUnavailable': '此浏览器暂时无法使用视频缓存。', + 'settings.videoCacheUnavailable': '此浏览器暂时无法使用媒体缓存。', 'settings.videoCacheChecking': '正在读取缓存…', - 'settings.videoCacheUsage': '已用 {amount} MB · {count} 条视频 · 最多 1 GB', - 'settings.videoCacheUsageOne': '已用 {amount} MB · 1 条视频 · 最多 1 GB', + 'settings.videoCacheUsage': '已用 {amount} MB · {count} 个文件 · 最多 4 GB', + 'settings.videoCacheUsageOne': '已用 {amount} MB · 1 个文件 · 最多 4 GB', 'settings.videoCacheHelp': - '缓存已播放且不超过 64 MB 的视频,重播前仍会核对权限。退出登录会清空缓存,线上原件保留。', + '打开过的视频、图片和音频会保存在这里,下次不用重新下载,每次仍会核对权限。退出登录会清空缓存,线上原件保留。', }); Object.assign(settingsCatalogs.en, { diff --git a/studio-app/src/media-read-gate.test.js b/studio-app/src/media-read-gate.test.js index d028418..4a2087b 100644 --- a/studio-app/src/media-read-gate.test.js +++ b/studio-app/src/media-read-gate.test.js @@ -270,7 +270,7 @@ test('original init/signal and non-content requests pass through unchanged, with assert.equal(calls.length, 2); }); -test('the actual worker flow-controls native asset GET/HEAD without opening the video cache', async () => { +test('the actual worker flow-controls native asset GET/HEAD and streams when the cache is unavailable', async () => { const previousSelf = globalThis.self, previousFetch = globalThis.fetch, previousDB = globalThis.indexedDB, @@ -284,7 +284,7 @@ test('the actual worker flow-controls native asset GET/HEAD without opening the }; globalThis.indexedDB = { open() { - throw Error('asset passthrough must not open a video cache'); + throw Error('cache storage unavailable'); }, }; globalThis.fetch = async (value, init = {}) => { @@ -319,9 +319,18 @@ test('the actual worker flow-controls native asset GET/HEAD without opening the }), ); assert.deepEqual(values, ['asset', 'asset', '', 'asset']); - assert.equal(calls.length, 4); + // Each asset GET first rechecks its owner with HEAD (uploads are now cacheable); + // without IndexedDB the original response still streams through the gate. + assert.equal(calls.length, 7); + assert.equal( + calls.filter((call) => (call.init.method || call.value.method) === 'HEAD').length, + 4, + ); for (const call of calls) - assert.equal(call.value.headers.get('X-Expected-Account'), 'superdan'); + assert.equal( + new Headers(call.init.headers || call.value.headers).get('X-Expected-Account'), + 'superdan', + ); } finally { globalThis.self = previousSelf; globalThis.fetch = previousFetch; diff --git a/studio-app/src/video-cache-client.js b/studio-app/src/video-cache-client.js index 36b07be..9e8df86 100644 --- a/studio-app/src/video-cache-client.js +++ b/studio-app/src/video-cache-client.js @@ -65,7 +65,7 @@ function playedVideoTarget(element, account, origin) { return null; try { const url = new URL(element.currentSrc || element.src, origin), - match = /^\/v1\/artifacts\/([A-Za-z0-9_-]{1,160})\/content$/.exec(url.pathname); + match = /^\/v1\/(?:artifacts|assets)\/([A-Za-z0-9_-]{1,160})\/content$/.exec(url.pathname); if ( url.origin !== origin || !match || @@ -73,7 +73,9 @@ function playedVideoTarget(element, account, origin) { url.searchParams.get('expected_account') !== account) ) return null; - return { account, id: match[1] }; + return url.pathname.startsWith('/v1/assets/') + ? { account, id: match[1], path: url.pathname } + : { account, id: match[1] }; } catch { return null; } diff --git a/studio-app/src/video-cache-core.js b/studio-app/src/video-cache-core.js index 90a3b0d..16e0733 100644 --- a/studio-app/src/video-cache-core.js +++ b/studio-app/src/video-cache-core.js @@ -1,17 +1,26 @@ import { VIDEO_FILE_LIMIT } from './video-cache-store.js'; -const ID = /^[A-Za-z0-9_-]{1,160}$/, - ACCOUNT = /^[A-Za-z0-9_-]{1,80}$/; +const ID = /^[A-Za-z0-9_-]{1,220}$/, + ACCOUNT = /^[A-Za-z0-9_-]{1,80}$/, + RENDITION = /^[a-z0-9_-]{1,40}$/; +// Original result media and owner uploads. Both are hash-verified objects that +// never change under the same id, so one copy can be kept across days. +const mediaType = (value) => { + const type = value?.split(';')[0].trim().toLowerCase(); + return /^(?:image|video|audio)\/[a-z0-9.+-]{1,60}$/.test(type || '') ? type : null; +}; function videoTarget(value, origin) { try { const url = new URL(value, origin), - match = /^\/v1\/artifacts\/([A-Za-z0-9_-]{1,160})\/content$/.exec(url.pathname); + match = /^\/v1\/(artifacts|assets)\/([A-Za-z0-9_-]{1,160})\/content$/.exec(url.pathname); if (url.origin !== origin || !match || url.hash || url.username || url.password) return null; for (const key of url.searchParams.keys()) if ( - !['expected_account', 'download'].includes(key) || + !['expected_account', 'download', 'rendition'].includes(key) || url.searchParams.getAll(key).length !== 1 ) return null; + const rendition = url.searchParams.get('rendition'); + if (rendition !== null && (!RENDITION.test(rendition) || rendition === 'thumb')) return null; const account = url.searchParams.get('expected_account'); if (account !== null && !ACCOUNT.test(account)) return null; if ( @@ -19,7 +28,10 @@ function videoTarget(value, origin) { !['0', '1', 'true', 'false'].includes(url.searchParams.get('download')) ) return null; - return { id: match[1], expected: account, url: url.pathname + url.search }; + // Artifact originals keep their earlier cache key; others are namespaced. + const id = + (match[1] === 'assets' ? 'as_' : '') + match[2] + (rendition ? '_r_' + rendition : ''); + return { id, expected: account, url: url.pathname + url.search }; } catch { return null; } @@ -52,7 +64,7 @@ function responseFor(blob, request, head) { const size = blob.size, range = byteRange(request.headers.get('Range'), size), headers = new Headers({ - 'Content-Type': 'video/mp4', + 'Content-Type': blob.type, 'Accept-Ranges': 'bytes', 'Cache-Control': 'private, no-store', 'Referrer-Policy': 'no-referrer', @@ -66,7 +78,7 @@ function responseFor(blob, request, head) { } headers.set('X-Inkseq-Video-Cache', 'hit'); headers.set('X-Authenticated-Account', head.headers.get('X-Authenticated-Account')); - const body = range ? blob.slice(range.start, range.end + 1, 'video/mp4') : blob; + const body = range ? blob.slice(range.start, range.end + 1, blob.type) : blob; headers.set('Content-Length', String(body.size)); if (range) headers.set('Content-Range', `bytes ${range.start}-${range.end}/${size}`); return new Response(body, { status: range ? 206 : 200, headers }); @@ -76,11 +88,12 @@ const failure = (status) => status: [400, 401, 403, 404, 409].includes(status) ? status : 503, headers: { 'Cache-Control': 'no-store' }, }); -async function completeBody(response, size) { +async function completeBody(response, size, type) { if ( response.status !== 200 || ['opaque', 'opaqueredirect', 'error'].includes(response.type) || - response.headers.get('Content-Type')?.split(';')[0].trim() !== 'video/mp4' || + !type || + mediaType(response.headers.get('Content-Type')) !== type || response.headers.get('Content-Range') || Number(response.headers.get('Content-Length')) !== size ) { @@ -102,7 +115,7 @@ async function completeBody(response, size) { } chunks.push(part.value); } - return bytes === size ? new Blob(chunks, { type: 'video/mp4' }) : null; + return bytes === size ? new Blob(chunks, { type }) : null; } catch { void reader.cancel().catch(() => {}); return null; @@ -148,14 +161,15 @@ function createVideoCache({ store, origin, fetcher = (...args) => fetch(...args) epoch: token.epoch, key: cacheKey(account, target.id), size, + type: mediaType(head.headers.get('Content-Type')), eligible: - head.headers.get('Content-Type')?.split(';')[0].trim() === 'video/mp4' && + !!mediaType(head.headers.get('Content-Type')) && Number.isSafeInteger(size) && size <= VIDEO_FILE_LIMIT, }; } async function saveResponse(target, auth, response) { - const blob = await completeBody(response, auth.size); + const blob = await completeBody(response, auth.size, auth.type); if (!blob) return null; const stored = await store.put( { key: auth.key, account: auth.account, id: target.id, size: blob.size, blob }, @@ -194,21 +208,24 @@ function createVideoCache({ store, origin, fetcher = (...args) => fetch(...args) filling.set(key, task); return task; } - async function ensure(account, id) { - if (!cacheKey(account, id)) return { cached: false }; + async function ensure(account, id, path) { + // `path` names an asset or rendition original; a bare id is a result original. + const url = new URL( + typeof path === 'string' ? path : '/v1/artifacts/' + id + '/content', + origin, + ); + url.searchParams.set('expected_account', account); + const target = url.origin === origin ? videoTarget(url.pathname + url.search, origin) : null; + if (!target || !cacheKey(account, target.id)) return { cached: false }; try { - const target = videoTarget( - '/v1/artifacts/' + id + '/content?expected_account=' + encodeURIComponent(account), - origin, - ), - snapshot = await store.snapshot(), + const snapshot = await store.snapshot(), auth = await authorize(target, snapshot.epoch); if (auth.error || !auth.eligible) return { cached: false }; const hit = await store.get(auth.key, auth.epoch); if ( hit?.size === auth.size && hit.blob instanceof Blob && - hit.blob.type === 'video/mp4' && + hit.blob.type === auth.type && hit.blob.size === auth.size ) return { cached: true }; @@ -249,7 +266,7 @@ function createVideoCache({ store, origin, fetcher = (...args) => fetch(...args) if ( hit?.size === auth.size && hit.blob instanceof Blob && - hit.blob.type === 'video/mp4' && + hit.blob.type === auth.type && hit.blob.size === auth.size ) { if ((await store.snapshot()).epoch !== auth.epoch) return failure(409); diff --git a/studio-app/src/video-cache-store.js b/studio-app/src/video-cache-store.js index 5618e6f..3781813 100644 --- a/studio-app/src/video-cache-store.js +++ b/studio-app/src/video-cache-store.js @@ -1,7 +1,9 @@ // Disposable media bytes only. This database never authorizes a user or a job. -const VIDEO_CACHE_LIMIT = 1024 * 1024 * 1024, - VIDEO_FILE_LIMIT = 64 * 1024 * 1024, - VIDEO_ENTRY_LIMIT = 256; +// Owner 2026-10-25: keep media as long as possible. The browser's own quota +// still applies; a refused write leaves the original network response intact. +const VIDEO_CACHE_LIMIT = 4 * 1024 * 1024 * 1024, + VIDEO_FILE_LIMIT = 256 * 1024 * 1024, + VIDEO_ENTRY_LIMIT = 4096; function evictionPlan( entries, incoming, diff --git a/studio-app/src/video-cache-worker.js b/studio-app/src/video-cache-worker.js index e84664d..1cd6cfb 100644 --- a/studio-app/src/video-cache-worker.js +++ b/studio-app/src/video-cache-worker.js @@ -34,7 +34,7 @@ self.addEventListener('message', (event) => { : data.action === 'status' ? await cache.stats(data.account) : data.action === 'played' - ? await cache.ensure(data.account, data.id) + ? await cache.ensure(data.account, data.id, data.path) : null; if (result === null) throw Error('invalid action'); reply.postMessage({ ok: true, result }); diff --git a/studio-app/src/video-cache.test.js b/studio-app/src/video-cache.test.js index 31ccfe2..7954f5c 100644 --- a/studio-app/src/video-cache.test.js +++ b/studio-app/src/video-cache.test.js @@ -135,10 +135,16 @@ function fixture({ body = '0123456789', account = A, wrapFetcher = (fetcher) => }; } -test('only canonical artifact URLs enter the cache, never assets/Agent credentials/signed URLs', async () => { +test('only canonical original media URLs enter the cache, never Agent credentials/signed URLs', async () => { assert.deepEqual(videoTarget(ORIGIN + path(), ORIGIN), { id: 'v1', expected: A, url: path() }); + // Owner uploads are cached under their own namespace; thumbnails keep their lane. + assert.deepEqual(videoTarget(ORIGIN + '/v1/assets/v1/content', ORIGIN), { + id: 'as_v1', + expected: null, + url: '/v1/assets/v1/content', + }); for (const url of [ - ORIGIN + '/v1/assets/v1/content', + ORIGIN + '/v1/assets/v1/content?rendition=thumb', ORIGIN + path() + '&token=secret', ORIGIN + path() + '&expected_account=' + A, ORIGIN + path() + '#fragment', @@ -423,8 +429,8 @@ test('partial, opaque, wrong-type, truncated and corrupt responses are never cac test('metadata LRU is globally bounded by bytes and count, with correct replacement', () => { const entry = (key, size, used) => ({ key, size, used }); - assert.equal(VIDEO_CACHE_LIMIT, 1024 ** 3); - assert.equal(VIDEO_FILE_LIMIT, 64 * 1024 ** 2); + assert.equal(VIDEO_CACHE_LIMIT, 4 * 1024 ** 3); + assert.equal(VIDEO_FILE_LIMIT, 256 * 1024 ** 2); assert.deepEqual( evictionPlan([entry('old', 4, 1), entry('newer', 4, 2)], entry('in', 4, 3), { maxBytes: 8 }), { remove: ['old'], bytes: 8, count: 2 }, @@ -746,3 +752,23 @@ test('signed-in pages ask once for durable storage and never re-ask', async () = await new Promise((resolve) => setTimeout(resolve, 0)); assert.deepEqual(calls, ['persisted', 'persist']); }); + +test('an uploaded image is kept across views and served with its own type', async () => { + const f = fixture(); + f.set({ + headMime: 'image/png', + full: () => + new Response('0123456789', { + headers: { 'Content-Type': 'image/png', 'Content-Length': '10' }, + }), + }); + const url = ORIGIN + '/v1/assets/a1/content?expected_account=' + A; + const first = await f.cache.handle(new Request(url)); + assert.equal(await first.text(), '0123456789'); + assert.equal(f.store.writes[0].key, A + '\0as_a1'); + const again = await f.cache.handle(new Request(url)); + assert.equal(again.headers.get('X-Inkseq-Video-Cache'), 'hit'); + assert.equal(again.headers.get('Content-Type'), 'image/png'); + assert.equal(await again.text(), '0123456789'); + assert.equal(f.calls.filter((c) => c.method === 'GET').length, 1); +});