file-browser: a thumbnail URL says which version of the file it means

`/thumb` answers `immutable` for a year, and the comment above it explained why that was safe: the
cache key holds the file's mtime and size, so a different file is a different URL.

Half of that was true, and it was the half a browser cannot see. The DISK key has always held mtime and
size, so the server regenerated correctly every time — verified against a file renamed into a name that
had been drawn before: the bytes coming back were right. The URL held the path and the box size and
nothing else, so a browser told it could keep the answer for a year did exactly that, and the picture
on screen was whatever had last occupied the name. Upload, rename, replace a photograph: the thumbnail
did not move, and nothing cleared it but somebody who knew to hard-reload.

The listing already carries `modifiedAt` and `size` per entry, so the client had the facts and was not
using them. It now sends `v=<mtime>-<size>`, from one helper shared by the grid, the list and the
viewer's filmstrip — two spellings would be two URLs for one file, which is two renders and two cache
entries for nothing.

Nothing on the server reads `v`. This route stats the file regardless, and a parameter it depended on
would be a second source of truth about which file is meant. It is in the URL to be part of the URL.

No migration and nothing to clear: the stale entries stay in whatever cache holds them, and are never
asked for again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Pastilhas
2026-09-24 16:41:44 +01:00
co-authored by Claude Opus 5
parent 06bed42ace
commit efa5703337
6 changed files with 56 additions and 8 deletions
+10
View File
@@ -433,6 +433,16 @@ router.post('/rename', async (ctx) => {
* *
* The cache key contains the file's mtime and size, so this URL's answer genuinely cannot change: a * The cache key contains the file's mtime and size, so this URL's answer genuinely cannot change: a
* different file is a different URL. That is what makes a year-long cache correct rather than a bet. * different file is a different URL. That is what makes a year-long cache correct rather than a bet.
*
* It was only ever half true, and the half that was missing is the half a browser can see. The DISK key
* has always held mtime and size, so the server regenerated correctly every time. The URL held the path
* and the box size and nothing else — so a browser told to keep the answer for a year kept it for a
* year, and a file replaced or renamed into a name already drawn showed the picture that used to be
* there. Nothing cleared it but somebody who knew to hard-reload.
*
* The client now sends `v=<mtime>-<size>` — see `getThumbUrl`. Nothing here reads it: this route stats
* the file anyway, and a parameter the server depended on would be a second source of truth about which
* file is meant. It is in the URL to be part of the URL, which is the whole job.
*/ */
router.get('/thumb', async (ctx) => { router.get('/thumb', async (ctx) => {
const user = ctx.get('user'); const user = ctx.get('user');
@@ -52,7 +52,7 @@ import { cardStyle } from '@/components/Card';
import type { DirEntry } from '../../../../hooks/useFilesAPI'; import type { DirEntry } from '../../../../hooks/useFilesAPI';
import type { TaskGroup, TaskSummary } from '../../useTasks'; import type { TaskGroup, TaskSummary } from '../../useTasks';
import type { AgentGroup, AgentSummary } from '../../useAgents'; import type { AgentGroup, AgentSummary } from '../../useAgents';
import { getFileType } from '../../../FileViewer'; import { getFileType, thumbVersion } from '../../../FileViewer';
import { useTranslation } from '../../../../locales'; import { useTranslation } from '../../../../locales';
import { useFormat } from 'i18n'; import { useFormat } from 'i18n';
@@ -670,7 +670,16 @@ export const FileItem = ({
rounded box would trim content off a photograph for decoration. rounded box would trim content off a photograph for decoration.
*/ */
const withThumb = (kind: 'image' | 'video', node: React.ReactNode, className: string, size: number) => ( const withThumb = (kind: 'image' | 'video', node: React.ReactNode, className: string, size: number) => (
<Thumbnail path={path} root={root} size={size} isVideo={kind === 'video'} className={className}> <Thumbnail
path={path}
root={root}
size={size}
// Which version of the file, so replacing or renaming one does not leave the old picture in a
// browser cache that was told it could keep it for a year. See `Thumbnail`.
version={thumbVersion(entry)}
isVideo={kind === 'video'}
className={className}
>
{node} {node}
</Thumbnail> </Thumbnail>
); );
@@ -52,6 +52,7 @@ export const Thumbnail = ({
path, path,
root, root,
size, size,
version,
isVideo, isVideo,
className, className,
children, children,
@@ -59,6 +60,16 @@ export const Thumbnail = ({
/** Root-relative, the same path every other file-browser call uses. */ /** Root-relative, the same path every other file-browser call uses. */
path: string; path: string;
root?: string; root?: string;
/**
* Which VERSION of the file this is — `mtime-size`, from the listing.
*
* The response is `immutable` for a year, and the URL used to carry only the path, so a browser that
* had drawn a thumbnail for a name went on drawing it however the file behind that name changed. A
* rename into a name already seen showed the picture that used to be there, and nothing short of a
* hard reload cleared it. The SERVER was never wrong — its own cache key has always held mtime and
* size — the URL simply did not say which file it meant. See `getThumbUrl`.
*/
version?: string;
/** What to ask the server for. Snapped server-side, so a near-enough number is fine. */ /** What to ask the server for. Snapped server-side, so a near-enough number is fine. */
size: number; size: number;
/** Draws the play badge. The thumbnail of a video is a still, and a still is not obviously a video. */ /** Draws the play badge. The thumbnail of a video is a still, and a still is not obviously a video. */
@@ -79,7 +90,8 @@ export const Thumbnail = ({
const token = getHeaders()['Authorization']?.replace('Bearer ', '') ?? ''; const token = getHeaders()['Authorization']?.replace('Bearer ', '') ?? '';
const rootParam = root && root !== 'home' ? `&root=${encodeURIComponent(root)}` : ''; const rootParam = root && root !== 'home' ? `&root=${encodeURIComponent(root)}` : '';
const src = `${API_URL}/file-browser/thumb?path=${encodeURIComponent(path)}&size=${size}&token=${encodeURIComponent(token)}${rootParam}`; const versionParam = version ? `&v=${encodeURIComponent(version)}` : '';
const src = `${API_URL}/file-browser/thumb?path=${encodeURIComponent(path)}&size=${size}&token=${encodeURIComponent(token)}${rootParam}${versionParam}`;
return ( return (
<span className={`relative inline-flex items-center justify-center overflow-hidden ${className ?? ''}`}> <span className={`relative inline-flex items-center justify-center overflow-hidden ${className ?? ''}`}>
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { ChevronLeft, ChevronRight } from 'lucide-react'; import { ChevronLeft, ChevronRight } from 'lucide-react';
import { useFilesAPI, type DirEntry } from '../../hooks/useFilesAPI'; import { useFilesAPI, type DirEntry } from '../../hooks/useFilesAPI';
import { getFileType, getThumbUrl } from './file-types'; import { getFileType, getThumbUrl, thumbVersion } from './file-types';
import { useFileViewer } from './FileViewerContext'; import { useFileViewer } from './FileViewerContext';
/** /**
@@ -128,7 +128,7 @@ export const FileCarousel = ({ onClose }: { onClose?: () => void }) => {
> >
{entry.thumb ? ( {entry.thumb ? (
<img <img
src={getThumbUrl(path, root)} src={getThumbUrl(path, root, 256, thumbVersion(entry))}
alt="" alt=""
loading="lazy" loading="lazy"
decoding="async" decoding="async"
@@ -165,14 +165,31 @@ export function getRawUrl(filePath: string, root?: string, audioTrack?: number):
* *
* Same route, same cache, same size — so a folder already browsed has a warm carousel strip, and a * Same route, same cache, same size — so a folder already browsed has a warm carousel strip, and a
* format that needs a plugin to decode works here exactly where it works there. * format that needs a plugin to decode works here exactly where it works there.
*
* `version` is what makes the year-long `immutable` on that response honest. The SERVER's cache key has
* always held the file's mtime and size; this URL held only the path, so a browser that had once drawn
* a thumbnail for a name kept drawing it for a year however the file behind that name changed. Upload,
* rename something else into the name, replace a photograph — the picture on screen stayed. Pass
* `mtime-size` and a changed file is simply a different URL. The server ignores the parameter; it stats
* the file regardless.
*/ */
export function getThumbUrl(filePath: string, root?: string, size = 256): string { export function getThumbUrl(filePath: string, root?: string, size = 256, version?: string): string {
const headers = getHeaders(); const headers = getHeaders();
const token = headers['Authorization']?.replace('Bearer ', '') ?? ''; const token = headers['Authorization']?.replace('Bearer ', '') ?? '';
const rootParam = root && root !== 'home' ? `&root=${encodeURIComponent(root)}` : ''; const rootParam = root && root !== 'home' ? `&root=${encodeURIComponent(root)}` : '';
return `${API_URL}/file-browser/thumb?path=${encodeURIComponent(filePath)}&size=${size}&token=${encodeURIComponent(token)}${rootParam}`; const versionParam = version ? `&v=${encodeURIComponent(version)}` : '';
return `${API_URL}/file-browser/thumb?path=${encodeURIComponent(filePath)}&size=${size}&token=${encodeURIComponent(token)}${rootParam}${versionParam}`;
} }
/**
* What identifies this VERSION of a file, for a cache key — the same two facts the server hashes.
*
* One place, because the grid and the carousel strip must agree: two spellings would mean two URLs for
* one file, which is two renders and two cache entries for no reason.
*/
export const thumbVersion = (entry: { modifiedAt?: number; size?: number }): string | undefined =>
Number.isFinite(entry.modifiedAt) && Number.isFinite(entry.size) ? `${entry.modifiedAt}-${entry.size}` : undefined;
export function getAudioTracksUrl(filePath: string, root?: string): string { export function getAudioTracksUrl(filePath: string, root?: string): string {
const headers = getHeaders(); const headers = getHeaders();
const token = headers['Authorization']?.replace('Bearer ', '') ?? ''; const token = headers['Authorization']?.replace('Bearer ', '') ?? '';
@@ -4,7 +4,7 @@ export { FileViewerProvider } from './FileViewerProvider';
export { FileViewerHeader } from './FileViewerHeader'; export { FileViewerHeader } from './FileViewerHeader';
export { FileViewerBody } from './FileViewerBody'; export { FileViewerBody } from './FileViewerBody';
export { useFileViewer } from './FileViewerContext'; export { useFileViewer } from './FileViewerContext';
export { getFileType, getLang, getExt, getArchiveBaseName, ARCHIVE_EXTS, type FileType } from './file-types'; export { getFileType, getLang, getExt, getArchiveBaseName, thumbVersion, ARCHIVE_EXTS, type FileType } from './file-types';
// No `appRegistryMetas` here on purpose. The file viewer is not a panel app you can put on a dashboard: // No `appRegistryMetas` here on purpose. The file viewer is not a panel app you can put on a dashboard:
// it is mounted as an ephemeral panel by `useFileViewerPanels`, which supplies `FileViewerBody`/`Header` // it is mounted as an ephemeral panel by `useFileViewerPanels`, which supplies `FileViewerBody`/`Header`