fix(thumbnails): key the ETag on content hash, not file id

The thumbnail ETag was "thumb-{file_id}-{size}-{format}", sent with
Cache-Control: public, max-age=31536000, immutable. Replacing a file's
content preserves its id — file_upload_service rebuilds the entity with
parts.id and a new hash, then fires on_file_updated, which deletes and
regenerates the thumbnails — so the server produced a new thumbnail while
still advertising the old ETag. Because `immutable` tells a conforming
browser not to revalidate at all inside the freshness window, clients kept
rendering the previous image for up to a year, unfixably.

Keyed on the content hash the directive becomes honest: a thumbnail is a
pure function of (source bytes, size, format), so that triple identifies
the response. New content yields a new ETag.

The same change fixes the opposite direction. A copy, or any dedup twin,
had a different id and therefore a different ETag, so clients refetched
bytes they already held even though both are served from the same derived
blob. Now identical content agrees on an ETag and revalidates to 304
across files, users and copies.

Both thumbnail endpoints were affected: the REST handler and the
NextCloud preview handler.

Cost is one PK lookup ahead of the 304 decision, where the id-keyed
version needed none — paid for by no longer serving stale images. It is
partly recovered: both handlers already resolved the same hash further
down for the render path, and that second lookup is now gone, so the
cache-miss path is unchanged and only the 304 path pays. The resolved
hash is also handed to get_cached_thumbnail instead of None, saving the
service its own lookup.

No new disclosure: content_hash is already on FileDto and returned by
GET /api/files/{id}.

Tests: thumbnail_etag_content_keyed.hurl covers invalidation — overwrite
in place via WebDAV PUT, assert the ETag changed, assert a client holding
the stale one gets 200 rather than 304. derived_blob_copy.hurl gains the
sharing direction: a copy answers with the SAME ETag and revalidates to
304, which is the one externally observable consequence of content-keying
and was not previously testable.
This commit is contained in:
Edouard Vanbelle
2026-08-24 23:47:57 +02:00
parent 46dc25a9a8
commit a3a93b90ec
5 changed files with 267 additions and 59 deletions
+32 -25
View File
@@ -137,19 +137,40 @@ pub async fn handle_preview(
}
};
// Conditional revalidation — the ETag is derived from (object id, size)
// only, so it is computable right here, BEFORE the blob-hash query and
// the thumbnail cache/disk read. NC clients revalidate gallery previews
// constantly; the REST thumbnail endpoint has honoured `If-None-Match`
// since PHOTOS-ETAG — this endpoint set an immutable ETag but never
// compared it, so every revalidation re-ran the whole pipeline and
// re-shipped the body (ROUND10). Authz already passed above; a 304
// must never skip the Read check.
// Conditional revalidation. NC clients revalidate gallery previews
// constantly; this endpoint set an immutable ETag but never compared it,
// so every revalidation re-ran the whole pipeline and re-shipped the body
// (ROUND10). Authz already passed above; a 304 must never skip the Read
// check.
//
// Keyed on the CONTENT hash, matching the REST thumbnail endpoint. A
// thumbnail is a pure function of (source bytes, size), so that pair
// identifies the response and `immutable` below is honest. Keying on the
// object id meant replacing a file's content — which preserves the id —
// left every client showing the old preview for up to a year, since
// `immutable` suppresses revalidation entirely.
//
// This moves the blob-hash query ahead of the 304 rather than adding one:
// the same lookup used to sit just below, on the path that renders.
let blob_hash = match state
.repositories
.file_read_repository
.get_blob_hash(&object_id)
.await
{
Ok(hash) => hash,
Err(_) => {
return Response::builder()
.status(StatusCode::NOT_FOUND)
.body(Body::from("File blob not found"))
.unwrap();
}
};
let etag = {
let s = thumb_size.as_str();
let mut e = String::with_capacity(9 + object_id.len() + s.len());
let mut e = String::with_capacity(9 + blob_hash.len() + s.len());
e.push_str("\"thumb-");
e.push_str(&object_id);
e.push_str(&blob_hash);
e.push('-');
e.push_str(s);
e.push('"');
@@ -179,21 +200,7 @@ pub async fn handle_preview(
.unwrap();
}
// Resolve the blob hash (content-addressable storage)
let blob_hash = match state
.repositories
.file_read_repository
.get_blob_hash(&object_id)
.await
{
Ok(hash) => hash,
Err(_) => {
return Response::builder()
.status(StatusCode::NOT_FOUND)
.body(Body::from("File blob not found"))
.unwrap();
}
};
// `blob_hash` was resolved above to build the ETag.
if let Some(data) = state
.core
.thumbnail_service