@@ -291,18 +291,29 @@ impl FileHandler {
291291 // THUMBNAILS
292292 // ═══════════════════════════════════════════════════════════════════════
293293
294- /// Get a thumbnail for an image file .
294+ /// Get a thumbnail for a file ( image or video) .
295295 ///
296- /// Thumbnail orchestration (path resolution, generation, caching) stays here
297- /// because it is tightly coupled to HTTP response headers.
296+ /// **Cache-first**: if the thumbnail already exists in the moka in-memory
297+ /// cache or on disk, serve it immediately — **zero DB queries**. The
298+ /// ownership check was already performed when the thumbnail was first
299+ /// generated (at upload) or uploaded (PUT by the owner). UUIDv4 file IDs
300+ /// have 122 bits of entropy, making enumeration infeasible.
301+ ///
302+ /// **ETag / 304**: responses carry an immutable ETag. If the browser
303+ /// sends `If-None-Match` matching the ETag, we return 304 Not Modified
304+ /// without touching cache or DB — pure header round-trip.
305+ ///
306+ /// The DB path is only taken on a **cache miss for images** where the
307+ /// thumbnail hasn't been generated yet (first access after upload if
308+ /// background generation hasn't finished).
298309 pub async fn get_thumbnail (
299310 State ( state) : State < GlobalState > ,
300311 auth_user : AuthUser ,
312+ headers : HeaderMap ,
301313 Path ( ( id, size) ) : Path < ( String , String ) > ,
302314 ) -> impl IntoResponse {
303315 use crate :: application:: ports:: thumbnail_ports:: ThumbnailSize ;
304316
305- let file_retrieval_service = & state. applications . file_retrieval_service ;
306317 let thumbnail_service = & state. core . thumbnail_service ;
307318
308319 let thumb_size = match size. as_str ( ) {
@@ -320,6 +331,46 @@ impl FileHandler {
320331 }
321332 } ;
322333
334+ // ── ETag short-circuit (Solution C) ──────────────────────────
335+ // Thumbnails are immutable — the ETag never changes for a given
336+ // (file_id, size) pair. If the browser already has it, return 304
337+ // with zero I/O or DB work.
338+ let etag = format ! ( "\" thumb-{}-{:?}\" " , id, thumb_size) ;
339+ if let Some ( if_none_match) = headers. get ( header:: IF_NONE_MATCH ) {
340+ if let Ok ( val) = if_none_match. to_str ( ) {
341+ if val == etag || val == "*" {
342+ return Response :: builder ( )
343+ . status ( StatusCode :: NOT_MODIFIED )
344+ . header ( header:: ETAG , & etag)
345+ . header ( header:: CACHE_CONTROL , "public, max-age=31536000, immutable" )
346+ . body ( Body :: empty ( ) )
347+ . unwrap ( )
348+ . into_response ( ) ;
349+ }
350+ }
351+ }
352+
353+ // ── Cache-first path (Solution A) ────────────────────────────
354+ // Try moka (RAM) → disk before touching the database.
355+ // If the thumbnail exists it was authorized at creation time.
356+ if let Some ( data) = thumbnail_service
357+ . get_cached_thumbnail ( & id, thumb_size. into ( ) )
358+ . await
359+ {
360+ return Response :: builder ( )
361+ . status ( StatusCode :: OK )
362+ . header ( header:: CONTENT_TYPE , "image/webp" )
363+ . header ( header:: CONTENT_LENGTH , data. len ( ) )
364+ . header ( header:: CACHE_CONTROL , "public, max-age=31536000, immutable" )
365+ . header ( header:: ETAG , & etag)
366+ . body ( Body :: from ( data) )
367+ . unwrap ( )
368+ . into_response ( ) ;
369+ }
370+
371+ // ── Cache miss — need DB for ownership + blob resolution ─────
372+ let file_retrieval_service = & state. applications . file_retrieval_service ;
373+
323374 let file = match file_retrieval_service
324375 . get_file_owned ( & id, auth_user. id )
325376 . await
@@ -330,25 +381,8 @@ impl FileHandler {
330381 }
331382 } ;
332383
384+ // Non-image (video, etc.) with no cached thumbnail → 204
333385 if !thumbnail_service. is_supported_image ( & file. mime_type ) {
334- // For non-images (videos, etc.), serve from cache if available;
335- // otherwise return 204 to signal "not yet generated — please
336- // generate client-side and upload via PUT".
337- if let Some ( data) = thumbnail_service
338- . get_cached_thumbnail ( & id, thumb_size. into ( ) )
339- . await
340- {
341- let etag = format ! ( "\" thumb-{}-{:?}\" " , id, thumb_size) ;
342- return Response :: builder ( )
343- . status ( StatusCode :: OK )
344- . header ( header:: CONTENT_TYPE , "image/webp" )
345- . header ( header:: CONTENT_LENGTH , data. len ( ) )
346- . header ( header:: CACHE_CONTROL , "public, max-age=31536000, immutable" )
347- . header ( header:: ETAG , etag)
348- . body ( Body :: from ( data) )
349- . unwrap ( )
350- . into_response ( ) ;
351- }
352386 return Response :: builder ( )
353387 . status ( StatusCode :: NO_CONTENT )
354388 . header ( header:: CACHE_CONTROL , "no-store" )
@@ -376,13 +410,12 @@ impl FileHandler {
376410 . await
377411 {
378412 Ok ( data) => {
379- let etag = format ! ( "\" thumb-{}-{:?}\" " , id, thumb_size) ;
380413 Response :: builder ( )
381414 . status ( StatusCode :: OK )
382415 . header ( header:: CONTENT_TYPE , "image/webp" )
383416 . header ( header:: CONTENT_LENGTH , data. len ( ) )
384417 . header ( header:: CACHE_CONTROL , "public, max-age=31536000, immutable" )
385- . header ( header:: ETAG , etag)
418+ . header ( header:: ETAG , & etag)
386419 . body ( Body :: from ( data) )
387420 . unwrap ( )
388421 . into_response ( )
0 commit comments