From ec55e7ec5588c4bccfe7424d31ff5471b64fa915 Mon Sep 17 00:00:00 2001 From: abnvle Date: Sun, 10 May 2026 20:43:59 +0200 Subject: [PATCH] fix(share): tighten public browsing (UUID guard, cache headers, abort race) --- .../repositories/pg/folder_db_repository.rs | 29 ++++++++++---- src/interfaces/api/handlers/share_handler.rs | 38 ++++++++++++++++++- static/js/views/public/publicShare.js | 10 ++++- 3 files changed, 65 insertions(+), 12 deletions(-) diff --git a/src/infrastructure/repositories/pg/folder_db_repository.rs b/src/infrastructure/repositories/pg/folder_db_repository.rs index 3743689a..ca69564b 100644 --- a/src/infrastructure/repositories/pg/folder_db_repository.rs +++ b/src/infrastructure/repositories/pg/folder_db_repository.rs @@ -971,19 +971,26 @@ impl FolderRepository for FolderDbRepository { candidate_folder_id: &str, root_folder_id: &str, ) -> Result { + let (Ok(candidate_uuid), Ok(root_uuid)) = ( + Uuid::parse_str(candidate_folder_id), + Uuid::parse_str(root_folder_id), + ) else { + return Ok(false); + }; + let exists: bool = sqlx::query_scalar( "SELECT EXISTS (\ SELECT 1 \ FROM storage.folders c, storage.folders r \ - WHERE c.id = $1::uuid \ - AND r.id = $2::uuid \ + WHERE c.id = $1 \ + AND r.id = $2 \ AND c.is_trashed = false \ AND r.is_trashed = false \ AND c.lpath <@ r.lpath \ )", ) - .bind(candidate_folder_id) - .bind(root_folder_id) + .bind(candidate_uuid) + .bind(root_uuid) .fetch_one(self.pool()) .await .map_err(|e| { @@ -997,21 +1004,27 @@ impl FolderRepository for FolderDbRepository { file_id: &str, root_folder_id: &str, ) -> Result { + let (Ok(file_uuid), Ok(root_uuid)) = + (Uuid::parse_str(file_id), Uuid::parse_str(root_folder_id)) + else { + return Ok(false); + }; + let exists: bool = sqlx::query_scalar( "SELECT EXISTS (\ SELECT 1 \ FROM storage.files f \ JOIN storage.folders parent ON f.folder_id = parent.id \ - JOIN storage.folders root ON root.id = $2::uuid \ - WHERE f.id = $1::uuid \ + JOIN storage.folders root ON root.id = $2 \ + WHERE f.id = $1 \ AND f.is_trashed = false \ AND parent.is_trashed = false \ AND root.is_trashed = false \ AND parent.lpath <@ root.lpath \ )", ) - .bind(file_id) - .bind(root_folder_id) + .bind(file_uuid) + .bind(root_uuid) .fetch_one(self.pool()) .await .map_err(|e| DomainError::internal_error("FolderDb", format!("is_file_in_subtree: {e}")))?; diff --git a/src/interfaces/api/handlers/share_handler.rs b/src/interfaces/api/handlers/share_handler.rs index 6adca9b4..be709244 100644 --- a/src/interfaces/api/handlers/share_handler.rs +++ b/src/interfaces/api/handlers/share_handler.rs @@ -411,6 +411,11 @@ async fn serve_share_file( return Response::builder() .status(StatusCode::NOT_MODIFIED) .header(header::ETAG, &etag) + .header( + header::CACHE_CONTROL, + "private, max-age=3600, must-revalidate", + ) + .header(header::VARY, "Cookie, Range") .body(Body::empty()) .unwrap() .into_response(); @@ -443,6 +448,11 @@ async fn serve_share_file( ) .header(header::ACCEPT_RANGES, "bytes") .header(header::ETAG, &etag) + .header( + header::CACHE_CONTROL, + "private, max-age=3600, must-revalidate", + ) + .header(header::VARY, "Cookie, Range") .body(Body::from_stream(Box::into_pin(stream))) .unwrap() .into_response(); @@ -457,6 +467,11 @@ async fn serve_share_file( return Response::builder() .status(StatusCode::RANGE_NOT_SATISFIABLE) .header(header::CONTENT_RANGE, format!("bytes */{}", file_dto.size)) + .header( + header::CACHE_CONTROL, + "private, max-age=3600, must-revalidate", + ) + .header(header::VARY, "Cookie, Range") .body(Body::empty()) .unwrap() .into_response(); @@ -473,6 +488,11 @@ async fn serve_share_file( .header(header::CONTENT_LENGTH, data.len()) .header(header::ACCEPT_RANGES, "bytes") .header(header::ETAG, &etag) + .header( + header::CACHE_CONTROL, + "private, max-age=3600, must-revalidate", + ) + .header(header::VARY, "Cookie, Range") .body(Body::from(data)) .unwrap() .into_response(), @@ -483,6 +503,11 @@ async fn serve_share_file( .header(header::CONTENT_LENGTH, mmap_data.len()) .header(header::ACCEPT_RANGES, "bytes") .header(header::ETAG, &etag) + .header( + header::CACHE_CONTROL, + "private, max-age=3600, must-revalidate", + ) + .header(header::VARY, "Cookie, Range") .body(Body::from(mmap_data)) .unwrap() .into_response(), @@ -493,6 +518,11 @@ async fn serve_share_file( .header(header::CONTENT_LENGTH, file_dto.size) .header(header::ACCEPT_RANGES, "bytes") .header(header::ETAG, &etag) + .header( + header::CACHE_CONTROL, + "private, max-age=3600, must-revalidate", + ) + .header(header::VARY, "Cookie, Range") .body(Body::from_stream(stream)) .unwrap() .into_response(), @@ -572,7 +602,8 @@ pub async fn list_share_contents_root( (status = 400, description = "Share is not a folder share"), (status = 401, description = "Password required"), (status = 404, description = "Subfolder not found or not in share scope"), - (status = 410, description = "Share expired") + (status = 410, description = "Share expired"), + (status = 503, description = "Sharing disabled") ), tag = "shares" )] @@ -665,7 +696,8 @@ pub async fn download_share_zip_root( (status = 200, description = "ZIP archive of the subfolder"), (status = 401, description = "Password required"), (status = 404, description = "Subfolder not found or not in share scope"), - (status = 410, description = "Share expired") + (status = 410, description = "Share expired"), + (status = 503, description = "Sharing or ZIP service disabled") ), tag = "shares" )] @@ -744,6 +776,8 @@ async fn serve_share_zip( .header(header::CONTENT_TYPE, "application/zip") .header(header::CONTENT_DISPOSITION, disposition) .header(header::CONTENT_LENGTH, file_size) + .header(header::CACHE_CONTROL, "private, no-store") + .header(header::VARY, "Cookie") .body(body) .unwrap(); diff --git a/static/js/views/public/publicShare.js b/static/js/views/public/publicShare.js index 231d3651..ef23063d 100644 --- a/static/js/views/public/publicShare.js +++ b/static/js/views/public/publicShare.js @@ -200,9 +200,14 @@ import { uiFileTypes } from '../../app/uiFileTypes.js'; return folderId ? `/api/s/${TOKEN_ENC}/zip/${encodeURIComponent(folderId)}` : `/api/s/${TOKEN_ENC}/zip`; } + let currentLoadController = null; function loadAndRender(folderId) { + if (currentLoadController) currentLoadController.abort(); + const controller = new AbortController(); + currentLoadController = controller; + $folder.innerHTML = ''; - fetch(listingUrl(folderId)) + fetch(listingUrl(folderId), { signal: controller.signal }) .then((res) => { if (res.ok) return res.json(); if (res.status === 401) { @@ -216,10 +221,11 @@ import { uiFileTypes } from '../../app/uiFileTypes.js'; throw new Error(`HTTP ${res.status}`); }) .then((listing) => { - if (!listing) return; + if (controller.signal.aborted || !listing) return; renderGallery(listing, folderId); }) .catch((err) => { + if (err.name === 'AbortError') return; $folder.innerHTML = ''; console.error('share gallery load failed:', err); });