feat: delta/instant upload + frontend UI/UX polish

Bundles the backend+frontend delta-upload (content-dedup) feature with a
batch of frontend fixes from this session.

Upload / dedup:
- Client-hashed delta & instant upload (deltaUpload, hashWasm vendor shim)
- Backend dedup batch endpoint (dedup_service, dedup_handler, routes)
- session store owned-hash helpers; unit tests + upload-strategy bench

Frontend UI/UX:
- Colour file-type icons in grid/list (per-type tinted tiles + glyph hue)
- Robust thumbnail fallback; PDFs now show their type icon (backend
  generates no PDF thumbnails) instead of a blank tile
- Fix PDF preview: load via a same-origin blob: iframe — the API URL is
  blocked by the global X-Frame-Options: DENY in the browser's framed
  PDF viewer, matching the existing CSP `frame-src blob:` design
- Groups: localized virtual-group description (no DB schema-note leak),
  add nav.groups to the 15 missing locales, fix primary-button contrast
- Repoint --color-text-light → --color-on-accent (was faint grey on accent)
- Nudge the admin role badge off the user-menu header divider

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
DioCrafts
2026-06-20 16:33:08 +02:00
parent f8490ad96e
commit d98e3117b2
35 changed files with 901 additions and 101 deletions
+120 -4
View File
@@ -1,10 +1,10 @@
use axum::{
body::Body,
extract::{Multipart, Path, State},
extract::{Json, Multipart, Path, State},
http::{Response, StatusCode, header},
response::IntoResponse,
};
use serde::Serialize;
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use crate::common::di::AppState;
@@ -15,6 +15,17 @@ use std::sync::Arc;
/// Global application state for dependency injection
type GlobalState = Arc<AppState>;
/// Upper bound on hashes accepted in one batch ownership check — keeps a single
/// request from pinning the DB with a pathologically large `ANY(...)` array.
/// ~10k covers any realistic folder upload; clients fall back to plain uploads
/// for whatever doesn't fit.
const MAX_BATCH_HASHES: usize = 10_000;
/// A well-formed BLAKE3 hash is 64 hex characters.
fn is_valid_blob_hash(hash: &str) -> bool {
hash.len() == 64 && hash.chars().all(|c| c.is_ascii_hexdigit())
}
/// Response for hash check endpoint
#[derive(Debug, Serialize, ToSchema)]
pub struct HashCheckResponse {
@@ -32,6 +43,20 @@ pub struct HashCheckResponse {
pub ref_count: Option<u32>,
}
/// Request body for the batch hash-ownership check (`POST /api/dedup/check-batch`).
#[derive(Debug, Deserialize, ToSchema)]
pub struct HashBatchRequest {
/// Candidate BLAKE3 hashes (64 hex chars each) to test for ownership.
pub hashes: Vec<String>,
}
/// Response for the batch hash-ownership check.
#[derive(Debug, Serialize, ToSchema)]
pub struct HashBatchResponse {
/// The subset of the submitted `hashes` the authenticated user already owns.
pub owned: Vec<String>,
}
/// Response for upload with dedup endpoint
#[derive(Debug, Serialize, ToSchema)]
pub struct DedupUploadResponse {
@@ -98,7 +123,7 @@ impl DedupHandler {
let dedup = &state.core.dedup_service;
// Validate hash format (BLAKE3 = 64 hex chars)
if hash.len() != 64 || !hash.chars().all(|c| c.is_ascii_hexdigit()) {
if !is_valid_blob_hash(&hash) {
return Response::builder()
.status(StatusCode::BAD_REQUEST)
.header(header::CONTENT_TYPE, "application/json")
@@ -152,6 +177,52 @@ impl DedupHandler {
}
}
/// Batch variant of [`Self::check_hash_impl`]: return the subset of the
/// submitted hashes the authenticated user already owns, in one round trip.
/// Lets a client hash a whole upload set up front and learn which files it
/// can skip with ONE request instead of one probe per file.
///
/// User-scoped (anti-enumeration): only the caller's own blobs are echoed
/// back — never reveals whether other users hold a blob. Malformed hashes
/// are silently dropped (they can't be owned anyway).
///
/// POST /api/dedup/check-batch
pub(super) async fn check_hashes_batch_impl(
State(state): State<GlobalState>,
auth_user: AuthUser,
Json(request): Json<HashBatchRequest>,
) -> impl IntoResponse {
if request.hashes.len() > MAX_BATCH_HASHES {
return Response::builder()
.status(StatusCode::BAD_REQUEST)
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(r#"{"error": "Too many hashes in one batch"}"#))
.unwrap()
.into_response();
}
let valid: Vec<String> = request
.hashes
.into_iter()
.filter(|h| is_valid_blob_hash(h))
.collect();
let owned = state
.core
.dedup_service
.user_owned_blob_references(&valid, &auth_user.id.to_string())
.await;
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(
serde_json::to_string(&HashBatchResponse { owned }).unwrap(),
))
.unwrap()
.into_response()
}
/// Upload content with automatic deduplication (streaming).
///
/// Streams the multipart field straight into the CDC chunk store —
@@ -313,7 +384,7 @@ impl DedupHandler {
let dedup = &state.core.dedup_service;
// Validate hash format
if hash.len() != 64 || !hash.chars().all(|c| c.is_ascii_hexdigit()) {
if !is_valid_blob_hash(&hash) {
return Response::builder()
.status(StatusCode::BAD_REQUEST)
.header(header::CONTENT_TYPE, "application/json")
@@ -472,6 +543,25 @@ pub async fn check_hash(
DedupHandler::check_hash_impl(state, auth_user, path).await
}
#[utoipa::path(
post,
path = "/api/dedup/check-batch",
request_body = HashBatchRequest,
responses(
(status = 200, description = "The subset of the submitted hashes the caller already owns", body = HashBatchResponse),
(status = 400, description = "Too many hashes in one batch"),
),
tag = "dedup",
security(("bearerAuth" = []))
)]
pub async fn check_hashes_batch(
state: State<GlobalState>,
auth_user: AuthUser,
body: Json<HashBatchRequest>,
) -> impl IntoResponse {
DedupHandler::check_hashes_batch_impl(state, auth_user, body).await
}
#[utoipa::path(
post,
path = "/api/dedup/upload",
@@ -751,4 +841,30 @@ mod tests {
assert_eq!(parsed["bytes_saved"], 2048);
assert_eq!(parsed["ref_count"], 3);
}
#[test]
fn valid_blob_hash_accepts_64_hex_only() {
assert!(is_valid_blob_hash(&"abcdef0123456789".repeat(4))); // 64 hex chars
assert!(!is_valid_blob_hash(&"a".repeat(63))); // too short
assert!(!is_valid_blob_hash(&"a".repeat(65))); // too long
assert!(!is_valid_blob_hash(&"g".repeat(64))); // non-hex
assert!(!is_valid_blob_hash("")); // empty
}
#[test]
fn hash_batch_request_deserializes() {
let req: HashBatchRequest = serde_json::from_str(r#"{"hashes":["aa","bb"]}"#).unwrap();
assert_eq!(req.hashes, vec!["aa".to_string(), "bb".to_string()]);
}
#[test]
fn hash_batch_response_serializes_owned_subset() {
let r = HashBatchResponse {
owned: vec!["a".repeat(64), "b".repeat(64)],
};
let parsed: serde_json::Value =
serde_json::from_str(&serde_json::to_string(&r).unwrap()).unwrap();
assert_eq!(parsed["owned"].as_array().unwrap().len(), 2);
assert_eq!(parsed["owned"][0], "a".repeat(64));
}
}
+2 -1
View File
@@ -391,10 +391,11 @@ pub fn create_api_routes(app_state: &Arc<AppState>) -> Router<Arc<AppState>> {
// All handlers are free functions — see dedup_handler.rs for why
// #[utoipa::path] cannot be applied to DedupHandler impl methods directly.
use super::handlers::dedup_handler::{
check_hash, get_blob, get_stats, recalculate_stats, upload_with_dedup,
check_hash, check_hashes_batch, get_blob, get_stats, recalculate_stats, upload_with_dedup,
};
let dedup_router = Router::new()
.route("/check/{hash}", get(check_hash))
.route("/check-batch", post(check_hashes_batch))
.route("/upload", post(upload_with_dedup))
.route("/stats", get(get_stats))
.route("/blob/{hash}", get(get_blob))