Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 11 additions & 11 deletions studio-app/src/i18n/settingsCatalogs.js
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand Down
17 changes: 13 additions & 4 deletions studio-app/src/media-read-gate.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 = {}) => {
Expand Down Expand Up @@ -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;
Expand Down
6 changes: 4 additions & 2 deletions studio-app/src/video-cache-client.js
Original file line number Diff line number Diff line change
Expand Up @@ -65,15 +65,17 @@ 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 ||
(url.searchParams.get('expected_account') &&
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;
}
Expand Down
59 changes: 38 additions & 21 deletions studio-app/src/video-cache-core.js
Original file line number Diff line number Diff line change
@@ -1,25 +1,37 @@
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 (
url.searchParams.has('download') &&
!['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;
}
Expand Down Expand Up @@ -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',
Expand All @@ -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 });
Expand All @@ -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
) {
Expand All @@ -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;
Expand Down Expand Up @@ -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 },
Expand Down Expand Up @@ -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 };
Expand Down Expand Up @@ -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);
Expand Down
8 changes: 5 additions & 3 deletions studio-app/src/video-cache-store.js
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
2 changes: 1 addition & 1 deletion studio-app/src/video-cache-worker.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
34 changes: 30 additions & 4 deletions studio-app/src/video-cache.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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 },
Expand Down Expand Up @@ -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);
});
Loading