Merge upstream/main into feat/external-file-mounts
Resolve conflicts between the external-file-mounts feature and upstream's D5/D7 refactor (per-file provenance, keyset pagination, cross-drive move gates, resource-access hook, folder-cascade lifecycle hook). Key resolutions: - FolderService::new now takes (repo, authz, file_lifecycle, mount_router); all callers + DI updated. - FileRetrievalService / FileManagementService keep both the mount_router and the new resource_access_hook / drive_repo / storage_usage wiring. - list_files_batch_with_perms: adapt the mount branch from offset- to keyset (after_name) pagination, mirroring paginate_mount_entries. - download_file_impl: keep upstream's &HeaderMap + `impl IntoResponse + use<>` signature, retain the mount-download branch. - Mount DTOs: the retired `owner_id` field maps onto created_by/updated_by (the mount owner) — the fields the frontend now uses for owner display. - admin/+page.svelte: keep upstream's user-delete modal + the 'mounts' tab. - Bump memmap2 0.9.10 -> 0.9.11 (RUSTSEC critical advisory fix) and regenerate Cargo.lock against the merged Cargo.toml.
This commit is contained in:
@@ -13,7 +13,7 @@ use utoipa::ToSchema;
|
||||
|
||||
use crate::application::ports::external_mount_ports::MountStat;
|
||||
use crate::application::ports::file_ports::{
|
||||
FileManagementUseCase, FileRetrievalUseCase, FileUploadUseCase,
|
||||
FileManagementUseCase, FileRetrievalUseCase, FileUploadUseCase, RangeContent,
|
||||
};
|
||||
use crate::application::ports::storage_ports::{FileReadPort, StorageUsagePort};
|
||||
use crate::application::ports::thumbnail_ports::ThumbnailPort;
|
||||
@@ -301,7 +301,7 @@ impl FileHandler {
|
||||
{
|
||||
upload_ingest::discard_ingested(dedup, &ingested).await;
|
||||
tracing::warn!(
|
||||
"⛔ UPLOAD REJECTED (quota): user={}, file={}, size={}",
|
||||
"⛔ UPLOAD REJECTED (user quota): user={}, file={}, size={}",
|
||||
auth_user.username,
|
||||
filename,
|
||||
ingested.size
|
||||
@@ -309,6 +309,31 @@ impl FileHandler {
|
||||
return Err(Self::quota_error_response(err));
|
||||
}
|
||||
|
||||
// ── Per-drive quota enforcement (D4) ─────────────────
|
||||
// Sibling to the per-user check above: same read-only
|
||||
// SELECT shape, same discard-then-507 outcome. Skipped
|
||||
// when there's no folder_id (root-level upload — no
|
||||
// drive to charge; folder service refuses these
|
||||
// independently). Unlimited-quota drives (`NULL`)
|
||||
// short-circuit inside the service.
|
||||
if let Some(storage_svc) = state.storage_usage_service.as_ref()
|
||||
&& let Some(fid_str) = folder_id.as_deref()
|
||||
&& let Ok(fid) = uuid::Uuid::parse_str(fid_str)
|
||||
&& let Err(err) = storage_svc
|
||||
.check_drive_quota_by_folder(fid, ingested.size)
|
||||
.await
|
||||
{
|
||||
upload_ingest::discard_ingested(dedup, &ingested).await;
|
||||
tracing::warn!(
|
||||
"⛔ UPLOAD REJECTED (drive quota): user={}, folder={}, file={}, size={}",
|
||||
auth_user.username,
|
||||
fid,
|
||||
filename,
|
||||
ingested.size
|
||||
);
|
||||
return Err(Self::quota_error_response(err));
|
||||
}
|
||||
|
||||
// ── Register the file row against the ingested blob ──
|
||||
let hash = ingested.hash.clone();
|
||||
let size = ingested.size;
|
||||
@@ -370,9 +395,9 @@ impl FileHandler {
|
||||
pub(super) async fn get_thumbnail_impl(
|
||||
State(state): State<GlobalState>,
|
||||
auth_user: AuthUser,
|
||||
headers: HeaderMap,
|
||||
headers: &HeaderMap,
|
||||
Path((id, size)): Path<(String, String)>,
|
||||
) -> impl IntoResponse {
|
||||
) -> impl IntoResponse + use<> {
|
||||
use crate::application::ports::thumbnail_ports::{ThumbnailFormat, ThumbnailSize};
|
||||
|
||||
// check first that user can access this resource
|
||||
@@ -413,7 +438,18 @@ impl FileHandler {
|
||||
// (file_id, size, format) triple. If the browser already has it, return
|
||||
// 304 with zero I/O or DB work. Format is in the ETag so a client that
|
||||
// switched codecs doesn't get a stale 304.
|
||||
let etag = format!("\"thumb-{}-{:?}-{:?}\"", id, thumb_size, format);
|
||||
let etag = {
|
||||
let (s, f) = (thumb_size.as_str(), format.as_str());
|
||||
let mut e = String::with_capacity(9 + id.len() + s.len() + f.len());
|
||||
e.push_str("\"thumb-");
|
||||
e.push_str(&id);
|
||||
e.push('-');
|
||||
e.push_str(s);
|
||||
e.push('-');
|
||||
e.push_str(f);
|
||||
e.push('"');
|
||||
e
|
||||
};
|
||||
if let Some(if_none_match) = headers.get(header::IF_NONE_MATCH)
|
||||
&& let Ok(val) = if_none_match.to_str()
|
||||
&& (val == etag || val == "*")
|
||||
@@ -663,8 +699,8 @@ impl FileHandler {
|
||||
auth_user: AuthUser,
|
||||
Path(id): Path<String>,
|
||||
Query(params): Query<HashMap<String, String>>,
|
||||
headers: HeaderMap,
|
||||
) -> impl IntoResponse {
|
||||
headers: &HeaderMap,
|
||||
) -> impl IntoResponse + use<> {
|
||||
// External mount: download a file living on the provider's backend.
|
||||
// (A mount-root UUID is a folder and is not downloadable — it falls
|
||||
// through and 404s as a non-file.)
|
||||
@@ -676,7 +712,7 @@ impl FileHandler {
|
||||
&id,
|
||||
auth_user.id,
|
||||
¶ms,
|
||||
&headers,
|
||||
headers,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
@@ -719,7 +755,7 @@ impl FileHandler {
|
||||
let etag = format!("\"{}\"", file_dto.etag);
|
||||
|
||||
// ── ETag (304 Not Modified) ──────────────────────────────────
|
||||
if let Some(resp) = not_modified_response(&headers, &etag) {
|
||||
if let Some(resp) = not_modified_response(headers, &etag) {
|
||||
return resp.into_response();
|
||||
}
|
||||
|
||||
@@ -737,11 +773,22 @@ impl FileHandler {
|
||||
let disposition =
|
||||
Self::content_disposition(&file_dto.name, &file_dto.mime_type, ¶ms);
|
||||
|
||||
// `file_dto` was already Read-authorized (and the access
|
||||
// recorded) by `get_file_with_perms` above — every seek in
|
||||
// a media/PDF scrub is a separate Range request, so
|
||||
// re-authorizing + re-notifying per seek doubled that work
|
||||
// for nothing. Use the non-perms range read, matching the
|
||||
// share-landing and WebDAV range paths which authorize once
|
||||
// then stream (benches/ROUND7.md).
|
||||
match retrieval
|
||||
.get_file_range_stream_with_perms(&id, auth_user.id, start, Some(end + 1))
|
||||
.get_file_range_preloaded(&file_dto, start, Some(end + 1))
|
||||
.await
|
||||
{
|
||||
Ok(stream) => {
|
||||
Ok(content) => {
|
||||
let body = match content {
|
||||
RangeContent::Bytes(b) => Body::from(b),
|
||||
RangeContent::Stream(s) => Body::from_stream(Box::into_pin(s)),
|
||||
};
|
||||
return Response::builder()
|
||||
.status(StatusCode::PARTIAL_CONTENT)
|
||||
.header(header::CONTENT_TYPE, &*file_dto.mime_type)
|
||||
@@ -757,7 +804,7 @@ impl FileHandler {
|
||||
header::CACHE_CONTROL,
|
||||
"private, max-age=3600, must-revalidate",
|
||||
)
|
||||
.body(Body::from_stream(Box::into_pin(stream)))
|
||||
.body(body)
|
||||
.unwrap()
|
||||
.into_response();
|
||||
}
|
||||
@@ -790,9 +837,15 @@ impl FileHandler {
|
||||
|
||||
// Use the ownership-scoped optimized download.
|
||||
// Ownership was already verified by get_file_owned above,
|
||||
// so we can safely use the preloaded variant.
|
||||
// so we can safely use the preloaded variant. Capture the two
|
||||
// fields the stream arm needs (one Arc bump + a u64 copy) and MOVE
|
||||
// the DTO in — the old `file_dto.clone()` deep-copied all 7 owned
|
||||
// Strings on every download, purely to read mime/size afterwards
|
||||
// (benches/ROUND11.md §1).
|
||||
let dto_mime = file_dto.mime_type.clone();
|
||||
let dto_size = file_dto.size;
|
||||
match retrieval
|
||||
.get_file_optimized_preloaded(&id, file_dto.clone(), accept_webp, prefer_original)
|
||||
.get_file_optimized_preloaded(&id, file_dto, accept_webp, prefer_original)
|
||||
.await
|
||||
{
|
||||
Ok((_file, content)) => match content {
|
||||
@@ -802,9 +855,9 @@ impl FileHandler {
|
||||
.into_response(),
|
||||
OptimizedFileContent::Stream(pinned_stream) => Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(header::CONTENT_TYPE, &*file_dto.mime_type)
|
||||
.header(header::CONTENT_TYPE, &*dto_mime)
|
||||
.header(header::CONTENT_DISPOSITION, &disposition)
|
||||
.header(header::CONTENT_LENGTH, file_dto.size)
|
||||
.header(header::CONTENT_LENGTH, dto_size)
|
||||
.header(header::ETAG, &etag)
|
||||
.header(
|
||||
header::CACHE_CONTROL,
|
||||
@@ -955,9 +1008,9 @@ impl FileHandler {
|
||||
pub(super) async fn list_files_query_impl(
|
||||
State(state): State<GlobalState>,
|
||||
auth_user: AuthUser,
|
||||
headers: HeaderMap,
|
||||
headers: &HeaderMap,
|
||||
Query(params): Query<HashMap<String, String>>,
|
||||
) -> impl IntoResponse {
|
||||
) -> impl IntoResponse + use<> {
|
||||
let folder_id = params.get("folder_id").map(|id| id.as_str());
|
||||
tracing::info!("API: Listing files with folder_id: {:?}", folder_id);
|
||||
|
||||
@@ -989,7 +1042,13 @@ impl FileHandler {
|
||||
}
|
||||
|
||||
tracing::info!("Found {} files", files.len());
|
||||
let mut resp = (StatusCode::OK, Json(files)).into_response();
|
||||
// Pre-sized serialization — this listing is unbounded (no
|
||||
// page cap), the axum Json 128-byte seed reallocs ~11 times
|
||||
// on a big folder (benches/ROUND12.md §M1).
|
||||
let mut resp = crate::interfaces::api::sized_json::sized_json(
|
||||
64 + files.len() * crate::interfaces::api::sized_json::EST_ROW_BYTES,
|
||||
&files,
|
||||
);
|
||||
resp.headers_mut()
|
||||
.insert(header::ETAG, header::HeaderValue::from_str(&etag).unwrap());
|
||||
resp
|
||||
@@ -1272,18 +1331,39 @@ pub(super) fn build_content_disposition(name: &str, mime: &str, force_inline: bo
|
||||
.remove(b'`')
|
||||
.remove(b'|')
|
||||
.remove(b'~');
|
||||
let encoded = utf8_percent_encode(name, RFC5987_SET).to_string();
|
||||
// Fast path: a name whose every byte is an RFC 5987 attr-char needs neither
|
||||
// percent-encoding nor ASCII-fallback filtering ('"' and '\\' are not
|
||||
// attr-chars, so none is substituted), so `filename` and `filename*` are the
|
||||
// name verbatim — one allocation (the header) instead of three.
|
||||
let all_attr_char = name.bytes().all(|b| {
|
||||
b.is_ascii_alphanumeric()
|
||||
|| matches!(
|
||||
b,
|
||||
b'!' | b'#' | b'$' | b'&' | b'+' | b'-' | b'.' | b'^' | b'_' | b'`' | b'|' | b'~'
|
||||
)
|
||||
});
|
||||
if all_attr_char {
|
||||
return format!("{disposition}; filename=\"{name}\"; filename*=UTF-8''{name}");
|
||||
}
|
||||
|
||||
let ascii_safe: String = name
|
||||
.chars()
|
||||
.filter(|c| c.is_ascii_graphic() || *c == ' ')
|
||||
.map(|c| match c {
|
||||
// Slow path: assemble the header in one pre-sized buffer, writing the ASCII
|
||||
// fallback and the percent-encoded form in place — no throwaway `ascii_safe`
|
||||
// / `encoded` Strings. Sized for the worst case (every byte → %XX) so it
|
||||
// never grows.
|
||||
let mut out = String::with_capacity(disposition.len() + name.len() * 4 + 32);
|
||||
out.push_str(disposition);
|
||||
out.push_str("; filename=\"");
|
||||
for c in name.chars().filter(|c| c.is_ascii_graphic() || *c == ' ') {
|
||||
out.push(match c {
|
||||
'"' | '\\' => '_',
|
||||
_ => c,
|
||||
})
|
||||
.collect();
|
||||
|
||||
format!("{disposition}; filename=\"{ascii_safe}\"; filename*=UTF-8''{encoded}")
|
||||
});
|
||||
}
|
||||
out.push_str("\"; filename*=UTF-8''");
|
||||
for chunk in utf8_percent_encode(name, RFC5987_SET) {
|
||||
out.push_str(chunk);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
// ── Route handlers (free functions) ──────────────────────────────────────────
|
||||
@@ -1315,10 +1395,14 @@ pub(super) fn build_content_disposition(name: &str, mime: &str, force_inline: bo
|
||||
pub async fn list_files_query(
|
||||
state: State<GlobalState>,
|
||||
auth_user: AuthUser,
|
||||
headers: HeaderMap,
|
||||
query: Query<HashMap<String, String>>,
|
||||
req: axum::extract::Request,
|
||||
) -> impl IntoResponse {
|
||||
FileHandler::list_files_query_impl(state, auth_user, headers, query).await
|
||||
// Read headers by borrow (`req.headers()`) instead of the `HeaderMap`
|
||||
// extractor, which clones the whole request header table (~2 allocs) just to
|
||||
// read one If-None-Match — the ROUND14 §A4 middleware pattern applied to the
|
||||
// hot listing handler (benches/ROUND22.md §H1).
|
||||
FileHandler::list_files_query_impl(state, auth_user, req.headers(), query).await
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
@@ -1397,9 +1481,12 @@ pub async fn download_file(
|
||||
auth_user: AuthUser,
|
||||
path: Path<String>,
|
||||
query: Query<HashMap<String, String>>,
|
||||
headers: HeaderMap,
|
||||
req: axum::extract::Request,
|
||||
) -> impl IntoResponse {
|
||||
FileHandler::download_file_impl(state, auth_user, path, query, headers).await
|
||||
// Borrow the headers (`req.headers()`) instead of the `HeaderMap` extractor's
|
||||
// full clone — every download AND every media Range seek hit this path
|
||||
// (benches/ROUND22.md §H1).
|
||||
FileHandler::download_file_impl(state, auth_user, path, query, req.headers()).await
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
@@ -1421,10 +1508,13 @@ pub async fn download_file(
|
||||
pub async fn get_thumbnail(
|
||||
state: State<GlobalState>,
|
||||
auth_user: AuthUser,
|
||||
headers: HeaderMap,
|
||||
path: Path<(String, String)>,
|
||||
req: axum::extract::Request,
|
||||
) -> impl IntoResponse {
|
||||
FileHandler::get_thumbnail_impl(state, auth_user, headers, path).await
|
||||
// Borrow the headers (`req.headers()`) instead of the `HeaderMap` extractor's
|
||||
// full clone — thumbnails are the highest-frequency GET (one per grid tile),
|
||||
// and this handler reads only Accept + If-None-Match (benches/ROUND22.md §H1).
|
||||
FileHandler::get_thumbnail_impl(state, auth_user, req.headers(), path).await
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
|
||||
Reference in New Issue
Block a user