Instant upload: register already-owned content by hash, zero bytes on the wire
Phase 0 of the delta-sync plan. Re-uploading a file the user already has (another device, a restore, a duplicate) used to transfer every byte just for the server to discard them as a dedup hit. The frontend now computes the file's BLAKE3 locally and, on a hit, registers the file with a single ~150-byte metadata call. Server — POST /api/files/by-hash: - All checks live in the application service per the AuthZ rule: Create permission on the target folder via the authorization engine, hash ownership via the existing user-scoped query (a non-owned hash returns 404 — same shape as "no such blob" — and emits an instant_upload.rejected audit event), quota on the logical size. - On success: one ref_count bump + the existing save_file_with_blob row registration (compensation included); is_new_blob=false so lifecycle hooks skip thumbnail regeneration. ~10 ms warm. - The storage-usage service is now built before the application services and injected, instead of only living on AppState. Client — WASM BLAKE3 + worker: - wasm/oxicloud-hash: the exact same blake3 crate the server uses, compiled with WASM SIMD128 (~660 MB/s measured) so browser hashes match server content addresses bit for bit. Built by scripts/build-wasm.sh; the artifacts (45 KB wasm + 8 KB glue) are vendored like pdf.js — no npm dependencies, no wasm toolchain needed for regular builds. - static/js/workers/hashWorker.js streams the File in 8 MiB slices off the main thread (constant RAM at any file size). - features/files/instantUpload.js orchestrates: threshold (8 MiB — below it the round-trips cost more than the bytes), user-scoped /api/dedup/check, by-hash registration, and silent fallback to the normal byte upload on any miss, race or unsupported environment. Wired into both uploadFiles and uploadFolderEntries. - biome.json vendors exclusion fixed to cover nested directories (previous vendors were .mjs and never matched the *.js include). Verified end-to-end against PostgreSQL 16: node-driven WASM hash equals the server's content_hash for a 20 MB file; by-hash returns 201 in ~10 ms warm with a 151-byte request (vs 20,971,873 bytes for the byte upload); the copy downloads byte-identical and the manifest ref_count goes 1→2; a second user probing the same hash gets exists:false and 404 plus the audit line; duplicate name → 409, malformed hash → 400; worker and wasm are served with correct MIME (application/wasm). https://claude.ai/code/session_01WdNenpnujNR2sc32XVvwfS
This commit is contained in:
@@ -1,14 +1,19 @@
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::application::dtos::file_dto::FileDto;
|
||||
use crate::application::ports::authorization_ports::AuthorizationEngine;
|
||||
use crate::application::ports::file_lifecycle::FileLifecycleHook;
|
||||
use crate::application::ports::file_ports::{FileUploadUseCase, StoredBlob};
|
||||
use crate::application::ports::storage_ports::{FileReadPort, FileWritePort};
|
||||
use crate::application::ports::storage_ports::{FileReadPort, FileWritePort, StorageUsagePort};
|
||||
use crate::application::services::storage_usage_service::StorageUsageService;
|
||||
use crate::common::errors::DomainError;
|
||||
use crate::domain::services::authorization::{Permission, Resource, Subject};
|
||||
use crate::infrastructure::repositories::pg::FileBlobReadRepository;
|
||||
use crate::infrastructure::repositories::pg::FileBlobWriteRepository;
|
||||
use crate::infrastructure::services::dedup_service::DedupService;
|
||||
use crate::infrastructure::services::file_content_cache::FileContentCache;
|
||||
use crate::infrastructure::services::pg_acl_engine::PgAclEngine;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
/// Helper function to extract username from folder path string.
|
||||
@@ -49,6 +54,18 @@ pub struct FileUploadService {
|
||||
content_cache: Option<Arc<FileContentCache>>,
|
||||
/// Single lifecycle dispatcher — fires on_file_created / on_file_updated.
|
||||
file_lifecycle_hook: Option<Arc<dyn FileLifecycleHook>>,
|
||||
/// Dependencies of the instant-upload path
|
||||
/// (`create_file_from_owned_blob_with_perms`); `None` in minimal test
|
||||
/// wiring.
|
||||
instant_upload: Option<InstantUploadDeps>,
|
||||
}
|
||||
|
||||
/// Everything the instant-upload path needs beyond the upload service's own
|
||||
/// ports: permission checks, the dedup index, and quota enforcement.
|
||||
struct InstantUploadDeps {
|
||||
authz: Arc<PgAclEngine>,
|
||||
dedup: Arc<DedupService>,
|
||||
quota: Arc<StorageUsageService>,
|
||||
}
|
||||
|
||||
impl FileUploadService {
|
||||
@@ -60,6 +77,7 @@ impl FileUploadService {
|
||||
storage_usage_service: None,
|
||||
content_cache: None,
|
||||
file_lifecycle_hook: None,
|
||||
instant_upload: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,9 +92,26 @@ impl FileUploadService {
|
||||
storage_usage_service: None,
|
||||
content_cache: None,
|
||||
file_lifecycle_hook: None,
|
||||
instant_upload: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Wires the authorization engine, dedup index and quota service that
|
||||
/// power the instant-upload path.
|
||||
pub fn with_instant_upload(
|
||||
mut self,
|
||||
authz: Arc<PgAclEngine>,
|
||||
dedup: Arc<DedupService>,
|
||||
quota: Arc<StorageUsageService>,
|
||||
) -> Self {
|
||||
self.instant_upload = Some(InstantUploadDeps {
|
||||
authz,
|
||||
dedup,
|
||||
quota,
|
||||
});
|
||||
self
|
||||
}
|
||||
|
||||
/// Configures the content cache for invalidation on file updates.
|
||||
pub fn with_content_cache(mut self, cache: Arc<FileContentCache>) -> Self {
|
||||
self.content_cache = Some(cache);
|
||||
@@ -98,6 +133,111 @@ impl FileUploadService {
|
||||
self
|
||||
}
|
||||
|
||||
// ── Instant upload (zero content bytes) ──────────────────────
|
||||
|
||||
/// Register a new file row pointing at a blob the caller **already
|
||||
/// owns** — the instant-upload path: the client proved it has the
|
||||
/// content by hash, so no bytes travel and no chunk is written. Pure
|
||||
/// metadata: one ref_count bump + one row INSERT.
|
||||
///
|
||||
/// Security model (mirrors `GET /api/dedup/check/{hash}`):
|
||||
/// - The caller must have `Create` permission on the target folder.
|
||||
/// - The hash is only claimable when the caller owns at least one
|
||||
/// non-trashed file referencing it — never a global content oracle.
|
||||
/// A non-owned hash returns `NotFound` (anti-enumeration: same shape
|
||||
/// as "no such blob") and emits an `instant_upload.rejected` audit
|
||||
/// event with the real reason.
|
||||
/// - Quota is enforced on the logical size, exactly like a byte upload.
|
||||
pub async fn create_file_from_owned_blob_with_perms(
|
||||
&self,
|
||||
caller_id: Uuid,
|
||||
name: String,
|
||||
folder_id: String,
|
||||
hash: &str,
|
||||
) -> Result<FileDto, DomainError> {
|
||||
let Some(InstantUploadDeps {
|
||||
authz,
|
||||
dedup,
|
||||
quota,
|
||||
}) = &self.instant_upload
|
||||
else {
|
||||
return Err(DomainError::internal_error(
|
||||
"FileUpload",
|
||||
"instant upload is not wired (authz/dedup/quota missing)",
|
||||
));
|
||||
};
|
||||
|
||||
// ── AuthZ: Create on the target folder ───────────────────
|
||||
let folder_uuid = Uuid::parse_str(&folder_id)
|
||||
.map_err(|_| DomainError::not_found("Folder", folder_id.clone()))?;
|
||||
authz
|
||||
.require(
|
||||
Subject::User(caller_id),
|
||||
Permission::Create,
|
||||
Resource::Folder(folder_uuid),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// ── Ownership: only blobs the caller can already read ────
|
||||
if !dedup
|
||||
.user_owns_blob_reference(hash, &caller_id.to_string())
|
||||
.await
|
||||
{
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "instant_upload.rejected",
|
||||
reason = "hash_not_owned",
|
||||
caller_id = %caller_id,
|
||||
blob_hash = %hash,
|
||||
"👮🏻♂️ Instant upload rejected: caller owns no file referencing the claimed hash",
|
||||
);
|
||||
return Err(DomainError::not_found("Blob", hash));
|
||||
}
|
||||
|
||||
let Some(metadata) = dedup.get_blob_metadata(hash).await else {
|
||||
// Lost a race with the last-reference delete — same shape as
|
||||
// "never existed".
|
||||
return Err(DomainError::not_found("Blob", hash));
|
||||
};
|
||||
|
||||
// ── Quota on the logical size, before taking any reference ──
|
||||
quota.check_storage_quota(caller_id, metadata.size).await?;
|
||||
|
||||
// The manifest knows the original content type; fall back to the
|
||||
// new name's extension when the stored one is generic.
|
||||
let claimed = metadata.content_type.as_deref().unwrap_or("");
|
||||
let content_type =
|
||||
match crate::common::mime_detect::refine_content_type(&[], &name, claimed) {
|
||||
ct if ct.is_empty() => "application/octet-stream".to_string(),
|
||||
ct => ct,
|
||||
};
|
||||
|
||||
// Take the reference the row registration will consume (it releases
|
||||
// it again on any failure). A concurrent GC between the ownership
|
||||
// check and this bump surfaces as NotFound — the client falls back
|
||||
// to a normal byte upload.
|
||||
dedup.add_reference(hash).await?;
|
||||
|
||||
let dto = self
|
||||
.upload_file_streaming(
|
||||
name,
|
||||
Some(folder_id),
|
||||
content_type,
|
||||
StoredBlob {
|
||||
hash: hash.to_string(),
|
||||
size: metadata.size,
|
||||
is_new_blob: false,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
info!(
|
||||
"⚡ INSTANT UPLOAD: {} ({} bytes, 0 transferred, ID: {})",
|
||||
dto.name, metadata.size, dto.id
|
||||
);
|
||||
Ok(dto)
|
||||
}
|
||||
|
||||
// ── private helpers ──────────────────────────────────────────
|
||||
|
||||
/// Optionally update storage usage after a successful upload.
|
||||
|
||||
+20
-5
@@ -439,6 +439,7 @@ impl AppServiceFactory {
|
||||
repos: &RepositoryServices,
|
||||
trash_service: Option<Arc<TrashService>>,
|
||||
authz: &Arc<PgAclEngine>,
|
||||
storage_usage: &Arc<StorageUsageService>,
|
||||
) -> ApplicationServices {
|
||||
// Main services
|
||||
let folder_service = Arc::new(FolderService::new(
|
||||
@@ -452,7 +453,12 @@ impl AppServiceFactory {
|
||||
repos.file_read_repository.clone(),
|
||||
)
|
||||
.with_content_cache(core.file_content_cache.clone())
|
||||
.with_file_lifecycle_hook(core.file_lifecycle.clone()),
|
||||
.with_file_lifecycle_hook(core.file_lifecycle.clone())
|
||||
.with_instant_upload(
|
||||
authz.clone(),
|
||||
core.dedup_service.clone(),
|
||||
storage_usage.clone(),
|
||||
),
|
||||
);
|
||||
|
||||
let file_retrieval_service = Arc::new(FileRetrievalService::new_with_cache(
|
||||
@@ -733,9 +739,19 @@ impl AppServiceFactory {
|
||||
.create_trash_service(&repos, &core, &authorization)
|
||||
.await;
|
||||
|
||||
// 3c. Storage usage / quota service (needed by the instant-upload
|
||||
// path inside the application services, and re-exposed on AppState
|
||||
// for the handler-side quota checks of the byte-upload paths).
|
||||
let storage_usage = self.create_storage_usage_service(&repos, &pool, &maintenance_pool);
|
||||
|
||||
// 4. Application services (with trash + authz already wired)
|
||||
let mut apps =
|
||||
self.create_application_services(&core, &repos, trash_service.clone(), &authorization);
|
||||
let mut apps = self.create_application_services(
|
||||
&core,
|
||||
&repos,
|
||||
trash_service.clone(),
|
||||
&authorization,
|
||||
&storage_usage,
|
||||
);
|
||||
|
||||
// 5. Share service
|
||||
let share_service = self.create_share_service(&repos, &pool, &authorization);
|
||||
@@ -775,8 +791,7 @@ impl AppServiceFactory {
|
||||
recent_service = Some(recent.clone());
|
||||
apps.recent_service = Some(recent);
|
||||
|
||||
storage_usage_service =
|
||||
Some(self.create_storage_usage_service(&repos, &pool, &maintenance_pool));
|
||||
storage_usage_service = Some(storage_usage.clone());
|
||||
|
||||
self.start_tree_etag_flush_job(&maintenance_pool);
|
||||
|
||||
|
||||
@@ -69,6 +69,57 @@ impl FileHandler {
|
||||
}
|
||||
}
|
||||
|
||||
/// Instant upload: create a file from a blob the caller already owns.
|
||||
///
|
||||
/// Zero content bytes travel — the client proved possession of the
|
||||
/// content by hash (it computed BLAKE3 locally and confirmed via
|
||||
/// `GET /api/dedup/check/{hash}`), so the server only bumps the blob's
|
||||
/// reference count and registers the metadata row.
|
||||
///
|
||||
/// All authorization (folder Create permission, hash ownership with
|
||||
/// anti-enumeration, quota) lives in the application service.
|
||||
pub(super) async fn create_file_by_hash_impl(
|
||||
State(state): State<GlobalState>,
|
||||
auth_user: AuthUser,
|
||||
Json(request): Json<CreateFileByHashRequest>,
|
||||
) -> impl IntoResponse {
|
||||
// Hash shape check — same contract as /api/dedup/check/{hash}.
|
||||
if request.hash.len() != 64 || !request.hash.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
return AppError::bad_request(
|
||||
"Invalid hash format. Expected BLAKE3 (64 hex characters)",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
// Basename only — same path-traversal guard as the multipart upload.
|
||||
let filename = request
|
||||
.name
|
||||
.rsplit('/')
|
||||
.next()
|
||||
.unwrap_or(&request.name)
|
||||
.rsplit('\\')
|
||||
.next()
|
||||
.unwrap_or(&request.name)
|
||||
.to_string();
|
||||
if filename.is_empty() {
|
||||
return AppError::bad_request("File name must not be empty").into_response();
|
||||
}
|
||||
|
||||
match state
|
||||
.applications
|
||||
.file_upload_service
|
||||
.create_file_from_owned_blob_with_perms(
|
||||
auth_user.id,
|
||||
filename,
|
||||
request.folder_id,
|
||||
&request.hash,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(file) => Self::created_json_response(&file).into_response(),
|
||||
Err(err) => Self::domain_error_response(err).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Core upload logic shared by [`Self::upload_file`] and
|
||||
/// [`Self::upload_file_with_thumbnails`].
|
||||
///
|
||||
@@ -1039,6 +1090,39 @@ pub async fn upload_file_with_thumbnails(
|
||||
FileHandler::upload_file_with_thumbnails_impl(state, auth_user, multipart).await
|
||||
}
|
||||
|
||||
/// Request body for the instant-upload endpoint.
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
pub struct CreateFileByHashRequest {
|
||||
/// File name to create (path components are stripped).
|
||||
pub name: String,
|
||||
/// Target folder ID (the caller needs Create permission on it).
|
||||
pub folder_id: String,
|
||||
/// BLAKE3 hash (64 hex chars) of content the caller already owns.
|
||||
pub hash: String,
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/files/by-hash",
|
||||
request_body = CreateFileByHashRequest,
|
||||
responses(
|
||||
(status = 201, description = "File created from an already-owned blob — zero bytes transferred", body = FileDto),
|
||||
(status = 400, description = "Invalid hash format or empty name"),
|
||||
(status = 404, description = "No owned blob with this hash (anti-enumeration: same shape as unknown hash)"),
|
||||
(status = 409, description = "A file with this name already exists in the folder"),
|
||||
(status = 507, description = "Storage quota exceeded"),
|
||||
),
|
||||
security(("bearerAuth" = [])),
|
||||
tag = "files"
|
||||
)]
|
||||
pub async fn create_file_by_hash(
|
||||
state: State<GlobalState>,
|
||||
auth_user: AuthUser,
|
||||
request: Json<CreateFileByHashRequest>,
|
||||
) -> impl IntoResponse {
|
||||
FileHandler::create_file_by_hash_impl(state, auth_user, request).await
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/files/{id}",
|
||||
|
||||
@@ -80,6 +80,7 @@ use crate::interfaces::api::handlers::file_handler::MoveFilePayload;
|
||||
// File handlers (free functions — see file_handler.rs for why)
|
||||
handlers::file_handler::list_files_query,
|
||||
handlers::file_handler::upload_file_with_thumbnails,
|
||||
handlers::file_handler::create_file_by_hash,
|
||||
handlers::file_handler::download_file,
|
||||
handlers::file_handler::get_thumbnail,
|
||||
handlers::file_handler::upload_thumbnail,
|
||||
|
||||
@@ -55,8 +55,8 @@ use crate::interfaces::api::handlers::chunked_upload_handler::{
|
||||
cancel_upload, complete_upload, create_upload, get_upload_status, upload_chunk,
|
||||
};
|
||||
use crate::interfaces::api::handlers::file_handler::{
|
||||
delete_file, download_file, get_file_metadata, get_thumbnail, list_files_query,
|
||||
move_file_simple, rename_file, upload_file_with_thumbnails, upload_thumbnail,
|
||||
create_file_by_hash, delete_file, download_file, get_file_metadata, get_thumbnail,
|
||||
list_files_query, move_file_simple, rename_file, upload_file_with_thumbnails, upload_thumbnail,
|
||||
};
|
||||
#[allow(deprecated)]
|
||||
use crate::interfaces::api::handlers::folder_handler::{
|
||||
@@ -229,6 +229,7 @@ pub fn create_api_routes(app_state: &Arc<AppState>) -> Router<Arc<AppState>> {
|
||||
let basic_file_router = Router::new()
|
||||
.route("/", get(list_files_query))
|
||||
.route("/upload", post(upload_file_with_thumbnails))
|
||||
.route("/by-hash", post(create_file_by_hash))
|
||||
.route("/{id}", get(download_file))
|
||||
.route(
|
||||
"/{id}/thumbnail/{size}",
|
||||
|
||||
Reference in New Issue
Block a user