diff --git a/AGENTS.md b/AGENTS.md index 41262f1..e96274f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -91,6 +91,7 @@ The 3D renderer (`three-d` + mesh parsing) runs in a **Web Worker** with its own - The generated `Cargo.toml` keeps human content (package, `[workspace.dependencies]`-backed deps, lints, curated `default`/`streaming`/`serde` features) above a `# --- generated ---` marker; codegen rewrites only the namespace features below it. - Jacquard deps are declared once in root `[workspace.dependencies]` and referenced via `workspace = true` in both the app and the generated crate, giving a single jacquard version across the workspace. - ATProto has no float type: dimension fields (e.g. `bbox` x/y/z) are decimal strings parsed to floats in app code. Images use the `#image` (blobref) / `#imageView` (CDN URL) split in `space.polymodel.library.defs`. Polymodel `*View` lexicon types are appview/UI contracts: shape them around fields the UI needs, including required hydrated identity/author/count/viewer fields, instead of treating views as lossy transport objects and adding app-local adapter workarounds. Preserve typed ATProto values (`Handle`, `AtUri`, `Datetime`, URI types, etc.) once constructed; do not `.as_str()`/stringify and re-parse them through convenience constructors just to move them between fixtures, helpers, or components. Borrow typed values only at display/serialization boundaries. Keep record-authored fields on records unless a view/feed UI intentionally needs them directly; external or aggregated hydrated metadata such as display tags belongs on view types. +- Part-file bytes are served via `space.polymodel.library.getPartFile` (`*/*` output, consistent with `com.atproto.sync.getBlob`): the server streams reassembled chunk blobs from the PDS without buffering the full file; digest and total-length verification are client-side (viewer mesh-load + direct download). ## Local projection: reads & writes diff --git a/Cargo.lock b/Cargo.lock index 87ee962..cf00400 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6350,6 +6350,7 @@ dependencies = [ "tracing-subscriber", "tracing-wasm", "ulid", + "urlencoding", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", @@ -6386,6 +6387,8 @@ version = "0.1.0" dependencies = [ "postcard", "serde", + "sha2 0.10.9", + "thiserror 2.0.18", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 2753e9f..6ae5872 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -74,6 +74,7 @@ polymodel-renderer-protocol = { path = "crates/polymodel-renderer-protocol" } serde = { workspace = true } serde_json = "1.0" thiserror = { workspace = true } +urlencoding = "2" tracing = { version = "0.1", default-features = false, features = ["std"] } axum = { version = "0.8", optional = true, features = ["multipart"] } @@ -132,7 +133,7 @@ js-sys = "0.3" tracing-wasm = "0.2" wasm-bindgen = "=0.2.121" wasm-bindgen-futures = "0.4" -web-sys = { version = "0.3", features = ["Window", "Document", "Element", "Event", "EventTarget", "HtmlCanvasElement", "WebGl2RenderingContext", "Performance", "MouseEvent", "WheelEvent", "PointerEvent", "DomRect", "BroadcastChannel", "console", "Response", "File", "FileList", "Blob", "HtmlInputElement", "HtmlDetailsElement", "Worker", "WorkerOptions", "WorkerType", "OffscreenCanvas", "MessageEvent", "ErrorEvent"] } +web-sys = { version = "0.3", features = ["Window", "Document", "Element", "Event", "EventTarget", "HtmlCanvasElement", "WebGl2RenderingContext", "Performance", "MouseEvent", "WheelEvent", "PointerEvent", "DomRect", "BroadcastChannel", "console", "Response", "File", "FileList", "Blob", "HtmlAnchorElement", "HtmlInputElement", "HtmlDetailsElement", "Worker", "WorkerOptions", "WorkerType", "OffscreenCanvas", "MessageEvent", "ErrorEvent"] } [dev-dependencies] anyhow = "1" @@ -148,6 +149,7 @@ jacquard-axum = { workspace = true } keyring = "3" serde_json = "1.0" ulid = "1" +urlencoding = "2" libsqlite3-sys = { version = "0.37", features = ["bundled"] } rustls = { version = "0.23", default-features = false, features = ["aws-lc-rs", "std"] } sha2 = "0.10" diff --git a/crates/polymodel-api/lexicons/space_polymodel_library_getPartFile.json b/crates/polymodel-api/lexicons/space_polymodel_library_getPartFile.json new file mode 100644 index 0000000..90e8051 --- /dev/null +++ b/crates/polymodel-api/lexicons/space_polymodel_library_getPartFile.json @@ -0,0 +1,25 @@ +{ + "lexicon": 1, + "id": "space.polymodel.library.getPartFile", + "defs": { + "main": { + "type": "query", + "description": "Fetch and reassemble a part's chunked geometry file into its original bytes. Returns the reconstructed file with its declared MIME type and a safe filename.", + "parameters": { + "type": "params", + "required": [ + "uri" + ], + "properties": { + "uri": { + "type": "string", + "format": "at-uri" + } + } + }, + "output": { + "encoding": "*/*" + } + } + } +} \ No newline at end of file diff --git a/crates/polymodel-api/src/space_polymodel/library.rs b/crates/polymodel-api/src/space_polymodel/library.rs index cef6fe4..f9dc400 100644 --- a/crates/polymodel-api/src/space_polymodel/library.rs +++ b/crates/polymodel-api/src/space_polymodel/library.rs @@ -10,6 +10,7 @@ pub mod delete_thing; pub mod get_author_things; pub mod get_feed; pub mod get_model; +pub mod get_part_file; pub mod get_thing; pub mod model; pub mod part; diff --git a/crates/polymodel-api/src/space_polymodel/library/get_part_file.rs b/crates/polymodel-api/src/space_polymodel/library/get_part_file.rs new file mode 100644 index 0000000..f429b41 --- /dev/null +++ b/crates/polymodel-api/src/space_polymodel/library/get_part_file.rs @@ -0,0 +1,186 @@ +// @generated by jacquard-lexicon. DO NOT EDIT. +// +// Lexicon: space.polymodel.library.getPartFile +// +// This file was automatically generated from Lexicon schemas. +// Any manual changes will be overwritten on the next regeneration. + +#[allow(unused_imports)] +use alloc::collections::BTreeMap; + +#[allow(unused_imports)] +use core::marker::PhantomData; +use jacquard_common::{BosStr, DefaultStr, FromStaticStr}; +use jacquard_common::deps::bytes::Bytes; +use jacquard_common::deps::smol_str::SmolStr; +use jacquard_common::types::string::AtUri; +use jacquard_common::types::value::Data; +use jacquard_derive::IntoStatic; +use serde::{Serialize, Deserialize}; + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, IntoStatic)] +#[serde(rename_all = "camelCase", bound(deserialize = "S: Deserialize<'de> + BosStr"))] +pub struct GetPartFile { + pub uri: AtUri, +} + + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, IntoStatic)] +#[serde(rename_all = "camelCase")] +pub struct GetPartFileOutput { + pub body: Bytes, +} + +/** Response marker for the `space.polymodel.library.getPartFile` query. + +Implements `jacquard_common::xrpc::XrpcResp`; successful bodies decode as `Self::Output`, which is `GetPartFileOutput` for this endpoint.*/ +pub struct GetPartFileResponse; +impl jacquard_common::xrpc::XrpcResp for GetPartFileResponse { + const NSID: &'static str = "space.polymodel.library.getPartFile"; + const ENCODING: &'static str = "*/*"; + type Output = GetPartFileOutput; + type Err = jacquard_common::xrpc::GenericError; + fn encode_output( + output: &Self::Output, + ) -> Result, jacquard_common::xrpc::EncodeError> + where + Self::Output: Serialize, + { + Ok(output.body.to_vec()) + } + fn decode_output<'de, S>( + body: &'de [u8], + ) -> Result, jacquard_common::error::DecodeError> + where + S: BosStr + Deserialize<'de>, + Self::Output: Deserialize<'de>, + { + Ok(GetPartFileOutput { + body: jacquard_common::deps::bytes::Bytes::copy_from_slice(body), + }) + } +} + +impl jacquard_common::xrpc::XrpcRequest for GetPartFile { + const NSID: &'static str = "space.polymodel.library.getPartFile"; + const METHOD: jacquard_common::xrpc::XrpcMethod = jacquard_common::xrpc::XrpcMethod::Query; + type Response = GetPartFileResponse; +} + +/** Endpoint marker for the `space.polymodel.library.getPartFile` query. + +Path: `/xrpc/space.polymodel.library.getPartFile`. The request payload type is `GetPartFile`; send that request with `jacquard::Client` or use this marker through lower-level `XrpcEndpoint` APIs.*/ +pub struct GetPartFileRequest; +impl jacquard_common::xrpc::XrpcEndpoint for GetPartFileRequest { + const PATH: &'static str = "/xrpc/space.polymodel.library.getPartFile"; + const METHOD: jacquard_common::xrpc::XrpcMethod = jacquard_common::xrpc::XrpcMethod::Query; + type Request = GetPartFile; + type Response = GetPartFileResponse; +} + +pub mod get_part_file_state { + + pub use crate::builder_types::{Set, Unset, IsSet, IsUnset}; + #[allow(unused)] + use ::core::marker::PhantomData; + mod sealed { + pub trait Sealed {} + } + /// State trait tracking which required fields have been set + pub trait State: sealed::Sealed { + type Uri; + } + /// Empty state - all required fields are unset + pub struct Empty(()); + impl sealed::Sealed for Empty {} + impl State for Empty { + type Uri = Unset; + } + ///State transition - sets the `uri` field to Set + pub struct SetUri(PhantomData St>); + impl sealed::Sealed for SetUri {} + impl State for SetUri { + type Uri = Set; + } + /// Marker types for field names + #[allow(non_camel_case_types)] + pub mod members { + ///Marker type for the `uri` field + pub struct uri(()); + } +} + +/// Builder for constructing an instance of this type. +pub struct GetPartFileBuilder { + _state: PhantomData St>, + _fields: (Option>,), + _type: PhantomData S>, +} + +impl GetPartFile { + /// Create a new builder for this type, using the default string type (DefaultStr = SmolStr) if needed + pub fn new() -> GetPartFileBuilder { + GetPartFileBuilder::new() + } +} + +impl GetPartFile { + /// Create a new builder for this type + pub fn builder() -> GetPartFileBuilder { + GetPartFileBuilder::builder() + } +} + +impl GetPartFileBuilder { + /// Create a new builder with all fields unset, using the default string type, if needed + pub fn new() -> Self { + GetPartFileBuilder { + _state: PhantomData, + _fields: (None,), + _type: PhantomData, + } + } +} + +impl GetPartFileBuilder { + /// Create a new builder with all fields unset + pub fn builder() -> Self { + GetPartFileBuilder { + _state: PhantomData, + _fields: (None,), + _type: PhantomData, + } + } +} + +impl GetPartFileBuilder +where + St: get_part_file_state::State, + St::Uri: get_part_file_state::IsUnset, +{ + /// Set the `uri` field (required) + pub fn uri( + mut self, + value: impl Into>, + ) -> GetPartFileBuilder, S> { + self._fields.0 = Option::Some(value.into()); + GetPartFileBuilder { + _state: PhantomData, + _fields: self._fields, + _type: PhantomData, + } + } +} + +impl GetPartFileBuilder +where + St: get_part_file_state::State, + St::Uri: get_part_file_state::IsSet, +{ + /// Build the final struct. + pub fn build(self) -> GetPartFile { + GetPartFile { + uri: self._fields.0.unwrap(), + } + } +} \ No newline at end of file diff --git a/crates/polymodel-renderer-protocol/Cargo.toml b/crates/polymodel-renderer-protocol/Cargo.toml index ed3f235..b43d6c4 100644 --- a/crates/polymodel-renderer-protocol/Cargo.toml +++ b/crates/polymodel-renderer-protocol/Cargo.toml @@ -8,3 +8,5 @@ description = "Leaf protocol crate for the renderer worker: shared types, messag [dependencies] serde = { workspace = true } postcard = { workspace = true, features = ["alloc"] } +sha2 = { version = "0.10", default-features = false } +thiserror = { workspace = true } diff --git a/crates/polymodel-renderer-protocol/src/lib.rs b/crates/polymodel-renderer-protocol/src/lib.rs index 3991f3b..f1925d7 100644 --- a/crates/polymodel-renderer-protocol/src/lib.rs +++ b/crates/polymodel-renderer-protocol/src/lib.rs @@ -10,6 +10,7 @@ //! worker crates, never here. use serde::{Deserialize, Serialize}; +use sha2::Digest; // --------------------------------------------------------------------------- // Shared mesh-format types (relocated from polymodel-mesh::contract) @@ -116,6 +117,41 @@ pub struct MeshStats { pub triangles: u32, } +// --------------------------------------------------------------------------- +// Part-file verification (client-side digest + size check) +// --------------------------------------------------------------------------- + +/// Errors from [`verify_part_file`]. +#[derive(Debug, thiserror::Error)] +pub enum VerifyError { + #[error("part file size mismatch: expected {expected}, got {actual}")] + Size { expected: i64, actual: usize }, + #[error("part file digest mismatch")] + Digest, +} + +/// Verify a reassembled part file's size and (optional) sha256 digest. +/// +/// Called on every consumer path (viewer mesh-load, direct download) after the +/// streamed bytes are collected. Per-chunk CID verifies each piece on the PDS +/// side; this full-file check catches assembly errors (reordering, swaps, +/// drops) that per-chunk CID cannot. +pub fn verify_part_file(bytes: &[u8], size: i64, digest: Option<&[u8]>) -> Result<(), VerifyError> { + if bytes.len() as i64 != size { + return Err(VerifyError::Size { + expected: size, + actual: bytes.len(), + }); + } + if let Some(expected) = digest { + let actual = sha2::Sha256::digest(bytes); + if actual[..] != *expected { + return Err(VerifyError::Digest); + } + } + Ok(()) +} + // --------------------------------------------------------------------------- // Renderer command (main → worker) // --------------------------------------------------------------------------- @@ -134,6 +170,8 @@ pub enum RendererCommand { LoadMesh { format: MeshFormat, primary_url: String, + expected_size: Option, + expected_digest: Option>, }, /// Render a mesh URL to a PNG preview image. This is intentionally handled by /// the worker so the Dioxus app does not depend on mesh parsing or `three-d`. @@ -243,6 +281,42 @@ pub fn deserialize_event(bytes: &[u8]) -> Result mod tests { use super::*; + #[test] + fn verify_part_file_matching_size_and_digest_is_ok() { + let data = b"hello world"; + let digest = sha2::Sha256::digest(data); + assert!(verify_part_file(data, data.len() as i64, Some(&digest)).is_ok()); + } + + #[test] + fn verify_part_file_size_mismatch_is_err() { + let data = b"hello world"; + let digest = sha2::Sha256::digest(data); + let err = verify_part_file(data, 999, Some(&digest)).unwrap_err(); + assert!(matches!( + err, + VerifyError::Size { + expected: 999, + actual: 11 + } + )); + } + + #[test] + fn verify_part_file_digest_mismatch_is_err() { + let data = b"hello world"; + let wrong_digest = [0u8; 32]; + let err = verify_part_file(data, data.len() as i64, Some(&wrong_digest)).unwrap_err(); + assert!(matches!(err, VerifyError::Digest)); + } + + #[test] + fn verify_part_file_no_digest_checks_size_only() { + let data = b"hello world"; + assert!(verify_part_file(data, data.len() as i64, None).is_ok()); + assert!(verify_part_file(data, 999, None).is_err()); + } + #[test] fn from_extension_maps_known_formats() { assert_eq!( @@ -304,18 +378,26 @@ mod tests { RendererCommand::LoadMesh { format: MeshFormat::Stl, primary_url: "/models/cube.stl".into(), + expected_size: Some(4096), + expected_digest: Some(vec![0u8; 32]), }, RendererCommand::LoadMesh { format: MeshFormat::Obj, primary_url: "/models/cube.obj".into(), + expected_size: None, + expected_digest: None, }, RendererCommand::LoadMesh { format: MeshFormat::Gltf, primary_url: "/models/cube.glb".into(), + expected_size: Some(2048), + expected_digest: None, }, RendererCommand::LoadMesh { format: MeshFormat::Threemf, primary_url: "/models/cube.3mf".into(), + expected_size: None, + expected_digest: Some(vec![1u8; 32]), }, RendererCommand::RenderPreviewImage { request_id: 7, diff --git a/crates/polymodel-renderer-worker/src/worker.rs b/crates/polymodel-renderer-worker/src/worker.rs index 07964a5..4615f00 100644 --- a/crates/polymodel-renderer-worker/src/worker.rs +++ b/crates/polymodel-renderer-worker/src/worker.rs @@ -374,7 +374,13 @@ fn consume_input_and_render(st: &mut WorkerState) { // LoadMesh (async) // --------------------------------------------------------------------------- -fn handle_load_mesh(state: &Rc>, format: MeshFormat, primary_url: String) { +fn handle_load_mesh( + state: &Rc>, + format: MeshFormat, + primary_url: String, + expected_size: Option, + expected_digest: Option>, +) { let epoch = { let mut st = state.borrow_mut(); if st.disposed { @@ -386,7 +392,7 @@ fn handle_load_mesh(state: &Rc>, format: MeshFormat, primar let state_clone = state.clone(); spawn_local(async move { - let result = load_mesh_async(&primary_url, format).await; + let result = load_mesh_async(&primary_url, format, expected_size, expected_digest).await; let mut st = match state_clone.try_borrow_mut() { Ok(st) => st, @@ -423,11 +429,27 @@ fn handle_load_mesh(state: &Rc>, format: MeshFormat, primar }); } -async fn load_mesh_async(primary_url: &str, format: MeshFormat) -> Result { +async fn load_mesh_async( + primary_url: &str, + format: MeshFormat, + expected_size: Option, + expected_digest: Option>, +) -> Result { let primary_bytes = fetch_bytes(primary_url) .await .map_err(|e| format!("fetch primary: {e}"))?; + if expected_size.is_some() || expected_digest.is_some() { + let size = expected_size.unwrap_or(primary_bytes.len() as i64); + if let Err(e) = polymodel_renderer_protocol::verify_part_file( + &primary_bytes, + size, + expected_digest.as_deref(), + ) { + return Err(format!("part file verification failed: {e}")); + } + } + let companions = if format == MeshFormat::Gltf { let uris = discover_companion_uris(&primary_bytes); let mut comps: Vec<(String, Vec)> = Vec::new(); @@ -478,7 +500,7 @@ fn handle_render_preview_image( let scope = state.borrow().scope.clone(); spawn_local(async move { let result = async { - let model = load_mesh_async(&primary_url, format).await?; + let model = load_mesh_async(&primary_url, format, None, None).await?; render_preview_png(&model, width.max(1), height.max(1)).await } .await; @@ -613,9 +635,11 @@ fn handle_command(state: &Rc>, cmd: RendererCommand) { RendererCommand::LoadMesh { format, primary_url, + expected_size, + expected_digest, } => { drop(st); - handle_load_mesh(state, format, primary_url); + handle_load_mesh(state, format, primary_url, expected_size, expected_digest); } RendererCommand::RenderPreviewImage { request_id, diff --git a/src/appview/downloads.rs b/src/appview/downloads.rs index 965a97f..d4828c2 100644 --- a/src/appview/downloads.rs +++ b/src/appview/downloads.rs @@ -1,29 +1,63 @@ +//! `space.polymodel.library.getPartFile` — reassemble a part's chunked geometry +//! file and stream it to the client. +//! +//! The handler returns `Result`, not `XrpcResponse<_>`, so +//! it can set `Content-Type` and `Content-Disposition` on the success body. +//! `XrpcResponse` hardcodes `Content-Type: */*` and has no disposition hook. +//! The error axis still goes through `AppError` → `GenericXrpcErrorResponse` +//! like every other handler. +//! +//! **Streaming model.** Each chunk's blob is fetched from the actor's PDS via +//! `com.atproto.sync.getBlob` and piped straight into the response body in +//! offset order — the server never buffers the whole file (or a whole chunk). +//! Chunk 0 is prefetched before the `Response` is returned so a first-fetch +//! PDS failure maps to a clean 500. From chunk 1 onward a fetch failure or +//! truncation surfaces as a broken body (headers are already committed); the +//! client catches it via `file.size` + `file.digest` (see +//! `polymodel_renderer_protocol::verify_part_file`). + use axum::body::Body; -use axum::extract::{Path, State}; +use axum::extract::State; use axum::http::{StatusCode, header}; use axum::response::Response; use futures::TryStreamExt; +use futures::stream::{self, StreamExt}; use jacquard::identity::resolver::IdentityResolver; -use jacquard_common::types::string::{Cid, Did}; +use jacquard_axum::ExtractXrpc; +use jacquard_common::types::string::Cid; use jacquard_common::xrpc::XrpcExt; use polymodel_api::com_atproto::sync::get_blob::GetBlob; use polymodel_api::space_polymodel::library::File; -use sha2::{Digest, Sha256}; +use polymodel_api::space_polymodel::library::get_part_file::GetPartFileRequest; use super::error::{AppResult, db, internal, invalid_request, not_found}; use super::state::AppState; -const MAX_DOWNLOAD_BYTES: i64 = 100 * 1024 * 1024; - -pub(super) async fn download_part( +/// Reassemble and stream a part's chunked geometry file. +/// +/// Accepts an at-URI pointing at a `space.polymodel.library.part` record. The +/// part's `File` manifest is loaded from the local SQLite projection, validated, +/// then each chunk's blob is fetched from the actor's PDS and streamed in offset +/// order. Digest and total-length verification are client-side. +pub(super) async fn get_part_file( State(state): State, - Path((did, rkey)): Path<(String, String)>, + ExtractXrpc(req): ExtractXrpc, ) -> AppResult { - let did: Did = Did::new_owned(did).map_err(|e| invalid_request(format!("invalid DID: {e}")))?; - let rkey = rkey.trim(); - if rkey.is_empty() || rkey.contains('/') { - return Err(invalid_request("invalid part rkey")); + let path = req + .uri + .path() + .ok_or_else(|| invalid_request("missing at-uri path"))?; + if path.collection.as_str() != "space.polymodel.library.part" { + return Err(invalid_request("at-uri is not a part record")); } + let authority = req.uri.authority(); + let rkey = path + .rkey + .ok_or_else(|| invalid_request("missing part rkey"))?; + + // Resolve authority to DID before the DB query — the local projection is + // keyed by DID, so a handle-authority at-uri would miss otherwise. + let did = super::actor::resolve_actor(&state, &authority.convert()).await?; #[derive(sqlx::FromRow)] struct PartDownloadRow { @@ -31,14 +65,16 @@ pub(super) async fn download_part( file_json: String, } - let row = db(sqlx::query_as!( - PartDownloadRow, - r#"SELECT name AS "name!", file_json AS "file_json!" FROM parts WHERE did = ? AND rkey = ?"#, - did.as_ref(), - rkey, - ) - .fetch_optional(&state.pool) - .await)? + let row = db( + sqlx::query_as!( + PartDownloadRow, + r#"SELECT name AS "name!", file_json AS "file_json!" FROM parts WHERE did = ? AND rkey = ?"#, + did.as_ref(), + rkey.as_ref(), + ) + .fetch_optional(&state.pool) + .await, + )? .ok_or_else(not_found)?; let file: File = serde_json::from_str(&row.file_json) @@ -61,51 +97,76 @@ pub(super) async fn download_part( .ok_or_else(|| internal("DID document does not advertise a PDS endpoint"))? .to_owned(); - let mut chunks = file.chunks; - chunks.sort_by_key(|chunk| chunk.offset); - let mut bytes = Vec::with_capacity(file.size as usize); - for chunk in chunks { - let cid: Cid = Cid::new_owned(chunk.blob.blob().r#ref.as_str().as_bytes()) - .map_err(|e| internal(format!("part blob CID is invalid: {e}")))?; - let request = GetBlob { + let ordered_cids: Vec = ordered_chunks(&file) + .iter() + .map(|chunk| { + Cid::new_owned(chunk.blob.blob().r#ref.as_str().as_bytes()) + .map_err(|e| internal(format!("part blob CID is invalid: {e}"))) + }) + .collect::>()?; + + // Prefetch chunk 0 so a first-fetch PDS failure returns a clean 500 before + // the 200 + body is committed. The response body chains this already-open + // stream with lazy fetches for the remaining chunks. + let first_cid = ordered_cids[0].clone(); + let first_response = state + .resolver + .xrpc(pds_endpoint.borrow()) + .download(&GetBlob { did: did.clone(), - cid, - }; - let response = state - .resolver - .xrpc(pds_endpoint.borrow()) - .download(&request) - .await - .map_err(|e| internal(format!("blob fetch from {pds_endpoint} failed: {e}")))?; - if !response.status().is_success() { - return Err(internal(format!( - "blob fetch from {pds_endpoint} failed with HTTP {}", - response.status() - ))); - } - let chunk_start = bytes.len(); - let (_, body) = response.into_parts(); - let mut stream = body.into_inner(); - while let Some(chunk_bytes) = stream - .try_next() - .await - .map_err(|e| internal(format!("blob stream from {pds_endpoint} failed: {e}")))? - { - bytes.extend_from_slice(&chunk_bytes); - } - if bytes.len() - chunk_start != chunk.size as usize { - return Err(internal("blob chunk length did not match manifest")); - } - } - if bytes.len() != file.size as usize { - return Err(internal("downloaded file length did not match manifest")); - } - if let Some(digest) = file.digest.as_ref() { - let actual = Sha256::digest(&bytes); - if actual[..] != *digest.as_ref() { - return Err(internal("downloaded file digest did not match manifest")); - } + cid: first_cid, + }) + .await + .map_err(|e| internal(format!("blob fetch from {pds_endpoint} failed: {e}")))?; + if !first_response.status().is_success() { + return Err(internal(format!( + "blob fetch from {pds_endpoint} failed with HTTP {}", + first_response.status() + ))); } + let (_, first_body) = first_response.into_parts(); + let first_stream = first_body.into_inner(); + + let remaining_cids = ordered_cids[1..].to_vec(); + let state_clone = state.clone(); + let pds_clone = pds_endpoint.clone(); + let did_clone = did.clone(); + + type BoxError = Box; + + let first_stream = first_stream.map_err(|e| -> BoxError { Box::new(e) }); + + let rest_stream = stream::iter(remaining_cids) + .then(move |cid| { + let state = state_clone.clone(); + let pds = pds_clone.clone(); + let did = did_clone.clone(); + async move { + let response = state + .resolver + .xrpc(pds.borrow()) + .download(&GetBlob { + did: did.clone(), + cid, + }) + .await + .map_err(|e| -> BoxError { + Box::new(internal(format!("blob fetch from {pds} failed: {e}"))) + })?; + if !response.status().is_success() { + return Err(Box::new(internal(format!( + "blob fetch from {pds} failed with HTTP {}", + response.status() + ))) as BoxError); + } + let (_, body) = response.into_parts(); + let stream = body.into_inner().map_err(|e| -> BoxError { Box::new(e) }); + Ok::<_, BoxError>(stream) + } + }) + .try_flatten(); + + let body_stream = first_stream.chain(rest_stream); let filename = download_filename(&row.name); let content_type = file.mime_type.to_string(); @@ -116,15 +177,25 @@ pub(super) async fn download_part( header::CONTENT_DISPOSITION, format!("attachment; filename=\"{filename}\""), ) - .body(Body::from(bytes)) + .header(header::CONTENT_LENGTH, file.size) + .body(Body::from_stream(body_stream)) .map_err(|e| internal(format!("download response build failed: {e}"))) } +/// Return a file's chunks sorted by offset. The streaming response yields them +/// in this order so the client reassembles the original file. +fn ordered_chunks(file: &File) -> Vec<&polymodel_api::space_polymodel::library::Chunk> { + let mut chunks: Vec<_> = file.chunks.iter().collect(); + chunks.sort_by_key(|chunk| chunk.offset); + chunks +} + +/// Cheap manifest preflight: offset contiguity, chunk count, per-chunk sanity. +/// Does **not** cap total file size — the PDS bounds each blob, and a total cap +/// breaks the large-model case chunking exists for. fn validate_file_manifest(file: &File) -> AppResult<()> { - if file.size < 0 || file.size > MAX_DOWNLOAD_BYTES { - return Err(invalid_request( - "part file size is outside the download limit", - )); + if file.size < 0 { + return Err(invalid_request("part file size is negative")); } if file.chunks.len() > 4096 { return Err(invalid_request("part file has too many chunks")); @@ -139,11 +210,6 @@ fn validate_file_manifest(file: &File) -> AppResult<()> { expected_offset = expected_offset .checked_add(chunk.size) .ok_or_else(|| invalid_request("part file chunk sizes overflow"))?; - if expected_offset > MAX_DOWNLOAD_BYTES { - return Err(invalid_request( - "part file size is outside the download limit", - )); - } } if expected_offset != file.size { return Err(invalid_request("part file chunks do not match file size")); @@ -168,7 +234,7 @@ fn download_filename(name: &str) -> String { #[cfg(test)] mod tests { - use super::{download_filename, validate_file_manifest}; + use super::{download_filename, ordered_chunks, validate_file_manifest}; use jacquard_common::deps::smol_str::SmolStr; use jacquard_common::types::blob::{Blob, BlobRef, MimeType}; use jacquard_common::types::cid::CidLink; @@ -203,12 +269,31 @@ mod tests { } #[test] - fn file_manifest_validation_requires_contiguous_bounded_chunks() { + fn validate_file_manifest_requires_contiguous_bounded_chunks() { let chunk = Chunk::builder().blob(blob_ref()).offset(0).size(4).build(); assert!(validate_file_manifest(&file(vec![chunk.clone()], 4)).is_ok()); let gapped = Chunk::builder().blob(blob_ref()).offset(2).size(4).build(); assert!(validate_file_manifest(&file(vec![gapped], 6)).is_err()); - assert!(validate_file_manifest(&file(vec![chunk], 101 * 1024 * 1024)).is_err()); + } + + #[test] + fn validate_file_manifest_rejects_too_many_chunks() { + let chunk = Chunk::builder().blob(blob_ref()).offset(0).size(4).build(); + let mut chunks = vec![chunk; 4097]; + for (i, chunk) in chunks.iter_mut().enumerate() { + chunk.offset = i as i64; + } + assert!(validate_file_manifest(&file(chunks, 4097 * 4)).is_err()); + } + + #[test] + fn ordered_chunks_sorts_by_offset() { + let chunk_b = Chunk::builder().blob(blob_ref()).offset(4).size(4).build(); + let chunk_a = Chunk::builder().blob(blob_ref()).offset(0).size(4).build(); + let file = file(vec![chunk_b, chunk_a], 8); + let ordered = ordered_chunks(&file); + assert_eq!(ordered[0].offset, 0); + assert_eq!(ordered[1].offset, 4); } } diff --git a/src/appview/error.rs b/src/appview/error.rs index b86ae61..cd734da 100644 --- a/src/appview/error.rs +++ b/src/appview/error.rs @@ -1,28 +1,36 @@ //! Typed appview error type. //! -//! Handlers return `Result, AppError>`. `AppError` is a small -//! typed enum (not a string bag): each variant maps to a fixed HTTP status and -//! XRPC error code at the [`IntoResponse`] boundary, so the error *code* is a -//! compile-time constant and only the human message is a `String`. Internal -//! details are logged; the response body never carries secrets or raw errors. +//! Handlers return `Result, AppError>` (or `Result` for `*/*` binary endpoints that need custom headers like +//! `Content-Disposition`). `AppError` is a small typed enum (not a string +//! bag): each variant maps to a fixed HTTP status and XRPC error code at the +//! [`IntoResponse`] boundary, so the error *code* is a compile-time constant +//! and only the human message is a `String`. Internal details are logged; the +//! response body never carries secrets or raw errors. `AppError` implements +//! `std::error::Error` so it can be used as a stream error type for streaming +//! response bodies. use axum::http::StatusCode; use axum::response::{IntoResponse, Response}; use jacquard_axum::GenericXrpcErrorResponse; /// Appview handler error. Maps onto the XRPC error response shape. -#[derive(Debug)] +#[derive(Debug, thiserror::Error)] pub(crate) enum AppError { /// 401 `AuthenticationRequired`: strict OAuth extraction failed or an /// authenticated PDS operation reported an auth failure. + #[error("AuthenticationRequired: {0}")] Unauthorized(String), /// 400 `InvalidRequest`: bad parameters (bad at-uri, unknown algorithm, /// unparseable cursor, unresolvable handle). + #[error("InvalidRequest: {0}")] InvalidRequest(String), /// 404 `RecordNotFound`: the requested record does not exist in the index. + #[error("RecordNotFound")] NotFound, /// 500 `InternalServerError`: unexpected failure (database, record decode, /// upstream bsky fetch). The message is safe to expose; details are logged. + #[error("InternalServerError: {0}")] Internal(String), } diff --git a/src/appview/mod.rs b/src/appview/mod.rs index 5423537..0ac0b22 100644 --- a/src/appview/mod.rs +++ b/src/appview/mod.rs @@ -46,9 +46,10 @@ use polymodel_api::space_polymodel::{ }, library::{ delete_thing::DeleteThingRequest, get_author_things::GetAuthorThingsRequest, - get_feed::GetFeedRequest, get_model::GetModelRequest, get_thing::GetThingRequest, - publish_thing::PublishThingRequest, search_things::SearchThingsRequest, - stage_file::StageFileRequest, update_thing::UpdateThingRequest, + get_feed::GetFeedRequest, get_model::GetModelRequest, get_part_file::GetPartFileRequest, + get_thing::GetThingRequest, publish_thing::PublishThingRequest, + search_things::SearchThingsRequest, stage_file::StageFileRequest, + update_thing::UpdateThingRequest, }, }; @@ -120,10 +121,7 @@ pub fn router() -> Router { GetSessionRequest::PATH, axum::routing::get(actor::get_session), ) - .route( - "/app/parts/{did}/{rkey}/download", - axum::routing::get(downloads::download_part), - ) + .merge(GetPartFileRequest::into_router(downloads::get_part_file)) // PM-43 app-internal draft store + image upload. Not federated lexicons, // so these use a plain `/app/*` namespace (cookie-authenticated, // same-origin) rather than `/xrpc/space.polymodel.*`. diff --git a/src/appview/tests.rs b/src/appview/tests.rs index af37bf6..54e482d 100644 --- a/src/appview/tests.rs +++ b/src/appview/tests.rs @@ -1452,3 +1452,98 @@ async fn proxy_pds_routes_require_an_oauth_session() { .unwrap(); assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); } + +#[tokio::test] +async fn get_part_file_missing_uri_is_bad_request() { + let state = state().await; + let app = super::router().with_state(state); + let resp = app + .oneshot( + Request::builder() + .uri("/xrpc/space.polymodel.library.getPartFile") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + let body = axum::body::to_bytes(resp.into_body(), usize::MAX) + .await + .unwrap(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(json["error"], "InvalidRequest"); +} + +#[tokio::test] +async fn get_part_file_malformed_uri_is_bad_request() { + let state = state().await; + let app = super::router().with_state(state); + let resp = app + .oneshot( + Request::builder() + .uri("/xrpc/space.polymodel.library.getPartFile?uri=notauri") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn get_part_file_wrong_collection_is_bad_request() { + let state = state().await; + let app = super::router().with_state(state); + let resp = app + .oneshot( + Request::builder() + .uri("/xrpc/space.polymodel.library.getPartFile?uri=at%3A%2F%2Fdid%3Aplc%3Ax%2Fspace.polymodel.library.thing%2Ft1") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + let body = axum::body::to_bytes(resp.into_body(), usize::MAX) + .await + .unwrap(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(json["error"], "InvalidRequest"); +} + +#[tokio::test] +async fn get_part_file_missing_part_is_not_found() { + let state = state().await; + let app = super::router().with_state(state); + let resp = app + .oneshot( + Request::builder() + .uri("/xrpc/space.polymodel.library.getPartFile?uri=at%3A%2F%2Fdid%3Aplc%3Ax%2Fspace.polymodel.library.part%2Fnone") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::NOT_FOUND); + let body = axum::body::to_bytes(resp.into_body(), usize::MAX) + .await + .unwrap(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(json["error"], "RecordNotFound"); +} + +#[tokio::test] +async fn app_parts_download_route_is_gone() { + let state = state().await; + let app = super::router().with_state(state); + let resp = app + .oneshot( + Request::builder() + .uri("/app/parts/did:plc:x/abc/download") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::NOT_FOUND); +} diff --git a/src/thing_card.rs b/src/thing_card.rs index 6b8ad9a..7f35650 100644 --- a/src/thing_card.rs +++ b/src/thing_card.rs @@ -1,7 +1,7 @@ use dioxus::prelude::*; #[cfg(test)] use jacquard_common::types::{ - string::{AtUri, Datetime, Did, Handle}, + string::{AtUri, Cid, Datetime, Did, Handle}, uri::UriValue, }; use jacquard_common::xrpc::XrpcClient; @@ -36,7 +36,7 @@ pub fn thing_card_fixtures() -> Vec { Some("Ari Chen"), Some("she/her"), ), - cid: "bafyreicardcomplete".into(), + cid: Cid::new_owned(b"bafyreicardcomplete").expect("fixture cid is valid"), cover: Some(vec![library::ImageView { alt: Some("Rendered preview of a parametric electronics enclosure".into()), aspect_ratio: None, @@ -74,7 +74,7 @@ pub fn thing_card_fixtures() -> Vec { }, library::ThingViewBasic { author: actor("did:plc:mira", "mira.tools", None, None), - cid: "bafyreicardsparse".into(), + cid: Cid::new_owned(b"bafyreicardsparse").expect("fixture cid is valid"), cover: None, indexed_at: Datetime::try_from("2026-06-21T12:05:00Z".to_string()) .expect("fixture datetime is valid"), @@ -97,7 +97,7 @@ pub fn thing_card_fixtures() -> Vec { Some("Foundry Lab"), Some("they/them"), ), - cid: "bafyreicardmissingmedia".into(), + cid: Cid::new_owned(b"bafyreicardmissingmedia").expect("fixture cid is valid"), cover: None, indexed_at: Datetime::try_from("2026-06-21T12:10:00Z".to_string()) .expect("fixture datetime is valid"), @@ -419,7 +419,7 @@ mod tests { Some("Maker Name"), Some("he/him"), ), - cid: "bafyreitest".into(), + cid: Cid::new_owned(b"bafyreitest").expect("fixture cid is valid"), cover: Some(vec![library::ImageView { alt: Some("Cover alt".into()), aspect_ratio: None, diff --git a/src/thing_detail.rs b/src/thing_detail.rs index 22d93eb..a28be65 100644 --- a/src/thing_detail.rs +++ b/src/thing_detail.rs @@ -75,11 +75,12 @@ fn part_download_href(uri: &AtUri) -> Option { if path.collection.as_str() != "space.polymodel.library.part" { return None; } - let rkey = path.rkey?; + if path.rkey.is_none() { + return None; + } Some(format!( - "/app/parts/{}/{}/download", - uri.authority().as_str(), - rkey.as_ref() + "/xrpc/space.polymodel.library.getPartFile?uri={}", + urlencoding::encode(uri.as_str()) )) } @@ -94,6 +95,8 @@ fn viewer_source_for_part(part: &PartDetail) -> Option { name, url, format, + expected_size: Some(part.view.file.size), + expected_digest: part.view.file.digest.as_deref().map(|b| b.to_vec()), }) } @@ -233,6 +236,99 @@ fn file_size_label(size: i64) -> String { } } +/// Fetch a part file from the XRPC endpoint, verify its size and digest, then +/// trigger a Blob-URL download. Only creates the download on successful verification. +#[cfg(target_arch = "wasm32")] +async fn verified_download( + url: &str, + name: &str, + expected_size: i64, + expected_digest: Option<&[u8]>, +) -> Result<(), String> { + use polymodel_renderer_protocol::verify_part_file; + use wasm_bindgen::JsCast; + use wasm_bindgen_futures::JsFuture; + + let opts = web_sys::RequestInit::new(); + opts.set_method("GET"); + + let request = web_sys::Request::new_with_str_and_init(url, &opts) + .map_err(|e| format!("request: {:?}", e))?; + + let global = js_sys::global(); + let fetch_fn = + js_sys::Reflect::get(&global, &"fetch".into()).map_err(|e| format!("no fetch: {:?}", e))?; + let fetch_promise = js_sys::Function::from(fetch_fn) + .call1(&global, &request) + .map_err(|e| format!("fetch call: {:?}", e))? + .dyn_into::() + .map_err(|_| "fetch: not a Promise".to_string())?; + + let response = JsFuture::from(fetch_promise) + .await + .map_err(|e| format!("fetch: {:?}", e))? + .dyn_into::() + .map_err(|_| "fetch: not a Response")?; + + if !response.ok() { + let status = response.status(); + let text = JsFuture::from(response.text().map_err(|e| format!("text: {:?}", e))?) + .await + .map_err(|e| format!("text: {:?}", e))? + .as_string() + .unwrap_or_default(); + return Err(format!("part file fetch failed: HTTP {status}: {text}")); + } + + let array_buffer = JsFuture::from( + response + .array_buffer() + .map_err(|e| format!("array_buffer: {:?}", e))?, + ) + .await + .map_err(|e| format!("array_buffer: {:?}", e))?; + + let bytes = js_sys::Uint8Array::new(&array_buffer).to_vec(); + + verify_part_file(&bytes, expected_size, expected_digest) + .map_err(|e| format!("verification failed: {e}"))?; + + let blob = web_sys::Blob::new_with_u8_array_sequence(&js_sys::Array::of1( + &js_sys::Uint8Array::from(&bytes[..]), + )) + .map_err(|e| format!("blob: {:?}", e))?; + + let blob_url = web_sys::Url::create_object_url_with_blob(&blob) + .map_err(|e| format!("object url: {:?}", e))?; + + let document = web_sys::window() + .ok_or("no window")? + .document() + .ok_or("no document")?; + let anchor = document + .create_element("a") + .map_err(|e| format!("create element: {:?}", e))? + .dyn_into::() + .map_err(|_| "created element is not an anchor".to_string())?; + anchor.set_href(&blob_url); + anchor.set_download(name); + anchor.click(); + + web_sys::Url::revoke_object_url(&blob_url).map_err(|e| format!("revoke: {:?}", e))?; + + Ok(()) +} + +#[cfg(not(target_arch = "wasm32"))] +async fn verified_download( + _url: &str, + _name: &str, + _expected_size: i64, + _expected_digest: Option<&[u8]>, +) -> Result<(), String> { + Err("downloads are only available in the browser".to_string()) +} + #[allow(clippy::useless_format)] #[component] pub(crate) fn ThingDetail(repo: String, rkey: String) -> Element { @@ -371,7 +467,18 @@ fn DetailPopulated( let first_download = selected_model .as_ref() .and_then(|selected| selected.parts.first()) - .and_then(|part| part_download_href(&part.view.uri)); + .and_then(|part| { + let href = part_download_href(&part.view.uri)?; + Some(( + href, + part.view.file.size, + part.view.file.digest.as_deref().map(|b| b.to_vec()), + clean_text(Some(part.view.name.as_ref())) + .unwrap_or("Untitled part") + .to_string(), + )) + }) + .clone(); let viewer_source = selected_model .as_ref() .and_then(|selected| selected.parts.first()) @@ -416,8 +523,22 @@ fn DetailPopulated( } div { class: "thing-detail-actions", aria_label: "Thing actions", - if let Some(href) = &first_download { - a { class: "button button-primary", href: "{href}", "Download files" } + if first_download.is_some() { + button { + class: "button button-primary", + onclick: { + let dl = first_download.clone(); + move |_| { + let (url, size, digest, name) = dl.clone().unwrap_or_default(); + spawn(async move { + if let Err(e) = verified_download(&url, &name, size, digest.as_deref()).await { + tracing::error!("part download failed: {e}"); + } + }); + } + }, + "Download files" + } } else { a { class: "button button-primary", href: "#parts-files", "View files" } } @@ -713,6 +834,14 @@ fn PartRow(position: usize, part: PartDetail) -> Element { .filter_map(|setting| clean_text(Some(setting.as_ref())).map(ToString::to_string)) .collect::>(); let download_href = part_download_href(&part.view.uri); + let download_data = download_href.as_ref().map(|href| { + ( + href.clone(), + part.view.file.size, + part.view.file.digest.as_deref().map(|b| b.to_vec()), + name.clone(), + ) + }); rsx! { article { class: "part-row", role: "listitem", @@ -736,8 +865,22 @@ fn PartRow(position: usize, part: PartDetail) -> Element { } } } - if let Some(href) = &download_href { - a { class: "button button-secondary part-download", href: "{href}", "Download part" } + if download_data.is_some() { + button { + class: "button button-secondary part-download", + onclick: { + let dl = download_data.clone(); + move |_| { + let (url, size, digest, name) = dl.clone().unwrap_or_default(); + spawn(async move { + if let Err(e) = verified_download(&url, &name, size, digest.as_deref()).await { + tracing::error!("part download failed: {e}"); + } + }); + } + }, + "Download part" + } } } } diff --git a/src/viewer.rs b/src/viewer.rs index 75c1f04..22526cb 100644 --- a/src/viewer.rs +++ b/src/viewer.rs @@ -53,6 +53,8 @@ pub struct ViewerMeshSource { pub name: String, pub url: String, pub format: ViewerSourceFormat, + pub expected_size: Option, + pub expected_digest: Option>, } #[derive(Clone, Debug, PartialEq)] @@ -60,6 +62,8 @@ struct RenderSource { key: String, format: MeshFormat, primary_url: String, + expected_size: Option, + expected_digest: Option>, } #[derive(Clone, Debug, PartialEq)] @@ -416,6 +420,8 @@ pub fn AssetViewer( key: source.key, format, primary_url: source.url, + expected_size: source.expected_size, + expected_digest: source.expected_digest, })); } ViewerSourceFormat::Step => { @@ -620,6 +626,8 @@ fn start_step_conversion( ), format: MeshFormat::Gltf, primary_url: url, + expected_size: None, + expected_digest: None, })); step_report.set(StepConversionReport { status: StepConversionStatus::Converted, @@ -970,6 +978,8 @@ fn WorkerSessionInner( key: load.model.id.to_string(), format: load.format, primary_url: load.model.asset_path.to_string(), + expected_size: None, + expected_digest: None, }) }) .flatten() @@ -980,6 +990,8 @@ fn WorkerSessionInner( bt.send_command(&RendererCommand::LoadMesh { format: source.format, primary_url: source.primary_url, + expected_size: source.expected_size, + expected_digest: source.expected_digest, }); bt.last_loaded_key = Some(source.key); } @@ -1715,6 +1727,8 @@ mod tests { bridge.send_command(RendererCommand::LoadMesh { format: MeshFormat::Stl, primary_url: "/models/cube.stl".into(), + expected_size: None, + expected_digest: None, }); assert_eq!( bridge.command_count(), @@ -1780,10 +1794,14 @@ mod tests { bridge.send_command(RendererCommand::LoadMesh { format: MeshFormat::Stl, primary_url: "/models/a.stl".into(), + expected_size: None, + expected_digest: None, }); bridge.send_command(RendererCommand::LoadMesh { format: MeshFormat::Stl, primary_url: "/models/b.stl".into(), + expected_size: None, + expected_digest: None, }); let cmds = bridge.commands();