feat(frontend): use a content token for the open-original link

Mint a content token on file load (POST /files/:id/content-token) and put it in
the original-content URL instead of the access token, so opening an original —
especially a long video — in a new tab keeps working past the 15-minute access
token expiry. Falls back to the access token until the content token arrives,
and re-mints when paging to another file.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-15 17:53:20 +03:00
parent 98de298e5b
commit 35b1b2e6d5
@@ -24,6 +24,10 @@
let file = $state<File | null>(null); let file = $state<File | null>(null);
let fileTags = $state<Tag[]>([]); let fileTags = $state<Tag[]>([]);
let previewSrc = $state<string | null>(null); let previewSrc = $state<string | null>(null);
// Capability token for the original-content URL, minted per file (see
// fetchContentToken). Outlives the 15-minute access token so a long video
// opened in a new tab keeps streaming.
let contentToken = $state<string | null>(null);
let loading = $state(true); let loading = $state(true);
let saving = $state(false); let saving = $state(false);
let error = $state(''); let error = $state('');
@@ -68,6 +72,8 @@
error = ''; error = '';
// Drop the previous file's tags; they reload lazily when scrolled to. // Drop the previous file's tags; they reload lazily when scrolled to.
fileTags = []; fileTags = [];
// Invalidate the previous file's content token before re-minting.
contentToken = null;
try { try {
const fileData = await api.get<File>(`/files/${id}`); const fileData = await api.get<File>(`/files/${id}`);
if (fileId !== id) return; // paged on; ignore if (fileId !== id) return; // paged on; ignore
@@ -79,6 +85,7 @@
isPublic = fileData.is_public ?? false; isPublic = fileData.is_public ?? false;
dirty = false; dirty = false;
void fetchPreview(id); void fetchPreview(id);
void fetchContentToken(id);
// Log the view (activity.file_views). Fire-and-forget — never block or // Log the view (activity.file_views). Fire-and-forget — never block or
// fail the viewer over view tracking. // fail the viewer over view tracking.
void api.post(`/files/${id}/views`).catch(() => {}); void api.post(`/files/${id}/views`).catch(() => {});
@@ -103,13 +110,28 @@
} }
} }
// Mint a content token for this file so the "open original" link survives the
// 15-minute access-token expiry — a long video opened in a new tab keeps
// streaming, since the token is file-scoped and outlives session rotation.
// Fire-and-forget; the link falls back to the access token until it arrives.
async function fetchContentToken(id: string) {
try {
const res = await api.post<{ token: string; expires_in: number }>(
`/files/${id}/content-token`
);
if (fileId === id) contentToken = res.token;
} catch {
// non-critical — originalUrl falls back to the access token below
}
}
// Direct link to the full-resolution original, opened in a new tab. A // Direct link to the full-resolution original, opened in a new tab. A
// navigation can't send the auth header, so the token rides in the query — // navigation can't send the auth header, so the token rides in the query —
// the server accepts ?access_token= for GET media. Reactive on the token so a // the server accepts ?access_token= for GET media. Prefer the long-lived
// silent refresh keeps the link valid. // content token; fall back to the access token until it's minted.
let originalUrl = $derived( let originalUrl = $derived(
fileId fileId
? `/api/v1/files/${fileId}/content?inline=1&access_token=${encodeURIComponent($authStore.accessToken ?? '')}` ? `/api/v1/files/${fileId}/content?inline=1&access_token=${encodeURIComponent(contentToken ?? $authStore.accessToken ?? '')}`
: '#' : '#'
); );