atproto Thingiverse but good
10

Configure Feed

Select the types of activity you want to include in your feed.

PM-73: XRPC part-file download endpoint with streaming reassembly

Orual (Jun 29, 2026, 7:06 PM EDT) df4fdafc 8a297da9

+781 -108
+1
AGENTS.md
··· 91 91 - 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. 92 92 - 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. 93 93 - 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. 94 + - 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). 94 95 95 96 ## Local projection: reads & writes 96 97
+3
Cargo.lock
··· 6350 6350 "tracing-subscriber", 6351 6351 "tracing-wasm", 6352 6352 "ulid", 6353 + "urlencoding", 6353 6354 "wasm-bindgen", 6354 6355 "wasm-bindgen-futures", 6355 6356 "web-sys", ··· 6386 6387 dependencies = [ 6387 6388 "postcard", 6388 6389 "serde", 6390 + "sha2 0.10.9", 6391 + "thiserror 2.0.18", 6389 6392 ] 6390 6393 6391 6394 [[package]]
+3 -1
Cargo.toml
··· 74 74 serde = { workspace = true } 75 75 serde_json = "1.0" 76 76 thiserror = { workspace = true } 77 + urlencoding = "2" 77 78 tracing = { version = "0.1", default-features = false, features = ["std"] } 78 79 79 80 axum = { version = "0.8", optional = true, features = ["multipart"] } ··· 132 133 tracing-wasm = "0.2" 133 134 wasm-bindgen = "=0.2.121" 134 135 wasm-bindgen-futures = "0.4" 135 - 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"] } 136 + 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"] } 136 137 137 138 [dev-dependencies] 138 139 anyhow = "1" ··· 148 149 keyring = "3" 149 150 serde_json = "1.0" 150 151 ulid = "1" 152 + urlencoding = "2" 151 153 libsqlite3-sys = { version = "0.37", features = ["bundled"] } 152 154 rustls = { version = "0.23", default-features = false, features = ["aws-lc-rs", "std"] } 153 155 sha2 = "0.10"
+25
crates/polymodel-api/lexicons/space_polymodel_library_getPartFile.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "space.polymodel.library.getPartFile", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "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.", 8 + "parameters": { 9 + "type": "params", 10 + "required": [ 11 + "uri" 12 + ], 13 + "properties": { 14 + "uri": { 15 + "type": "string", 16 + "format": "at-uri" 17 + } 18 + } 19 + }, 20 + "output": { 21 + "encoding": "*/*" 22 + } 23 + } 24 + } 25 + }
+1
crates/polymodel-api/src/space_polymodel/library.rs
··· 10 10 pub mod get_author_things; 11 11 pub mod get_feed; 12 12 pub mod get_model; 13 + pub mod get_part_file; 13 14 pub mod get_thing; 14 15 pub mod model; 15 16 pub mod part;
+186
crates/polymodel-api/src/space_polymodel/library/get_part_file.rs
··· 1 + // @generated by jacquard-lexicon. DO NOT EDIT. 2 + // 3 + // Lexicon: space.polymodel.library.getPartFile 4 + // 5 + // This file was automatically generated from Lexicon schemas. 6 + // Any manual changes will be overwritten on the next regeneration. 7 + 8 + #[allow(unused_imports)] 9 + use alloc::collections::BTreeMap; 10 + 11 + #[allow(unused_imports)] 12 + use core::marker::PhantomData; 13 + use jacquard_common::{BosStr, DefaultStr, FromStaticStr}; 14 + use jacquard_common::deps::bytes::Bytes; 15 + use jacquard_common::deps::smol_str::SmolStr; 16 + use jacquard_common::types::string::AtUri; 17 + use jacquard_common::types::value::Data; 18 + use jacquard_derive::IntoStatic; 19 + use serde::{Serialize, Deserialize}; 20 + 21 + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, IntoStatic)] 22 + #[serde(rename_all = "camelCase", bound(deserialize = "S: Deserialize<'de> + BosStr"))] 23 + pub struct GetPartFile<S: BosStr = DefaultStr> { 24 + pub uri: AtUri<S>, 25 + } 26 + 27 + 28 + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, IntoStatic)] 29 + #[serde(rename_all = "camelCase")] 30 + pub struct GetPartFileOutput { 31 + pub body: Bytes, 32 + } 33 + 34 + /** Response marker for the `space.polymodel.library.getPartFile` query. 35 + 36 + Implements `jacquard_common::xrpc::XrpcResp`; successful bodies decode as `Self::Output<S>`, which is `GetPartFileOutput` for this endpoint.*/ 37 + pub struct GetPartFileResponse; 38 + impl jacquard_common::xrpc::XrpcResp for GetPartFileResponse { 39 + const NSID: &'static str = "space.polymodel.library.getPartFile"; 40 + const ENCODING: &'static str = "*/*"; 41 + type Output<S: BosStr> = GetPartFileOutput; 42 + type Err = jacquard_common::xrpc::GenericError; 43 + fn encode_output<S: BosStr>( 44 + output: &Self::Output<S>, 45 + ) -> Result<Vec<u8>, jacquard_common::xrpc::EncodeError> 46 + where 47 + Self::Output<S>: Serialize, 48 + { 49 + Ok(output.body.to_vec()) 50 + } 51 + fn decode_output<'de, S>( 52 + body: &'de [u8], 53 + ) -> Result<Self::Output<S>, jacquard_common::error::DecodeError> 54 + where 55 + S: BosStr + Deserialize<'de>, 56 + Self::Output<S>: Deserialize<'de>, 57 + { 58 + Ok(GetPartFileOutput { 59 + body: jacquard_common::deps::bytes::Bytes::copy_from_slice(body), 60 + }) 61 + } 62 + } 63 + 64 + impl<S: BosStr> jacquard_common::xrpc::XrpcRequest for GetPartFile<S> { 65 + const NSID: &'static str = "space.polymodel.library.getPartFile"; 66 + const METHOD: jacquard_common::xrpc::XrpcMethod = jacquard_common::xrpc::XrpcMethod::Query; 67 + type Response = GetPartFileResponse; 68 + } 69 + 70 + /** Endpoint marker for the `space.polymodel.library.getPartFile` query. 71 + 72 + Path: `/xrpc/space.polymodel.library.getPartFile`. The request payload type is `GetPartFile<S>`; send that request with `jacquard::Client` or use this marker through lower-level `XrpcEndpoint` APIs.*/ 73 + pub struct GetPartFileRequest; 74 + impl jacquard_common::xrpc::XrpcEndpoint for GetPartFileRequest { 75 + const PATH: &'static str = "/xrpc/space.polymodel.library.getPartFile"; 76 + const METHOD: jacquard_common::xrpc::XrpcMethod = jacquard_common::xrpc::XrpcMethod::Query; 77 + type Request<S: BosStr> = GetPartFile<S>; 78 + type Response = GetPartFileResponse; 79 + } 80 + 81 + pub mod get_part_file_state { 82 + 83 + pub use crate::builder_types::{Set, Unset, IsSet, IsUnset}; 84 + #[allow(unused)] 85 + use ::core::marker::PhantomData; 86 + mod sealed { 87 + pub trait Sealed {} 88 + } 89 + /// State trait tracking which required fields have been set 90 + pub trait State: sealed::Sealed { 91 + type Uri; 92 + } 93 + /// Empty state - all required fields are unset 94 + pub struct Empty(()); 95 + impl sealed::Sealed for Empty {} 96 + impl State for Empty { 97 + type Uri = Unset; 98 + } 99 + ///State transition - sets the `uri` field to Set 100 + pub struct SetUri<St: State = Empty>(PhantomData<fn() -> St>); 101 + impl<St: State> sealed::Sealed for SetUri<St> {} 102 + impl<St: State> State for SetUri<St> { 103 + type Uri = Set<members::uri>; 104 + } 105 + /// Marker types for field names 106 + #[allow(non_camel_case_types)] 107 + pub mod members { 108 + ///Marker type for the `uri` field 109 + pub struct uri(()); 110 + } 111 + } 112 + 113 + /// Builder for constructing an instance of this type. 114 + pub struct GetPartFileBuilder<St: get_part_file_state::State, S: BosStr = DefaultStr> { 115 + _state: PhantomData<fn() -> St>, 116 + _fields: (Option<AtUri<S>>,), 117 + _type: PhantomData<fn() -> S>, 118 + } 119 + 120 + impl GetPartFile<DefaultStr> { 121 + /// Create a new builder for this type, using the default string type (DefaultStr = SmolStr) if needed 122 + pub fn new() -> GetPartFileBuilder<get_part_file_state::Empty, DefaultStr> { 123 + GetPartFileBuilder::new() 124 + } 125 + } 126 + 127 + impl<S: BosStr> GetPartFile<S> { 128 + /// Create a new builder for this type 129 + pub fn builder() -> GetPartFileBuilder<get_part_file_state::Empty, S> { 130 + GetPartFileBuilder::builder() 131 + } 132 + } 133 + 134 + impl GetPartFileBuilder<get_part_file_state::Empty, DefaultStr> { 135 + /// Create a new builder with all fields unset, using the default string type, if needed 136 + pub fn new() -> Self { 137 + GetPartFileBuilder { 138 + _state: PhantomData, 139 + _fields: (None,), 140 + _type: PhantomData, 141 + } 142 + } 143 + } 144 + 145 + impl<S: BosStr> GetPartFileBuilder<get_part_file_state::Empty, S> { 146 + /// Create a new builder with all fields unset 147 + pub fn builder() -> Self { 148 + GetPartFileBuilder { 149 + _state: PhantomData, 150 + _fields: (None,), 151 + _type: PhantomData, 152 + } 153 + } 154 + } 155 + 156 + impl<St, S: BosStr> GetPartFileBuilder<St, S> 157 + where 158 + St: get_part_file_state::State, 159 + St::Uri: get_part_file_state::IsUnset, 160 + { 161 + /// Set the `uri` field (required) 162 + pub fn uri( 163 + mut self, 164 + value: impl Into<AtUri<S>>, 165 + ) -> GetPartFileBuilder<get_part_file_state::SetUri<St>, S> { 166 + self._fields.0 = Option::Some(value.into()); 167 + GetPartFileBuilder { 168 + _state: PhantomData, 169 + _fields: self._fields, 170 + _type: PhantomData, 171 + } 172 + } 173 + } 174 + 175 + impl<St, S: BosStr> GetPartFileBuilder<St, S> 176 + where 177 + St: get_part_file_state::State, 178 + St::Uri: get_part_file_state::IsSet, 179 + { 180 + /// Build the final struct. 181 + pub fn build(self) -> GetPartFile<S> { 182 + GetPartFile { 183 + uri: self._fields.0.unwrap(), 184 + } 185 + } 186 + }
+2
crates/polymodel-renderer-protocol/Cargo.toml
··· 8 8 [dependencies] 9 9 serde = { workspace = true } 10 10 postcard = { workspace = true, features = ["alloc"] } 11 + sha2 = { version = "0.10", default-features = false } 12 + thiserror = { workspace = true }
+82
crates/polymodel-renderer-protocol/src/lib.rs
··· 10 10 //! worker crates, never here. 11 11 12 12 use serde::{Deserialize, Serialize}; 13 + use sha2::Digest; 13 14 14 15 // --------------------------------------------------------------------------- 15 16 // Shared mesh-format types (relocated from polymodel-mesh::contract) ··· 117 118 } 118 119 119 120 // --------------------------------------------------------------------------- 121 + // Part-file verification (client-side digest + size check) 122 + // --------------------------------------------------------------------------- 123 + 124 + /// Errors from [`verify_part_file`]. 125 + #[derive(Debug, thiserror::Error)] 126 + pub enum VerifyError { 127 + #[error("part file size mismatch: expected {expected}, got {actual}")] 128 + Size { expected: i64, actual: usize }, 129 + #[error("part file digest mismatch")] 130 + Digest, 131 + } 132 + 133 + /// Verify a reassembled part file's size and (optional) sha256 digest. 134 + /// 135 + /// Called on every consumer path (viewer mesh-load, direct download) after the 136 + /// streamed bytes are collected. Per-chunk CID verifies each piece on the PDS 137 + /// side; this full-file check catches assembly errors (reordering, swaps, 138 + /// drops) that per-chunk CID cannot. 139 + pub fn verify_part_file(bytes: &[u8], size: i64, digest: Option<&[u8]>) -> Result<(), VerifyError> { 140 + if bytes.len() as i64 != size { 141 + return Err(VerifyError::Size { 142 + expected: size, 143 + actual: bytes.len(), 144 + }); 145 + } 146 + if let Some(expected) = digest { 147 + let actual = sha2::Sha256::digest(bytes); 148 + if actual[..] != *expected { 149 + return Err(VerifyError::Digest); 150 + } 151 + } 152 + Ok(()) 153 + } 154 + 155 + // --------------------------------------------------------------------------- 120 156 // Renderer command (main → worker) 121 157 // --------------------------------------------------------------------------- 122 158 ··· 134 170 LoadMesh { 135 171 format: MeshFormat, 136 172 primary_url: String, 173 + expected_size: Option<i64>, 174 + expected_digest: Option<Vec<u8>>, 137 175 }, 138 176 /// Render a mesh URL to a PNG preview image. This is intentionally handled by 139 177 /// the worker so the Dioxus app does not depend on mesh parsing or `three-d`. ··· 244 282 use super::*; 245 283 246 284 #[test] 285 + fn verify_part_file_matching_size_and_digest_is_ok() { 286 + let data = b"hello world"; 287 + let digest = sha2::Sha256::digest(data); 288 + assert!(verify_part_file(data, data.len() as i64, Some(&digest)).is_ok()); 289 + } 290 + 291 + #[test] 292 + fn verify_part_file_size_mismatch_is_err() { 293 + let data = b"hello world"; 294 + let digest = sha2::Sha256::digest(data); 295 + let err = verify_part_file(data, 999, Some(&digest)).unwrap_err(); 296 + assert!(matches!( 297 + err, 298 + VerifyError::Size { 299 + expected: 999, 300 + actual: 11 301 + } 302 + )); 303 + } 304 + 305 + #[test] 306 + fn verify_part_file_digest_mismatch_is_err() { 307 + let data = b"hello world"; 308 + let wrong_digest = [0u8; 32]; 309 + let err = verify_part_file(data, data.len() as i64, Some(&wrong_digest)).unwrap_err(); 310 + assert!(matches!(err, VerifyError::Digest)); 311 + } 312 + 313 + #[test] 314 + fn verify_part_file_no_digest_checks_size_only() { 315 + let data = b"hello world"; 316 + assert!(verify_part_file(data, data.len() as i64, None).is_ok()); 317 + assert!(verify_part_file(data, 999, None).is_err()); 318 + } 319 + 320 + #[test] 247 321 fn from_extension_maps_known_formats() { 248 322 assert_eq!( 249 323 MeshFormat::from_extension("cube.stl"), ··· 304 378 RendererCommand::LoadMesh { 305 379 format: MeshFormat::Stl, 306 380 primary_url: "/models/cube.stl".into(), 381 + expected_size: Some(4096), 382 + expected_digest: Some(vec![0u8; 32]), 307 383 }, 308 384 RendererCommand::LoadMesh { 309 385 format: MeshFormat::Obj, 310 386 primary_url: "/models/cube.obj".into(), 387 + expected_size: None, 388 + expected_digest: None, 311 389 }, 312 390 RendererCommand::LoadMesh { 313 391 format: MeshFormat::Gltf, 314 392 primary_url: "/models/cube.glb".into(), 393 + expected_size: Some(2048), 394 + expected_digest: None, 315 395 }, 316 396 RendererCommand::LoadMesh { 317 397 format: MeshFormat::Threemf, 318 398 primary_url: "/models/cube.3mf".into(), 399 + expected_size: None, 400 + expected_digest: Some(vec![1u8; 32]), 319 401 }, 320 402 RendererCommand::RenderPreviewImage { 321 403 request_id: 7,
+29 -5
crates/polymodel-renderer-worker/src/worker.rs
··· 374 374 // LoadMesh (async) 375 375 // --------------------------------------------------------------------------- 376 376 377 - fn handle_load_mesh(state: &Rc<RefCell<WorkerState>>, format: MeshFormat, primary_url: String) { 377 + fn handle_load_mesh( 378 + state: &Rc<RefCell<WorkerState>>, 379 + format: MeshFormat, 380 + primary_url: String, 381 + expected_size: Option<i64>, 382 + expected_digest: Option<Vec<u8>>, 383 + ) { 378 384 let epoch = { 379 385 let mut st = state.borrow_mut(); 380 386 if st.disposed { ··· 386 392 387 393 let state_clone = state.clone(); 388 394 spawn_local(async move { 389 - let result = load_mesh_async(&primary_url, format).await; 395 + let result = load_mesh_async(&primary_url, format, expected_size, expected_digest).await; 390 396 391 397 let mut st = match state_clone.try_borrow_mut() { 392 398 Ok(st) => st, ··· 423 429 }); 424 430 } 425 431 426 - async fn load_mesh_async(primary_url: &str, format: MeshFormat) -> Result<ModelMesh, String> { 432 + async fn load_mesh_async( 433 + primary_url: &str, 434 + format: MeshFormat, 435 + expected_size: Option<i64>, 436 + expected_digest: Option<Vec<u8>>, 437 + ) -> Result<ModelMesh, String> { 427 438 let primary_bytes = fetch_bytes(primary_url) 428 439 .await 429 440 .map_err(|e| format!("fetch primary: {e}"))?; 441 + 442 + if expected_size.is_some() || expected_digest.is_some() { 443 + let size = expected_size.unwrap_or(primary_bytes.len() as i64); 444 + if let Err(e) = polymodel_renderer_protocol::verify_part_file( 445 + &primary_bytes, 446 + size, 447 + expected_digest.as_deref(), 448 + ) { 449 + return Err(format!("part file verification failed: {e}")); 450 + } 451 + } 430 452 431 453 let companions = if format == MeshFormat::Gltf { 432 454 let uris = discover_companion_uris(&primary_bytes); ··· 478 500 let scope = state.borrow().scope.clone(); 479 501 spawn_local(async move { 480 502 let result = async { 481 - let model = load_mesh_async(&primary_url, format).await?; 503 + let model = load_mesh_async(&primary_url, format, None, None).await?; 482 504 render_preview_png(&model, width.max(1), height.max(1)).await 483 505 } 484 506 .await; ··· 613 635 RendererCommand::LoadMesh { 614 636 format, 615 637 primary_url, 638 + expected_size, 639 + expected_digest, 616 640 } => { 617 641 drop(st); 618 - handle_load_mesh(state, format, primary_url); 642 + handle_load_mesh(state, format, primary_url, expected_size, expected_digest); 619 643 } 620 644 RendererCommand::RenderPreviewImage { 621 645 request_id,
+160 -75
src/appview/downloads.rs
··· 1 + //! `space.polymodel.library.getPartFile` — reassemble a part's chunked geometry 2 + //! file and stream it to the client. 3 + //! 4 + //! The handler returns `Result<Response, AppError>`, not `XrpcResponse<_>`, so 5 + //! it can set `Content-Type` and `Content-Disposition` on the success body. 6 + //! `XrpcResponse` hardcodes `Content-Type: */*` and has no disposition hook. 7 + //! The error axis still goes through `AppError` → `GenericXrpcErrorResponse` 8 + //! like every other handler. 9 + //! 10 + //! **Streaming model.** Each chunk's blob is fetched from the actor's PDS via 11 + //! `com.atproto.sync.getBlob` and piped straight into the response body in 12 + //! offset order — the server never buffers the whole file (or a whole chunk). 13 + //! Chunk 0 is prefetched before the `Response` is returned so a first-fetch 14 + //! PDS failure maps to a clean 500. From chunk 1 onward a fetch failure or 15 + //! truncation surfaces as a broken body (headers are already committed); the 16 + //! client catches it via `file.size` + `file.digest` (see 17 + //! `polymodel_renderer_protocol::verify_part_file`). 18 + 1 19 use axum::body::Body; 2 - use axum::extract::{Path, State}; 20 + use axum::extract::State; 3 21 use axum::http::{StatusCode, header}; 4 22 use axum::response::Response; 5 23 use futures::TryStreamExt; 24 + use futures::stream::{self, StreamExt}; 6 25 use jacquard::identity::resolver::IdentityResolver; 7 - use jacquard_common::types::string::{Cid, Did}; 26 + use jacquard_axum::ExtractXrpc; 27 + use jacquard_common::types::string::Cid; 8 28 use jacquard_common::xrpc::XrpcExt; 9 29 use polymodel_api::com_atproto::sync::get_blob::GetBlob; 10 30 use polymodel_api::space_polymodel::library::File; 11 - use sha2::{Digest, Sha256}; 31 + use polymodel_api::space_polymodel::library::get_part_file::GetPartFileRequest; 12 32 13 33 use super::error::{AppResult, db, internal, invalid_request, not_found}; 14 34 use super::state::AppState; 15 35 16 - const MAX_DOWNLOAD_BYTES: i64 = 100 * 1024 * 1024; 17 - 18 - pub(super) async fn download_part( 36 + /// Reassemble and stream a part's chunked geometry file. 37 + /// 38 + /// Accepts an at-URI pointing at a `space.polymodel.library.part` record. The 39 + /// part's `File` manifest is loaded from the local SQLite projection, validated, 40 + /// then each chunk's blob is fetched from the actor's PDS and streamed in offset 41 + /// order. Digest and total-length verification are client-side. 42 + pub(super) async fn get_part_file( 19 43 State(state): State<AppState>, 20 - Path((did, rkey)): Path<(String, String)>, 44 + ExtractXrpc(req): ExtractXrpc<GetPartFileRequest>, 21 45 ) -> AppResult<Response> { 22 - let did: Did = Did::new_owned(did).map_err(|e| invalid_request(format!("invalid DID: {e}")))?; 23 - let rkey = rkey.trim(); 24 - if rkey.is_empty() || rkey.contains('/') { 25 - return Err(invalid_request("invalid part rkey")); 46 + let path = req 47 + .uri 48 + .path() 49 + .ok_or_else(|| invalid_request("missing at-uri path"))?; 50 + if path.collection.as_str() != "space.polymodel.library.part" { 51 + return Err(invalid_request("at-uri is not a part record")); 26 52 } 53 + let authority = req.uri.authority(); 54 + let rkey = path 55 + .rkey 56 + .ok_or_else(|| invalid_request("missing part rkey"))?; 57 + 58 + // Resolve authority to DID before the DB query — the local projection is 59 + // keyed by DID, so a handle-authority at-uri would miss otherwise. 60 + let did = super::actor::resolve_actor(&state, &authority.convert()).await?; 27 61 28 62 #[derive(sqlx::FromRow)] 29 63 struct PartDownloadRow { ··· 31 65 file_json: String, 32 66 } 33 67 34 - let row = db(sqlx::query_as!( 35 - PartDownloadRow, 36 - r#"SELECT name AS "name!", file_json AS "file_json!" FROM parts WHERE did = ? AND rkey = ?"#, 37 - did.as_ref(), 38 - rkey, 39 - ) 40 - .fetch_optional(&state.pool) 41 - .await)? 68 + let row = db( 69 + sqlx::query_as!( 70 + PartDownloadRow, 71 + r#"SELECT name AS "name!", file_json AS "file_json!" FROM parts WHERE did = ? AND rkey = ?"#, 72 + did.as_ref(), 73 + rkey.as_ref(), 74 + ) 75 + .fetch_optional(&state.pool) 76 + .await, 77 + )? 42 78 .ok_or_else(not_found)?; 43 79 44 80 let file: File = serde_json::from_str(&row.file_json) ··· 61 97 .ok_or_else(|| internal("DID document does not advertise a PDS endpoint"))? 62 98 .to_owned(); 63 99 64 - let mut chunks = file.chunks; 65 - chunks.sort_by_key(|chunk| chunk.offset); 66 - let mut bytes = Vec::with_capacity(file.size as usize); 67 - for chunk in chunks { 68 - let cid: Cid = Cid::new_owned(chunk.blob.blob().r#ref.as_str().as_bytes()) 69 - .map_err(|e| internal(format!("part blob CID is invalid: {e}")))?; 70 - let request = GetBlob { 100 + let ordered_cids: Vec<Cid> = ordered_chunks(&file) 101 + .iter() 102 + .map(|chunk| { 103 + Cid::new_owned(chunk.blob.blob().r#ref.as_str().as_bytes()) 104 + .map_err(|e| internal(format!("part blob CID is invalid: {e}"))) 105 + }) 106 + .collect::<AppResult<_>>()?; 107 + 108 + // Prefetch chunk 0 so a first-fetch PDS failure returns a clean 500 before 109 + // the 200 + body is committed. The response body chains this already-open 110 + // stream with lazy fetches for the remaining chunks. 111 + let first_cid = ordered_cids[0].clone(); 112 + let first_response = state 113 + .resolver 114 + .xrpc(pds_endpoint.borrow()) 115 + .download(&GetBlob { 71 116 did: did.clone(), 72 - cid, 73 - }; 74 - let response = state 75 - .resolver 76 - .xrpc(pds_endpoint.borrow()) 77 - .download(&request) 78 - .await 79 - .map_err(|e| internal(format!("blob fetch from {pds_endpoint} failed: {e}")))?; 80 - if !response.status().is_success() { 81 - return Err(internal(format!( 82 - "blob fetch from {pds_endpoint} failed with HTTP {}", 83 - response.status() 84 - ))); 85 - } 86 - let chunk_start = bytes.len(); 87 - let (_, body) = response.into_parts(); 88 - let mut stream = body.into_inner(); 89 - while let Some(chunk_bytes) = stream 90 - .try_next() 91 - .await 92 - .map_err(|e| internal(format!("blob stream from {pds_endpoint} failed: {e}")))? 93 - { 94 - bytes.extend_from_slice(&chunk_bytes); 95 - } 96 - if bytes.len() - chunk_start != chunk.size as usize { 97 - return Err(internal("blob chunk length did not match manifest")); 98 - } 117 + cid: first_cid, 118 + }) 119 + .await 120 + .map_err(|e| internal(format!("blob fetch from {pds_endpoint} failed: {e}")))?; 121 + if !first_response.status().is_success() { 122 + return Err(internal(format!( 123 + "blob fetch from {pds_endpoint} failed with HTTP {}", 124 + first_response.status() 125 + ))); 99 126 } 100 - if bytes.len() != file.size as usize { 101 - return Err(internal("downloaded file length did not match manifest")); 102 - } 103 - if let Some(digest) = file.digest.as_ref() { 104 - let actual = Sha256::digest(&bytes); 105 - if actual[..] != *digest.as_ref() { 106 - return Err(internal("downloaded file digest did not match manifest")); 107 - } 108 - } 127 + let (_, first_body) = first_response.into_parts(); 128 + let first_stream = first_body.into_inner(); 129 + 130 + let remaining_cids = ordered_cids[1..].to_vec(); 131 + let state_clone = state.clone(); 132 + let pds_clone = pds_endpoint.clone(); 133 + let did_clone = did.clone(); 134 + 135 + type BoxError = Box<dyn std::error::Error + Send + Sync>; 136 + 137 + let first_stream = first_stream.map_err(|e| -> BoxError { Box::new(e) }); 138 + 139 + let rest_stream = stream::iter(remaining_cids) 140 + .then(move |cid| { 141 + let state = state_clone.clone(); 142 + let pds = pds_clone.clone(); 143 + let did = did_clone.clone(); 144 + async move { 145 + let response = state 146 + .resolver 147 + .xrpc(pds.borrow()) 148 + .download(&GetBlob { 149 + did: did.clone(), 150 + cid, 151 + }) 152 + .await 153 + .map_err(|e| -> BoxError { 154 + Box::new(internal(format!("blob fetch from {pds} failed: {e}"))) 155 + })?; 156 + if !response.status().is_success() { 157 + return Err(Box::new(internal(format!( 158 + "blob fetch from {pds} failed with HTTP {}", 159 + response.status() 160 + ))) as BoxError); 161 + } 162 + let (_, body) = response.into_parts(); 163 + let stream = body.into_inner().map_err(|e| -> BoxError { Box::new(e) }); 164 + Ok::<_, BoxError>(stream) 165 + } 166 + }) 167 + .try_flatten(); 168 + 169 + let body_stream = first_stream.chain(rest_stream); 109 170 110 171 let filename = download_filename(&row.name); 111 172 let content_type = file.mime_type.to_string(); ··· 116 177 header::CONTENT_DISPOSITION, 117 178 format!("attachment; filename=\"{filename}\""), 118 179 ) 119 - .body(Body::from(bytes)) 180 + .header(header::CONTENT_LENGTH, file.size) 181 + .body(Body::from_stream(body_stream)) 120 182 .map_err(|e| internal(format!("download response build failed: {e}"))) 121 183 } 122 184 185 + /// Return a file's chunks sorted by offset. The streaming response yields them 186 + /// in this order so the client reassembles the original file. 187 + fn ordered_chunks(file: &File) -> Vec<&polymodel_api::space_polymodel::library::Chunk> { 188 + let mut chunks: Vec<_> = file.chunks.iter().collect(); 189 + chunks.sort_by_key(|chunk| chunk.offset); 190 + chunks 191 + } 192 + 193 + /// Cheap manifest preflight: offset contiguity, chunk count, per-chunk sanity. 194 + /// Does **not** cap total file size — the PDS bounds each blob, and a total cap 195 + /// breaks the large-model case chunking exists for. 123 196 fn validate_file_manifest(file: &File) -> AppResult<()> { 124 - if file.size < 0 || file.size > MAX_DOWNLOAD_BYTES { 125 - return Err(invalid_request( 126 - "part file size is outside the download limit", 127 - )); 197 + if file.size < 0 { 198 + return Err(invalid_request("part file size is negative")); 128 199 } 129 200 if file.chunks.len() > 4096 { 130 201 return Err(invalid_request("part file has too many chunks")); ··· 139 210 expected_offset = expected_offset 140 211 .checked_add(chunk.size) 141 212 .ok_or_else(|| invalid_request("part file chunk sizes overflow"))?; 142 - if expected_offset > MAX_DOWNLOAD_BYTES { 143 - return Err(invalid_request( 144 - "part file size is outside the download limit", 145 - )); 146 - } 147 213 } 148 214 if expected_offset != file.size { 149 215 return Err(invalid_request("part file chunks do not match file size")); ··· 168 234 169 235 #[cfg(test)] 170 236 mod tests { 171 - use super::{download_filename, validate_file_manifest}; 237 + use super::{download_filename, ordered_chunks, validate_file_manifest}; 172 238 use jacquard_common::deps::smol_str::SmolStr; 173 239 use jacquard_common::types::blob::{Blob, BlobRef, MimeType}; 174 240 use jacquard_common::types::cid::CidLink; ··· 203 269 } 204 270 205 271 #[test] 206 - fn file_manifest_validation_requires_contiguous_bounded_chunks() { 272 + fn validate_file_manifest_requires_contiguous_bounded_chunks() { 207 273 let chunk = Chunk::builder().blob(blob_ref()).offset(0).size(4).build(); 208 274 assert!(validate_file_manifest(&file(vec![chunk.clone()], 4)).is_ok()); 209 275 210 276 let gapped = Chunk::builder().blob(blob_ref()).offset(2).size(4).build(); 211 277 assert!(validate_file_manifest(&file(vec![gapped], 6)).is_err()); 212 - assert!(validate_file_manifest(&file(vec![chunk], 101 * 1024 * 1024)).is_err()); 278 + } 279 + 280 + #[test] 281 + fn validate_file_manifest_rejects_too_many_chunks() { 282 + let chunk = Chunk::builder().blob(blob_ref()).offset(0).size(4).build(); 283 + let mut chunks = vec![chunk; 4097]; 284 + for (i, chunk) in chunks.iter_mut().enumerate() { 285 + chunk.offset = i as i64; 286 + } 287 + assert!(validate_file_manifest(&file(chunks, 4097 * 4)).is_err()); 288 + } 289 + 290 + #[test] 291 + fn ordered_chunks_sorts_by_offset() { 292 + let chunk_b = Chunk::builder().blob(blob_ref()).offset(4).size(4).build(); 293 + let chunk_a = Chunk::builder().blob(blob_ref()).offset(0).size(4).build(); 294 + let file = file(vec![chunk_b, chunk_a], 8); 295 + let ordered = ordered_chunks(&file); 296 + assert_eq!(ordered[0].offset, 0); 297 + assert_eq!(ordered[1].offset, 4); 213 298 } 214 299 }
+14 -6
src/appview/error.rs
··· 1 1 //! Typed appview error type. 2 2 //! 3 - //! Handlers return `Result<XrpcResponse<_>, AppError>`. `AppError` is a small 4 - //! typed enum (not a string bag): each variant maps to a fixed HTTP status and 5 - //! XRPC error code at the [`IntoResponse`] boundary, so the error *code* is a 6 - //! compile-time constant and only the human message is a `String`. Internal 7 - //! details are logged; the response body never carries secrets or raw errors. 3 + //! Handlers return `Result<XrpcResponse<_>, AppError>` (or `Result<Response, 4 + //! AppError>` for `*/*` binary endpoints that need custom headers like 5 + //! `Content-Disposition`). `AppError` is a small typed enum (not a string 6 + //! bag): each variant maps to a fixed HTTP status and XRPC error code at the 7 + //! [`IntoResponse`] boundary, so the error *code* is a compile-time constant 8 + //! and only the human message is a `String`. Internal details are logged; the 9 + //! response body never carries secrets or raw errors. `AppError` implements 10 + //! `std::error::Error` so it can be used as a stream error type for streaming 11 + //! response bodies. 8 12 9 13 use axum::http::StatusCode; 10 14 use axum::response::{IntoResponse, Response}; 11 15 use jacquard_axum::GenericXrpcErrorResponse; 12 16 13 17 /// Appview handler error. Maps onto the XRPC error response shape. 14 - #[derive(Debug)] 18 + #[derive(Debug, thiserror::Error)] 15 19 pub(crate) enum AppError { 16 20 /// 401 `AuthenticationRequired`: strict OAuth extraction failed or an 17 21 /// authenticated PDS operation reported an auth failure. 22 + #[error("AuthenticationRequired: {0}")] 18 23 Unauthorized(String), 19 24 /// 400 `InvalidRequest`: bad parameters (bad at-uri, unknown algorithm, 20 25 /// unparseable cursor, unresolvable handle). 26 + #[error("InvalidRequest: {0}")] 21 27 InvalidRequest(String), 22 28 /// 404 `RecordNotFound`: the requested record does not exist in the index. 29 + #[error("RecordNotFound")] 23 30 NotFound, 24 31 /// 500 `InternalServerError`: unexpected failure (database, record decode, 25 32 /// upstream bsky fetch). The message is safe to expose; details are logged. 33 + #[error("InternalServerError: {0}")] 26 34 Internal(String), 27 35 } 28 36
+5 -7
src/appview/mod.rs
··· 46 46 }, 47 47 library::{ 48 48 delete_thing::DeleteThingRequest, get_author_things::GetAuthorThingsRequest, 49 - get_feed::GetFeedRequest, get_model::GetModelRequest, get_thing::GetThingRequest, 50 - publish_thing::PublishThingRequest, search_things::SearchThingsRequest, 51 - stage_file::StageFileRequest, update_thing::UpdateThingRequest, 49 + get_feed::GetFeedRequest, get_model::GetModelRequest, get_part_file::GetPartFileRequest, 50 + get_thing::GetThingRequest, publish_thing::PublishThingRequest, 51 + search_things::SearchThingsRequest, stage_file::StageFileRequest, 52 + update_thing::UpdateThingRequest, 52 53 }, 53 54 }; 54 55 ··· 120 121 GetSessionRequest::PATH, 121 122 axum::routing::get(actor::get_session), 122 123 ) 123 - .route( 124 - "/app/parts/{did}/{rkey}/download", 125 - axum::routing::get(downloads::download_part), 126 - ) 124 + .merge(GetPartFileRequest::into_router(downloads::get_part_file)) 127 125 // PM-43 app-internal draft store + image upload. Not federated lexicons, 128 126 // so these use a plain `/app/*` namespace (cookie-authenticated, 129 127 // same-origin) rather than `/xrpc/space.polymodel.*`.
+95
src/appview/tests.rs
··· 1452 1452 .unwrap(); 1453 1453 assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); 1454 1454 } 1455 + 1456 + #[tokio::test] 1457 + async fn get_part_file_missing_uri_is_bad_request() { 1458 + let state = state().await; 1459 + let app = super::router().with_state(state); 1460 + let resp = app 1461 + .oneshot( 1462 + Request::builder() 1463 + .uri("/xrpc/space.polymodel.library.getPartFile") 1464 + .body(Body::empty()) 1465 + .unwrap(), 1466 + ) 1467 + .await 1468 + .unwrap(); 1469 + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); 1470 + let body = axum::body::to_bytes(resp.into_body(), usize::MAX) 1471 + .await 1472 + .unwrap(); 1473 + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); 1474 + assert_eq!(json["error"], "InvalidRequest"); 1475 + } 1476 + 1477 + #[tokio::test] 1478 + async fn get_part_file_malformed_uri_is_bad_request() { 1479 + let state = state().await; 1480 + let app = super::router().with_state(state); 1481 + let resp = app 1482 + .oneshot( 1483 + Request::builder() 1484 + .uri("/xrpc/space.polymodel.library.getPartFile?uri=notauri") 1485 + .body(Body::empty()) 1486 + .unwrap(), 1487 + ) 1488 + .await 1489 + .unwrap(); 1490 + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); 1491 + } 1492 + 1493 + #[tokio::test] 1494 + async fn get_part_file_wrong_collection_is_bad_request() { 1495 + let state = state().await; 1496 + let app = super::router().with_state(state); 1497 + let resp = app 1498 + .oneshot( 1499 + Request::builder() 1500 + .uri("/xrpc/space.polymodel.library.getPartFile?uri=at%3A%2F%2Fdid%3Aplc%3Ax%2Fspace.polymodel.library.thing%2Ft1") 1501 + .body(Body::empty()) 1502 + .unwrap(), 1503 + ) 1504 + .await 1505 + .unwrap(); 1506 + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); 1507 + let body = axum::body::to_bytes(resp.into_body(), usize::MAX) 1508 + .await 1509 + .unwrap(); 1510 + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); 1511 + assert_eq!(json["error"], "InvalidRequest"); 1512 + } 1513 + 1514 + #[tokio::test] 1515 + async fn get_part_file_missing_part_is_not_found() { 1516 + let state = state().await; 1517 + let app = super::router().with_state(state); 1518 + let resp = app 1519 + .oneshot( 1520 + Request::builder() 1521 + .uri("/xrpc/space.polymodel.library.getPartFile?uri=at%3A%2F%2Fdid%3Aplc%3Ax%2Fspace.polymodel.library.part%2Fnone") 1522 + .body(Body::empty()) 1523 + .unwrap(), 1524 + ) 1525 + .await 1526 + .unwrap(); 1527 + assert_eq!(resp.status(), StatusCode::NOT_FOUND); 1528 + let body = axum::body::to_bytes(resp.into_body(), usize::MAX) 1529 + .await 1530 + .unwrap(); 1531 + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); 1532 + assert_eq!(json["error"], "RecordNotFound"); 1533 + } 1534 + 1535 + #[tokio::test] 1536 + async fn app_parts_download_route_is_gone() { 1537 + let state = state().await; 1538 + let app = super::router().with_state(state); 1539 + let resp = app 1540 + .oneshot( 1541 + Request::builder() 1542 + .uri("/app/parts/did:plc:x/abc/download") 1543 + .body(Body::empty()) 1544 + .unwrap(), 1545 + ) 1546 + .await 1547 + .unwrap(); 1548 + assert_eq!(resp.status(), StatusCode::NOT_FOUND); 1549 + }
+5 -5
src/thing_card.rs
··· 1 1 use dioxus::prelude::*; 2 2 #[cfg(test)] 3 3 use jacquard_common::types::{ 4 - string::{AtUri, Datetime, Did, Handle}, 4 + string::{AtUri, Cid, Datetime, Did, Handle}, 5 5 uri::UriValue, 6 6 }; 7 7 use jacquard_common::xrpc::XrpcClient; ··· 36 36 Some("Ari Chen"), 37 37 Some("she/her"), 38 38 ), 39 - cid: "bafyreicardcomplete".into(), 39 + cid: Cid::new_owned(b"bafyreicardcomplete").expect("fixture cid is valid"), 40 40 cover: Some(vec![library::ImageView { 41 41 alt: Some("Rendered preview of a parametric electronics enclosure".into()), 42 42 aspect_ratio: None, ··· 74 74 }, 75 75 library::ThingViewBasic { 76 76 author: actor("did:plc:mira", "mira.tools", None, None), 77 - cid: "bafyreicardsparse".into(), 77 + cid: Cid::new_owned(b"bafyreicardsparse").expect("fixture cid is valid"), 78 78 cover: None, 79 79 indexed_at: Datetime::try_from("2026-06-21T12:05:00Z".to_string()) 80 80 .expect("fixture datetime is valid"), ··· 97 97 Some("Foundry Lab"), 98 98 Some("they/them"), 99 99 ), 100 - cid: "bafyreicardmissingmedia".into(), 100 + cid: Cid::new_owned(b"bafyreicardmissingmedia").expect("fixture cid is valid"), 101 101 cover: None, 102 102 indexed_at: Datetime::try_from("2026-06-21T12:10:00Z".to_string()) 103 103 .expect("fixture datetime is valid"), ··· 419 419 Some("Maker Name"), 420 420 Some("he/him"), 421 421 ), 422 - cid: "bafyreitest".into(), 422 + cid: Cid::new_owned(b"bafyreitest").expect("fixture cid is valid"), 423 423 cover: Some(vec![library::ImageView { 424 424 alt: Some("Cover alt".into()), 425 425 aspect_ratio: None,
+152 -9
src/thing_detail.rs
··· 75 75 if path.collection.as_str() != "space.polymodel.library.part" { 76 76 return None; 77 77 } 78 - let rkey = path.rkey?; 78 + if path.rkey.is_none() { 79 + return None; 80 + } 79 81 Some(format!( 80 - "/app/parts/{}/{}/download", 81 - uri.authority().as_str(), 82 - rkey.as_ref() 82 + "/xrpc/space.polymodel.library.getPartFile?uri={}", 83 + urlencoding::encode(uri.as_str()) 83 84 )) 84 85 } 85 86 ··· 94 95 name, 95 96 url, 96 97 format, 98 + expected_size: Some(part.view.file.size), 99 + expected_digest: part.view.file.digest.as_deref().map(|b| b.to_vec()), 97 100 }) 98 101 } 99 102 ··· 233 236 } 234 237 } 235 238 239 + /// Fetch a part file from the XRPC endpoint, verify its size and digest, then 240 + /// trigger a Blob-URL download. Only creates the download on successful verification. 241 + #[cfg(target_arch = "wasm32")] 242 + async fn verified_download( 243 + url: &str, 244 + name: &str, 245 + expected_size: i64, 246 + expected_digest: Option<&[u8]>, 247 + ) -> Result<(), String> { 248 + use polymodel_renderer_protocol::verify_part_file; 249 + use wasm_bindgen::JsCast; 250 + use wasm_bindgen_futures::JsFuture; 251 + 252 + let opts = web_sys::RequestInit::new(); 253 + opts.set_method("GET"); 254 + 255 + let request = web_sys::Request::new_with_str_and_init(url, &opts) 256 + .map_err(|e| format!("request: {:?}", e))?; 257 + 258 + let global = js_sys::global(); 259 + let fetch_fn = 260 + js_sys::Reflect::get(&global, &"fetch".into()).map_err(|e| format!("no fetch: {:?}", e))?; 261 + let fetch_promise = js_sys::Function::from(fetch_fn) 262 + .call1(&global, &request) 263 + .map_err(|e| format!("fetch call: {:?}", e))? 264 + .dyn_into::<js_sys::Promise>() 265 + .map_err(|_| "fetch: not a Promise".to_string())?; 266 + 267 + let response = JsFuture::from(fetch_promise) 268 + .await 269 + .map_err(|e| format!("fetch: {:?}", e))? 270 + .dyn_into::<web_sys::Response>() 271 + .map_err(|_| "fetch: not a Response")?; 272 + 273 + if !response.ok() { 274 + let status = response.status(); 275 + let text = JsFuture::from(response.text().map_err(|e| format!("text: {:?}", e))?) 276 + .await 277 + .map_err(|e| format!("text: {:?}", e))? 278 + .as_string() 279 + .unwrap_or_default(); 280 + return Err(format!("part file fetch failed: HTTP {status}: {text}")); 281 + } 282 + 283 + let array_buffer = JsFuture::from( 284 + response 285 + .array_buffer() 286 + .map_err(|e| format!("array_buffer: {:?}", e))?, 287 + ) 288 + .await 289 + .map_err(|e| format!("array_buffer: {:?}", e))?; 290 + 291 + let bytes = js_sys::Uint8Array::new(&array_buffer).to_vec(); 292 + 293 + verify_part_file(&bytes, expected_size, expected_digest) 294 + .map_err(|e| format!("verification failed: {e}"))?; 295 + 296 + let blob = web_sys::Blob::new_with_u8_array_sequence(&js_sys::Array::of1( 297 + &js_sys::Uint8Array::from(&bytes[..]), 298 + )) 299 + .map_err(|e| format!("blob: {:?}", e))?; 300 + 301 + let blob_url = web_sys::Url::create_object_url_with_blob(&blob) 302 + .map_err(|e| format!("object url: {:?}", e))?; 303 + 304 + let document = web_sys::window() 305 + .ok_or("no window")? 306 + .document() 307 + .ok_or("no document")?; 308 + let anchor = document 309 + .create_element("a") 310 + .map_err(|e| format!("create element: {:?}", e))? 311 + .dyn_into::<web_sys::HtmlAnchorElement>() 312 + .map_err(|_| "created element is not an anchor".to_string())?; 313 + anchor.set_href(&blob_url); 314 + anchor.set_download(name); 315 + anchor.click(); 316 + 317 + web_sys::Url::revoke_object_url(&blob_url).map_err(|e| format!("revoke: {:?}", e))?; 318 + 319 + Ok(()) 320 + } 321 + 322 + #[cfg(not(target_arch = "wasm32"))] 323 + async fn verified_download( 324 + _url: &str, 325 + _name: &str, 326 + _expected_size: i64, 327 + _expected_digest: Option<&[u8]>, 328 + ) -> Result<(), String> { 329 + Err("downloads are only available in the browser".to_string()) 330 + } 331 + 236 332 #[allow(clippy::useless_format)] 237 333 #[component] 238 334 pub(crate) fn ThingDetail(repo: String, rkey: String) -> Element { ··· 371 467 let first_download = selected_model 372 468 .as_ref() 373 469 .and_then(|selected| selected.parts.first()) 374 - .and_then(|part| part_download_href(&part.view.uri)); 470 + .and_then(|part| { 471 + let href = part_download_href(&part.view.uri)?; 472 + Some(( 473 + href, 474 + part.view.file.size, 475 + part.view.file.digest.as_deref().map(|b| b.to_vec()), 476 + clean_text(Some(part.view.name.as_ref())) 477 + .unwrap_or("Untitled part") 478 + .to_string(), 479 + )) 480 + }) 481 + .clone(); 375 482 let viewer_source = selected_model 376 483 .as_ref() 377 484 .and_then(|selected| selected.parts.first()) ··· 416 523 } 417 524 418 525 div { class: "thing-detail-actions", aria_label: "Thing actions", 419 - if let Some(href) = &first_download { 420 - a { class: "button button-primary", href: "{href}", "Download files" } 526 + if first_download.is_some() { 527 + button { 528 + class: "button button-primary", 529 + onclick: { 530 + let dl = first_download.clone(); 531 + move |_| { 532 + let (url, size, digest, name) = dl.clone().unwrap_or_default(); 533 + spawn(async move { 534 + if let Err(e) = verified_download(&url, &name, size, digest.as_deref()).await { 535 + tracing::error!("part download failed: {e}"); 536 + } 537 + }); 538 + } 539 + }, 540 + "Download files" 541 + } 421 542 } else { 422 543 a { class: "button button-primary", href: "#parts-files", "View files" } 423 544 } ··· 713 834 .filter_map(|setting| clean_text(Some(setting.as_ref())).map(ToString::to_string)) 714 835 .collect::<Vec<_>>(); 715 836 let download_href = part_download_href(&part.view.uri); 837 + let download_data = download_href.as_ref().map(|href| { 838 + ( 839 + href.clone(), 840 + part.view.file.size, 841 + part.view.file.digest.as_deref().map(|b| b.to_vec()), 842 + name.clone(), 843 + ) 844 + }); 716 845 717 846 rsx! { 718 847 article { class: "part-row", role: "listitem", ··· 736 865 } 737 866 } 738 867 } 739 - if let Some(href) = &download_href { 740 - a { class: "button button-secondary part-download", href: "{href}", "Download part" } 868 + if download_data.is_some() { 869 + button { 870 + class: "button button-secondary part-download", 871 + onclick: { 872 + let dl = download_data.clone(); 873 + move |_| { 874 + let (url, size, digest, name) = dl.clone().unwrap_or_default(); 875 + spawn(async move { 876 + if let Err(e) = verified_download(&url, &name, size, digest.as_deref()).await { 877 + tracing::error!("part download failed: {e}"); 878 + } 879 + }); 880 + } 881 + }, 882 + "Download part" 883 + } 741 884 } 742 885 } 743 886 }
+18
src/viewer.rs
··· 53 53 pub name: String, 54 54 pub url: String, 55 55 pub format: ViewerSourceFormat, 56 + pub expected_size: Option<i64>, 57 + pub expected_digest: Option<Vec<u8>>, 56 58 } 57 59 58 60 #[derive(Clone, Debug, PartialEq)] ··· 60 62 key: String, 61 63 format: MeshFormat, 62 64 primary_url: String, 65 + expected_size: Option<i64>, 66 + expected_digest: Option<Vec<u8>>, 63 67 } 64 68 65 69 #[derive(Clone, Debug, PartialEq)] ··· 416 420 key: source.key, 417 421 format, 418 422 primary_url: source.url, 423 + expected_size: source.expected_size, 424 + expected_digest: source.expected_digest, 419 425 })); 420 426 } 421 427 ViewerSourceFormat::Step => { ··· 620 626 ), 621 627 format: MeshFormat::Gltf, 622 628 primary_url: url, 629 + expected_size: None, 630 + expected_digest: None, 623 631 })); 624 632 step_report.set(StepConversionReport { 625 633 status: StepConversionStatus::Converted, ··· 970 978 key: load.model.id.to_string(), 971 979 format: load.format, 972 980 primary_url: load.model.asset_path.to_string(), 981 + expected_size: None, 982 + expected_digest: None, 973 983 }) 974 984 }) 975 985 .flatten() ··· 980 990 bt.send_command(&RendererCommand::LoadMesh { 981 991 format: source.format, 982 992 primary_url: source.primary_url, 993 + expected_size: source.expected_size, 994 + expected_digest: source.expected_digest, 983 995 }); 984 996 bt.last_loaded_key = Some(source.key); 985 997 } ··· 1715 1727 bridge.send_command(RendererCommand::LoadMesh { 1716 1728 format: MeshFormat::Stl, 1717 1729 primary_url: "/models/cube.stl".into(), 1730 + expected_size: None, 1731 + expected_digest: None, 1718 1732 }); 1719 1733 assert_eq!( 1720 1734 bridge.command_count(), ··· 1780 1794 bridge.send_command(RendererCommand::LoadMesh { 1781 1795 format: MeshFormat::Stl, 1782 1796 primary_url: "/models/a.stl".into(), 1797 + expected_size: None, 1798 + expected_digest: None, 1783 1799 }); 1784 1800 bridge.send_command(RendererCommand::LoadMesh { 1785 1801 format: MeshFormat::Stl, 1786 1802 primary_url: "/models/b.stl".into(), 1803 + expected_size: None, 1804 + expected_digest: None, 1787 1805 }); 1788 1806 1789 1807 let cmds = bridge.commands();