fix(share): tighten public browsing (UUID guard, cache headers, abort race)
This commit is contained in:
@@ -971,19 +971,26 @@ impl FolderRepository for FolderDbRepository {
|
|||||||
candidate_folder_id: &str,
|
candidate_folder_id: &str,
|
||||||
root_folder_id: &str,
|
root_folder_id: &str,
|
||||||
) -> Result<bool, DomainError> {
|
) -> Result<bool, DomainError> {
|
||||||
|
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(
|
let exists: bool = sqlx::query_scalar(
|
||||||
"SELECT EXISTS (\
|
"SELECT EXISTS (\
|
||||||
SELECT 1 \
|
SELECT 1 \
|
||||||
FROM storage.folders c, storage.folders r \
|
FROM storage.folders c, storage.folders r \
|
||||||
WHERE c.id = $1::uuid \
|
WHERE c.id = $1 \
|
||||||
AND r.id = $2::uuid \
|
AND r.id = $2 \
|
||||||
AND c.is_trashed = false \
|
AND c.is_trashed = false \
|
||||||
AND r.is_trashed = false \
|
AND r.is_trashed = false \
|
||||||
AND c.lpath <@ r.lpath \
|
AND c.lpath <@ r.lpath \
|
||||||
)",
|
)",
|
||||||
)
|
)
|
||||||
.bind(candidate_folder_id)
|
.bind(candidate_uuid)
|
||||||
.bind(root_folder_id)
|
.bind(root_uuid)
|
||||||
.fetch_one(self.pool())
|
.fetch_one(self.pool())
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
@@ -997,21 +1004,27 @@ impl FolderRepository for FolderDbRepository {
|
|||||||
file_id: &str,
|
file_id: &str,
|
||||||
root_folder_id: &str,
|
root_folder_id: &str,
|
||||||
) -> Result<bool, DomainError> {
|
) -> Result<bool, DomainError> {
|
||||||
|
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(
|
let exists: bool = sqlx::query_scalar(
|
||||||
"SELECT EXISTS (\
|
"SELECT EXISTS (\
|
||||||
SELECT 1 \
|
SELECT 1 \
|
||||||
FROM storage.files f \
|
FROM storage.files f \
|
||||||
JOIN storage.folders parent ON f.folder_id = parent.id \
|
JOIN storage.folders parent ON f.folder_id = parent.id \
|
||||||
JOIN storage.folders root ON root.id = $2::uuid \
|
JOIN storage.folders root ON root.id = $2 \
|
||||||
WHERE f.id = $1::uuid \
|
WHERE f.id = $1 \
|
||||||
AND f.is_trashed = false \
|
AND f.is_trashed = false \
|
||||||
AND parent.is_trashed = false \
|
AND parent.is_trashed = false \
|
||||||
AND root.is_trashed = false \
|
AND root.is_trashed = false \
|
||||||
AND parent.lpath <@ root.lpath \
|
AND parent.lpath <@ root.lpath \
|
||||||
)",
|
)",
|
||||||
)
|
)
|
||||||
.bind(file_id)
|
.bind(file_uuid)
|
||||||
.bind(root_folder_id)
|
.bind(root_uuid)
|
||||||
.fetch_one(self.pool())
|
.fetch_one(self.pool())
|
||||||
.await
|
.await
|
||||||
.map_err(|e| DomainError::internal_error("FolderDb", format!("is_file_in_subtree: {e}")))?;
|
.map_err(|e| DomainError::internal_error("FolderDb", format!("is_file_in_subtree: {e}")))?;
|
||||||
|
|||||||
@@ -411,6 +411,11 @@ async fn serve_share_file(
|
|||||||
return Response::builder()
|
return Response::builder()
|
||||||
.status(StatusCode::NOT_MODIFIED)
|
.status(StatusCode::NOT_MODIFIED)
|
||||||
.header(header::ETAG, &etag)
|
.header(header::ETAG, &etag)
|
||||||
|
.header(
|
||||||
|
header::CACHE_CONTROL,
|
||||||
|
"private, max-age=3600, must-revalidate",
|
||||||
|
)
|
||||||
|
.header(header::VARY, "Cookie, Range")
|
||||||
.body(Body::empty())
|
.body(Body::empty())
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.into_response();
|
.into_response();
|
||||||
@@ -443,6 +448,11 @@ async fn serve_share_file(
|
|||||||
)
|
)
|
||||||
.header(header::ACCEPT_RANGES, "bytes")
|
.header(header::ACCEPT_RANGES, "bytes")
|
||||||
.header(header::ETAG, &etag)
|
.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)))
|
.body(Body::from_stream(Box::into_pin(stream)))
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.into_response();
|
.into_response();
|
||||||
@@ -457,6 +467,11 @@ async fn serve_share_file(
|
|||||||
return Response::builder()
|
return Response::builder()
|
||||||
.status(StatusCode::RANGE_NOT_SATISFIABLE)
|
.status(StatusCode::RANGE_NOT_SATISFIABLE)
|
||||||
.header(header::CONTENT_RANGE, format!("bytes */{}", file_dto.size))
|
.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())
|
.body(Body::empty())
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.into_response();
|
.into_response();
|
||||||
@@ -473,6 +488,11 @@ async fn serve_share_file(
|
|||||||
.header(header::CONTENT_LENGTH, data.len())
|
.header(header::CONTENT_LENGTH, data.len())
|
||||||
.header(header::ACCEPT_RANGES, "bytes")
|
.header(header::ACCEPT_RANGES, "bytes")
|
||||||
.header(header::ETAG, &etag)
|
.header(header::ETAG, &etag)
|
||||||
|
.header(
|
||||||
|
header::CACHE_CONTROL,
|
||||||
|
"private, max-age=3600, must-revalidate",
|
||||||
|
)
|
||||||
|
.header(header::VARY, "Cookie, Range")
|
||||||
.body(Body::from(data))
|
.body(Body::from(data))
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.into_response(),
|
.into_response(),
|
||||||
@@ -483,6 +503,11 @@ async fn serve_share_file(
|
|||||||
.header(header::CONTENT_LENGTH, mmap_data.len())
|
.header(header::CONTENT_LENGTH, mmap_data.len())
|
||||||
.header(header::ACCEPT_RANGES, "bytes")
|
.header(header::ACCEPT_RANGES, "bytes")
|
||||||
.header(header::ETAG, &etag)
|
.header(header::ETAG, &etag)
|
||||||
|
.header(
|
||||||
|
header::CACHE_CONTROL,
|
||||||
|
"private, max-age=3600, must-revalidate",
|
||||||
|
)
|
||||||
|
.header(header::VARY, "Cookie, Range")
|
||||||
.body(Body::from(mmap_data))
|
.body(Body::from(mmap_data))
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.into_response(),
|
.into_response(),
|
||||||
@@ -493,6 +518,11 @@ async fn serve_share_file(
|
|||||||
.header(header::CONTENT_LENGTH, file_dto.size)
|
.header(header::CONTENT_LENGTH, file_dto.size)
|
||||||
.header(header::ACCEPT_RANGES, "bytes")
|
.header(header::ACCEPT_RANGES, "bytes")
|
||||||
.header(header::ETAG, &etag)
|
.header(header::ETAG, &etag)
|
||||||
|
.header(
|
||||||
|
header::CACHE_CONTROL,
|
||||||
|
"private, max-age=3600, must-revalidate",
|
||||||
|
)
|
||||||
|
.header(header::VARY, "Cookie, Range")
|
||||||
.body(Body::from_stream(stream))
|
.body(Body::from_stream(stream))
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.into_response(),
|
.into_response(),
|
||||||
@@ -572,7 +602,8 @@ pub async fn list_share_contents_root(
|
|||||||
(status = 400, description = "Share is not a folder share"),
|
(status = 400, description = "Share is not a folder share"),
|
||||||
(status = 401, description = "Password required"),
|
(status = 401, description = "Password required"),
|
||||||
(status = 404, description = "Subfolder not found or not in share scope"),
|
(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"
|
tag = "shares"
|
||||||
)]
|
)]
|
||||||
@@ -665,7 +696,8 @@ pub async fn download_share_zip_root(
|
|||||||
(status = 200, description = "ZIP archive of the subfolder"),
|
(status = 200, description = "ZIP archive of the subfolder"),
|
||||||
(status = 401, description = "Password required"),
|
(status = 401, description = "Password required"),
|
||||||
(status = 404, description = "Subfolder not found or not in share scope"),
|
(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"
|
tag = "shares"
|
||||||
)]
|
)]
|
||||||
@@ -744,6 +776,8 @@ async fn serve_share_zip(
|
|||||||
.header(header::CONTENT_TYPE, "application/zip")
|
.header(header::CONTENT_TYPE, "application/zip")
|
||||||
.header(header::CONTENT_DISPOSITION, disposition)
|
.header(header::CONTENT_DISPOSITION, disposition)
|
||||||
.header(header::CONTENT_LENGTH, file_size)
|
.header(header::CONTENT_LENGTH, file_size)
|
||||||
|
.header(header::CACHE_CONTROL, "private, no-store")
|
||||||
|
.header(header::VARY, "Cookie")
|
||||||
.body(body)
|
.body(body)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
|
|||||||
@@ -200,9 +200,14 @@ import { uiFileTypes } from '../../app/uiFileTypes.js';
|
|||||||
return folderId ? `/api/s/${TOKEN_ENC}/zip/${encodeURIComponent(folderId)}` : `/api/s/${TOKEN_ENC}/zip`;
|
return folderId ? `/api/s/${TOKEN_ENC}/zip/${encodeURIComponent(folderId)}` : `/api/s/${TOKEN_ENC}/zip`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let currentLoadController = null;
|
||||||
function loadAndRender(folderId) {
|
function loadAndRender(folderId) {
|
||||||
|
if (currentLoadController) currentLoadController.abort();
|
||||||
|
const controller = new AbortController();
|
||||||
|
currentLoadController = controller;
|
||||||
|
|
||||||
$folder.innerHTML = '<div class="gallery-loading"><div class="spinner"></div></div>';
|
$folder.innerHTML = '<div class="gallery-loading"><div class="spinner"></div></div>';
|
||||||
fetch(listingUrl(folderId))
|
fetch(listingUrl(folderId), { signal: controller.signal })
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
if (res.ok) return res.json();
|
if (res.ok) return res.json();
|
||||||
if (res.status === 401) {
|
if (res.status === 401) {
|
||||||
@@ -216,10 +221,11 @@ import { uiFileTypes } from '../../app/uiFileTypes.js';
|
|||||||
throw new Error(`HTTP ${res.status}`);
|
throw new Error(`HTTP ${res.status}`);
|
||||||
})
|
})
|
||||||
.then((listing) => {
|
.then((listing) => {
|
||||||
if (!listing) return;
|
if (controller.signal.aborted || !listing) return;
|
||||||
renderGallery(listing, folderId);
|
renderGallery(listing, folderId);
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
|
if (err.name === 'AbortError') return;
|
||||||
$folder.innerHTML = '<div class="gallery-error">Failed to load contents. Try again.</div>';
|
$folder.innerHTML = '<div class="gallery-error">Failed to load contents. Try again.</div>';
|
||||||
console.error('share gallery load failed:', err);
|
console.error('share gallery load failed:', err);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user