···9191- 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.
9292- 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.
9393- 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.
9494+- 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).
94959596## Local projection: reads & writes
9697
···1010pub mod get_author_things;
1111pub mod get_feed;
1212pub mod get_model;
1313+pub mod get_part_file;
1314pub mod get_thing;
1415pub mod model;
1516pub mod part;
···1010//! worker crates, never here.
11111212use serde::{Deserialize, Serialize};
1313+use sha2::Digest;
13141415// ---------------------------------------------------------------------------
1516// Shared mesh-format types (relocated from polymodel-mesh::contract)
···117118}
118119119120// ---------------------------------------------------------------------------
121121+// Part-file verification (client-side digest + size check)
122122+// ---------------------------------------------------------------------------
123123+124124+/// Errors from [`verify_part_file`].
125125+#[derive(Debug, thiserror::Error)]
126126+pub enum VerifyError {
127127+ #[error("part file size mismatch: expected {expected}, got {actual}")]
128128+ Size { expected: i64, actual: usize },
129129+ #[error("part file digest mismatch")]
130130+ Digest,
131131+}
132132+133133+/// Verify a reassembled part file's size and (optional) sha256 digest.
134134+///
135135+/// Called on every consumer path (viewer mesh-load, direct download) after the
136136+/// streamed bytes are collected. Per-chunk CID verifies each piece on the PDS
137137+/// side; this full-file check catches assembly errors (reordering, swaps,
138138+/// drops) that per-chunk CID cannot.
139139+pub fn verify_part_file(bytes: &[u8], size: i64, digest: Option<&[u8]>) -> Result<(), VerifyError> {
140140+ if bytes.len() as i64 != size {
141141+ return Err(VerifyError::Size {
142142+ expected: size,
143143+ actual: bytes.len(),
144144+ });
145145+ }
146146+ if let Some(expected) = digest {
147147+ let actual = sha2::Sha256::digest(bytes);
148148+ if actual[..] != *expected {
149149+ return Err(VerifyError::Digest);
150150+ }
151151+ }
152152+ Ok(())
153153+}
154154+155155+// ---------------------------------------------------------------------------
120156// Renderer command (main → worker)
121157// ---------------------------------------------------------------------------
122158···134170 LoadMesh {
135171 format: MeshFormat,
136172 primary_url: String,
173173+ expected_size: Option<i64>,
174174+ expected_digest: Option<Vec<u8>>,
137175 },
138176 /// Render a mesh URL to a PNG preview image. This is intentionally handled by
139177 /// the worker so the Dioxus app does not depend on mesh parsing or `three-d`.
···244282 use super::*;
245283246284 #[test]
285285+ fn verify_part_file_matching_size_and_digest_is_ok() {
286286+ let data = b"hello world";
287287+ let digest = sha2::Sha256::digest(data);
288288+ assert!(verify_part_file(data, data.len() as i64, Some(&digest)).is_ok());
289289+ }
290290+291291+ #[test]
292292+ fn verify_part_file_size_mismatch_is_err() {
293293+ let data = b"hello world";
294294+ let digest = sha2::Sha256::digest(data);
295295+ let err = verify_part_file(data, 999, Some(&digest)).unwrap_err();
296296+ assert!(matches!(
297297+ err,
298298+ VerifyError::Size {
299299+ expected: 999,
300300+ actual: 11
301301+ }
302302+ ));
303303+ }
304304+305305+ #[test]
306306+ fn verify_part_file_digest_mismatch_is_err() {
307307+ let data = b"hello world";
308308+ let wrong_digest = [0u8; 32];
309309+ let err = verify_part_file(data, data.len() as i64, Some(&wrong_digest)).unwrap_err();
310310+ assert!(matches!(err, VerifyError::Digest));
311311+ }
312312+313313+ #[test]
314314+ fn verify_part_file_no_digest_checks_size_only() {
315315+ let data = b"hello world";
316316+ assert!(verify_part_file(data, data.len() as i64, None).is_ok());
317317+ assert!(verify_part_file(data, 999, None).is_err());
318318+ }
319319+320320+ #[test]
247321 fn from_extension_maps_known_formats() {
248322 assert_eq!(
249323 MeshFormat::from_extension("cube.stl"),
···304378 RendererCommand::LoadMesh {
305379 format: MeshFormat::Stl,
306380 primary_url: "/models/cube.stl".into(),
381381+ expected_size: Some(4096),
382382+ expected_digest: Some(vec![0u8; 32]),
307383 },
308384 RendererCommand::LoadMesh {
309385 format: MeshFormat::Obj,
310386 primary_url: "/models/cube.obj".into(),
387387+ expected_size: None,
388388+ expected_digest: None,
311389 },
312390 RendererCommand::LoadMesh {
313391 format: MeshFormat::Gltf,
314392 primary_url: "/models/cube.glb".into(),
393393+ expected_size: Some(2048),
394394+ expected_digest: None,
315395 },
316396 RendererCommand::LoadMesh {
317397 format: MeshFormat::Threemf,
318398 primary_url: "/models/cube.3mf".into(),
399399+ expected_size: None,
400400+ expected_digest: Some(vec![1u8; 32]),
319401 },
320402 RendererCommand::RenderPreviewImage {
321403 request_id: 7,
+29-5
crates/polymodel-renderer-worker/src/worker.rs
···374374// LoadMesh (async)
375375// ---------------------------------------------------------------------------
376376377377-fn handle_load_mesh(state: &Rc<RefCell<WorkerState>>, format: MeshFormat, primary_url: String) {
377377+fn handle_load_mesh(
378378+ state: &Rc<RefCell<WorkerState>>,
379379+ format: MeshFormat,
380380+ primary_url: String,
381381+ expected_size: Option<i64>,
382382+ expected_digest: Option<Vec<u8>>,
383383+) {
378384 let epoch = {
379385 let mut st = state.borrow_mut();
380386 if st.disposed {
···386392387393 let state_clone = state.clone();
388394 spawn_local(async move {
389389- let result = load_mesh_async(&primary_url, format).await;
395395+ let result = load_mesh_async(&primary_url, format, expected_size, expected_digest).await;
390396391397 let mut st = match state_clone.try_borrow_mut() {
392398 Ok(st) => st,
···423429 });
424430}
425431426426-async fn load_mesh_async(primary_url: &str, format: MeshFormat) -> Result<ModelMesh, String> {
432432+async fn load_mesh_async(
433433+ primary_url: &str,
434434+ format: MeshFormat,
435435+ expected_size: Option<i64>,
436436+ expected_digest: Option<Vec<u8>>,
437437+) -> Result<ModelMesh, String> {
427438 let primary_bytes = fetch_bytes(primary_url)
428439 .await
429440 .map_err(|e| format!("fetch primary: {e}"))?;
441441+442442+ if expected_size.is_some() || expected_digest.is_some() {
443443+ let size = expected_size.unwrap_or(primary_bytes.len() as i64);
444444+ if let Err(e) = polymodel_renderer_protocol::verify_part_file(
445445+ &primary_bytes,
446446+ size,
447447+ expected_digest.as_deref(),
448448+ ) {
449449+ return Err(format!("part file verification failed: {e}"));
450450+ }
451451+ }
430452431453 let companions = if format == MeshFormat::Gltf {
432454 let uris = discover_companion_uris(&primary_bytes);
···478500 let scope = state.borrow().scope.clone();
479501 spawn_local(async move {
480502 let result = async {
481481- let model = load_mesh_async(&primary_url, format).await?;
503503+ let model = load_mesh_async(&primary_url, format, None, None).await?;
482504 render_preview_png(&model, width.max(1), height.max(1)).await
483505 }
484506 .await;
···613635 RendererCommand::LoadMesh {
614636 format,
615637 primary_url,
638638+ expected_size,
639639+ expected_digest,
616640 } => {
617641 drop(st);
618618- handle_load_mesh(state, format, primary_url);
642642+ handle_load_mesh(state, format, primary_url, expected_size, expected_digest);
619643 }
620644 RendererCommand::RenderPreviewImage {
621645 request_id,
+160-75
src/appview/downloads.rs
···11+//! `space.polymodel.library.getPartFile` — reassemble a part's chunked geometry
22+//! file and stream it to the client.
33+//!
44+//! The handler returns `Result<Response, AppError>`, not `XrpcResponse<_>`, so
55+//! it can set `Content-Type` and `Content-Disposition` on the success body.
66+//! `XrpcResponse` hardcodes `Content-Type: */*` and has no disposition hook.
77+//! The error axis still goes through `AppError` → `GenericXrpcErrorResponse`
88+//! like every other handler.
99+//!
1010+//! **Streaming model.** Each chunk's blob is fetched from the actor's PDS via
1111+//! `com.atproto.sync.getBlob` and piped straight into the response body in
1212+//! offset order — the server never buffers the whole file (or a whole chunk).
1313+//! Chunk 0 is prefetched before the `Response` is returned so a first-fetch
1414+//! PDS failure maps to a clean 500. From chunk 1 onward a fetch failure or
1515+//! truncation surfaces as a broken body (headers are already committed); the
1616+//! client catches it via `file.size` + `file.digest` (see
1717+//! `polymodel_renderer_protocol::verify_part_file`).
1818+119use axum::body::Body;
22-use axum::extract::{Path, State};
2020+use axum::extract::State;
321use axum::http::{StatusCode, header};
422use axum::response::Response;
523use futures::TryStreamExt;
2424+use futures::stream::{self, StreamExt};
625use jacquard::identity::resolver::IdentityResolver;
77-use jacquard_common::types::string::{Cid, Did};
2626+use jacquard_axum::ExtractXrpc;
2727+use jacquard_common::types::string::Cid;
828use jacquard_common::xrpc::XrpcExt;
929use polymodel_api::com_atproto::sync::get_blob::GetBlob;
1030use polymodel_api::space_polymodel::library::File;
1111-use sha2::{Digest, Sha256};
3131+use polymodel_api::space_polymodel::library::get_part_file::GetPartFileRequest;
12321333use super::error::{AppResult, db, internal, invalid_request, not_found};
1434use super::state::AppState;
15351616-const MAX_DOWNLOAD_BYTES: i64 = 100 * 1024 * 1024;
1717-1818-pub(super) async fn download_part(
3636+/// Reassemble and stream a part's chunked geometry file.
3737+///
3838+/// Accepts an at-URI pointing at a `space.polymodel.library.part` record. The
3939+/// part's `File` manifest is loaded from the local SQLite projection, validated,
4040+/// then each chunk's blob is fetched from the actor's PDS and streamed in offset
4141+/// order. Digest and total-length verification are client-side.
4242+pub(super) async fn get_part_file(
1943 State(state): State<AppState>,
2020- Path((did, rkey)): Path<(String, String)>,
4444+ ExtractXrpc(req): ExtractXrpc<GetPartFileRequest>,
2145) -> AppResult<Response> {
2222- let did: Did = Did::new_owned(did).map_err(|e| invalid_request(format!("invalid DID: {e}")))?;
2323- let rkey = rkey.trim();
2424- if rkey.is_empty() || rkey.contains('/') {
2525- return Err(invalid_request("invalid part rkey"));
4646+ let path = req
4747+ .uri
4848+ .path()
4949+ .ok_or_else(|| invalid_request("missing at-uri path"))?;
5050+ if path.collection.as_str() != "space.polymodel.library.part" {
5151+ return Err(invalid_request("at-uri is not a part record"));
2652 }
5353+ let authority = req.uri.authority();
5454+ let rkey = path
5555+ .rkey
5656+ .ok_or_else(|| invalid_request("missing part rkey"))?;
5757+5858+ // Resolve authority to DID before the DB query — the local projection is
5959+ // keyed by DID, so a handle-authority at-uri would miss otherwise.
6060+ let did = super::actor::resolve_actor(&state, &authority.convert()).await?;
27612862 #[derive(sqlx::FromRow)]
2963 struct PartDownloadRow {
···3165 file_json: String,
3266 }
33673434- let row = db(sqlx::query_as!(
3535- PartDownloadRow,
3636- r#"SELECT name AS "name!", file_json AS "file_json!" FROM parts WHERE did = ? AND rkey = ?"#,
3737- did.as_ref(),
3838- rkey,
3939- )
4040- .fetch_optional(&state.pool)
4141- .await)?
6868+ let row = db(
6969+ sqlx::query_as!(
7070+ PartDownloadRow,
7171+ r#"SELECT name AS "name!", file_json AS "file_json!" FROM parts WHERE did = ? AND rkey = ?"#,
7272+ did.as_ref(),
7373+ rkey.as_ref(),
7474+ )
7575+ .fetch_optional(&state.pool)
7676+ .await,
7777+ )?
4278 .ok_or_else(not_found)?;
43794480 let file: File = serde_json::from_str(&row.file_json)
···6197 .ok_or_else(|| internal("DID document does not advertise a PDS endpoint"))?
6298 .to_owned();
63996464- let mut chunks = file.chunks;
6565- chunks.sort_by_key(|chunk| chunk.offset);
6666- let mut bytes = Vec::with_capacity(file.size as usize);
6767- for chunk in chunks {
6868- let cid: Cid = Cid::new_owned(chunk.blob.blob().r#ref.as_str().as_bytes())
6969- .map_err(|e| internal(format!("part blob CID is invalid: {e}")))?;
7070- let request = GetBlob {
100100+ let ordered_cids: Vec<Cid> = ordered_chunks(&file)
101101+ .iter()
102102+ .map(|chunk| {
103103+ Cid::new_owned(chunk.blob.blob().r#ref.as_str().as_bytes())
104104+ .map_err(|e| internal(format!("part blob CID is invalid: {e}")))
105105+ })
106106+ .collect::<AppResult<_>>()?;
107107+108108+ // Prefetch chunk 0 so a first-fetch PDS failure returns a clean 500 before
109109+ // the 200 + body is committed. The response body chains this already-open
110110+ // stream with lazy fetches for the remaining chunks.
111111+ let first_cid = ordered_cids[0].clone();
112112+ let first_response = state
113113+ .resolver
114114+ .xrpc(pds_endpoint.borrow())
115115+ .download(&GetBlob {
71116 did: did.clone(),
7272- cid,
7373- };
7474- let response = state
7575- .resolver
7676- .xrpc(pds_endpoint.borrow())
7777- .download(&request)
7878- .await
7979- .map_err(|e| internal(format!("blob fetch from {pds_endpoint} failed: {e}")))?;
8080- if !response.status().is_success() {
8181- return Err(internal(format!(
8282- "blob fetch from {pds_endpoint} failed with HTTP {}",
8383- response.status()
8484- )));
8585- }
8686- let chunk_start = bytes.len();
8787- let (_, body) = response.into_parts();
8888- let mut stream = body.into_inner();
8989- while let Some(chunk_bytes) = stream
9090- .try_next()
9191- .await
9292- .map_err(|e| internal(format!("blob stream from {pds_endpoint} failed: {e}")))?
9393- {
9494- bytes.extend_from_slice(&chunk_bytes);
9595- }
9696- if bytes.len() - chunk_start != chunk.size as usize {
9797- return Err(internal("blob chunk length did not match manifest"));
9898- }
117117+ cid: first_cid,
118118+ })
119119+ .await
120120+ .map_err(|e| internal(format!("blob fetch from {pds_endpoint} failed: {e}")))?;
121121+ if !first_response.status().is_success() {
122122+ return Err(internal(format!(
123123+ "blob fetch from {pds_endpoint} failed with HTTP {}",
124124+ first_response.status()
125125+ )));
99126 }
100100- if bytes.len() != file.size as usize {
101101- return Err(internal("downloaded file length did not match manifest"));
102102- }
103103- if let Some(digest) = file.digest.as_ref() {
104104- let actual = Sha256::digest(&bytes);
105105- if actual[..] != *digest.as_ref() {
106106- return Err(internal("downloaded file digest did not match manifest"));
107107- }
108108- }
127127+ let (_, first_body) = first_response.into_parts();
128128+ let first_stream = first_body.into_inner();
129129+130130+ let remaining_cids = ordered_cids[1..].to_vec();
131131+ let state_clone = state.clone();
132132+ let pds_clone = pds_endpoint.clone();
133133+ let did_clone = did.clone();
134134+135135+ type BoxError = Box<dyn std::error::Error + Send + Sync>;
136136+137137+ let first_stream = first_stream.map_err(|e| -> BoxError { Box::new(e) });
138138+139139+ let rest_stream = stream::iter(remaining_cids)
140140+ .then(move |cid| {
141141+ let state = state_clone.clone();
142142+ let pds = pds_clone.clone();
143143+ let did = did_clone.clone();
144144+ async move {
145145+ let response = state
146146+ .resolver
147147+ .xrpc(pds.borrow())
148148+ .download(&GetBlob {
149149+ did: did.clone(),
150150+ cid,
151151+ })
152152+ .await
153153+ .map_err(|e| -> BoxError {
154154+ Box::new(internal(format!("blob fetch from {pds} failed: {e}")))
155155+ })?;
156156+ if !response.status().is_success() {
157157+ return Err(Box::new(internal(format!(
158158+ "blob fetch from {pds} failed with HTTP {}",
159159+ response.status()
160160+ ))) as BoxError);
161161+ }
162162+ let (_, body) = response.into_parts();
163163+ let stream = body.into_inner().map_err(|e| -> BoxError { Box::new(e) });
164164+ Ok::<_, BoxError>(stream)
165165+ }
166166+ })
167167+ .try_flatten();
168168+169169+ let body_stream = first_stream.chain(rest_stream);
109170110171 let filename = download_filename(&row.name);
111172 let content_type = file.mime_type.to_string();
···116177 header::CONTENT_DISPOSITION,
117178 format!("attachment; filename=\"{filename}\""),
118179 )
119119- .body(Body::from(bytes))
180180+ .header(header::CONTENT_LENGTH, file.size)
181181+ .body(Body::from_stream(body_stream))
120182 .map_err(|e| internal(format!("download response build failed: {e}")))
121183}
122184185185+/// Return a file's chunks sorted by offset. The streaming response yields them
186186+/// in this order so the client reassembles the original file.
187187+fn ordered_chunks(file: &File) -> Vec<&polymodel_api::space_polymodel::library::Chunk> {
188188+ let mut chunks: Vec<_> = file.chunks.iter().collect();
189189+ chunks.sort_by_key(|chunk| chunk.offset);
190190+ chunks
191191+}
192192+193193+/// Cheap manifest preflight: offset contiguity, chunk count, per-chunk sanity.
194194+/// Does **not** cap total file size — the PDS bounds each blob, and a total cap
195195+/// breaks the large-model case chunking exists for.
123196fn validate_file_manifest(file: &File) -> AppResult<()> {
124124- if file.size < 0 || file.size > MAX_DOWNLOAD_BYTES {
125125- return Err(invalid_request(
126126- "part file size is outside the download limit",
127127- ));
197197+ if file.size < 0 {
198198+ return Err(invalid_request("part file size is negative"));
128199 }
129200 if file.chunks.len() > 4096 {
130201 return Err(invalid_request("part file has too many chunks"));
···139210 expected_offset = expected_offset
140211 .checked_add(chunk.size)
141212 .ok_or_else(|| invalid_request("part file chunk sizes overflow"))?;
142142- if expected_offset > MAX_DOWNLOAD_BYTES {
143143- return Err(invalid_request(
144144- "part file size is outside the download limit",
145145- ));
146146- }
147213 }
148214 if expected_offset != file.size {
149215 return Err(invalid_request("part file chunks do not match file size"));
···168234169235#[cfg(test)]
170236mod tests {
171171- use super::{download_filename, validate_file_manifest};
237237+ use super::{download_filename, ordered_chunks, validate_file_manifest};
172238 use jacquard_common::deps::smol_str::SmolStr;
173239 use jacquard_common::types::blob::{Blob, BlobRef, MimeType};
174240 use jacquard_common::types::cid::CidLink;
···203269 }
204270205271 #[test]
206206- fn file_manifest_validation_requires_contiguous_bounded_chunks() {
272272+ fn validate_file_manifest_requires_contiguous_bounded_chunks() {
207273 let chunk = Chunk::builder().blob(blob_ref()).offset(0).size(4).build();
208274 assert!(validate_file_manifest(&file(vec![chunk.clone()], 4)).is_ok());
209275210276 let gapped = Chunk::builder().blob(blob_ref()).offset(2).size(4).build();
211277 assert!(validate_file_manifest(&file(vec![gapped], 6)).is_err());
212212- assert!(validate_file_manifest(&file(vec![chunk], 101 * 1024 * 1024)).is_err());
278278+ }
279279+280280+ #[test]
281281+ fn validate_file_manifest_rejects_too_many_chunks() {
282282+ let chunk = Chunk::builder().blob(blob_ref()).offset(0).size(4).build();
283283+ let mut chunks = vec![chunk; 4097];
284284+ for (i, chunk) in chunks.iter_mut().enumerate() {
285285+ chunk.offset = i as i64;
286286+ }
287287+ assert!(validate_file_manifest(&file(chunks, 4097 * 4)).is_err());
288288+ }
289289+290290+ #[test]
291291+ fn ordered_chunks_sorts_by_offset() {
292292+ let chunk_b = Chunk::builder().blob(blob_ref()).offset(4).size(4).build();
293293+ let chunk_a = Chunk::builder().blob(blob_ref()).offset(0).size(4).build();
294294+ let file = file(vec![chunk_b, chunk_a], 8);
295295+ let ordered = ordered_chunks(&file);
296296+ assert_eq!(ordered[0].offset, 0);
297297+ assert_eq!(ordered[1].offset, 4);
213298 }
214299}
+14-6
src/appview/error.rs
···11//! Typed appview error type.
22//!
33-//! Handlers return `Result<XrpcResponse<_>, AppError>`. `AppError` is a small
44-//! typed enum (not a string bag): each variant maps to a fixed HTTP status and
55-//! XRPC error code at the [`IntoResponse`] boundary, so the error *code* is a
66-//! compile-time constant and only the human message is a `String`. Internal
77-//! details are logged; the response body never carries secrets or raw errors.
33+//! Handlers return `Result<XrpcResponse<_>, AppError>` (or `Result<Response,
44+//! AppError>` for `*/*` binary endpoints that need custom headers like
55+//! `Content-Disposition`). `AppError` is a small typed enum (not a string
66+//! bag): each variant maps to a fixed HTTP status and XRPC error code at the
77+//! [`IntoResponse`] boundary, so the error *code* is a compile-time constant
88+//! and only the human message is a `String`. Internal details are logged; the
99+//! response body never carries secrets or raw errors. `AppError` implements
1010+//! `std::error::Error` so it can be used as a stream error type for streaming
1111+//! response bodies.
812913use axum::http::StatusCode;
1014use axum::response::{IntoResponse, Response};
1115use jacquard_axum::GenericXrpcErrorResponse;
12161317/// Appview handler error. Maps onto the XRPC error response shape.
1414-#[derive(Debug)]
1818+#[derive(Debug, thiserror::Error)]
1519pub(crate) enum AppError {
1620 /// 401 `AuthenticationRequired`: strict OAuth extraction failed or an
1721 /// authenticated PDS operation reported an auth failure.
2222+ #[error("AuthenticationRequired: {0}")]
1823 Unauthorized(String),
1924 /// 400 `InvalidRequest`: bad parameters (bad at-uri, unknown algorithm,
2025 /// unparseable cursor, unresolvable handle).
2626+ #[error("InvalidRequest: {0}")]
2127 InvalidRequest(String),
2228 /// 404 `RecordNotFound`: the requested record does not exist in the index.
2929+ #[error("RecordNotFound")]
2330 NotFound,
2431 /// 500 `InternalServerError`: unexpected failure (database, record decode,
2532 /// upstream bsky fetch). The message is safe to expose; details are logged.
3333+ #[error("InternalServerError: {0}")]
2634 Internal(String),
2735}
2836
+5-7
src/appview/mod.rs
···4646 },
4747 library::{
4848 delete_thing::DeleteThingRequest, get_author_things::GetAuthorThingsRequest,
4949- get_feed::GetFeedRequest, get_model::GetModelRequest, get_thing::GetThingRequest,
5050- publish_thing::PublishThingRequest, search_things::SearchThingsRequest,
5151- stage_file::StageFileRequest, update_thing::UpdateThingRequest,
4949+ get_feed::GetFeedRequest, get_model::GetModelRequest, get_part_file::GetPartFileRequest,
5050+ get_thing::GetThingRequest, publish_thing::PublishThingRequest,
5151+ search_things::SearchThingsRequest, stage_file::StageFileRequest,
5252+ update_thing::UpdateThingRequest,
5253 },
5354};
5455···120121 GetSessionRequest::PATH,
121122 axum::routing::get(actor::get_session),
122123 )
123123- .route(
124124- "/app/parts/{did}/{rkey}/download",
125125- axum::routing::get(downloads::download_part),
126126- )
124124+ .merge(GetPartFileRequest::into_router(downloads::get_part_file))
127125 // PM-43 app-internal draft store + image upload. Not federated lexicons,
128126 // so these use a plain `/app/*` namespace (cookie-authenticated,
129127 // same-origin) rather than `/xrpc/space.polymodel.*`.
+95
src/appview/tests.rs
···14521452 .unwrap();
14531453 assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
14541454}
14551455+14561456+#[tokio::test]
14571457+async fn get_part_file_missing_uri_is_bad_request() {
14581458+ let state = state().await;
14591459+ let app = super::router().with_state(state);
14601460+ let resp = app
14611461+ .oneshot(
14621462+ Request::builder()
14631463+ .uri("/xrpc/space.polymodel.library.getPartFile")
14641464+ .body(Body::empty())
14651465+ .unwrap(),
14661466+ )
14671467+ .await
14681468+ .unwrap();
14691469+ assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
14701470+ let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
14711471+ .await
14721472+ .unwrap();
14731473+ let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
14741474+ assert_eq!(json["error"], "InvalidRequest");
14751475+}
14761476+14771477+#[tokio::test]
14781478+async fn get_part_file_malformed_uri_is_bad_request() {
14791479+ let state = state().await;
14801480+ let app = super::router().with_state(state);
14811481+ let resp = app
14821482+ .oneshot(
14831483+ Request::builder()
14841484+ .uri("/xrpc/space.polymodel.library.getPartFile?uri=notauri")
14851485+ .body(Body::empty())
14861486+ .unwrap(),
14871487+ )
14881488+ .await
14891489+ .unwrap();
14901490+ assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
14911491+}
14921492+14931493+#[tokio::test]
14941494+async fn get_part_file_wrong_collection_is_bad_request() {
14951495+ let state = state().await;
14961496+ let app = super::router().with_state(state);
14971497+ let resp = app
14981498+ .oneshot(
14991499+ Request::builder()
15001500+ .uri("/xrpc/space.polymodel.library.getPartFile?uri=at%3A%2F%2Fdid%3Aplc%3Ax%2Fspace.polymodel.library.thing%2Ft1")
15011501+ .body(Body::empty())
15021502+ .unwrap(),
15031503+ )
15041504+ .await
15051505+ .unwrap();
15061506+ assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
15071507+ let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
15081508+ .await
15091509+ .unwrap();
15101510+ let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
15111511+ assert_eq!(json["error"], "InvalidRequest");
15121512+}
15131513+15141514+#[tokio::test]
15151515+async fn get_part_file_missing_part_is_not_found() {
15161516+ let state = state().await;
15171517+ let app = super::router().with_state(state);
15181518+ let resp = app
15191519+ .oneshot(
15201520+ Request::builder()
15211521+ .uri("/xrpc/space.polymodel.library.getPartFile?uri=at%3A%2F%2Fdid%3Aplc%3Ax%2Fspace.polymodel.library.part%2Fnone")
15221522+ .body(Body::empty())
15231523+ .unwrap(),
15241524+ )
15251525+ .await
15261526+ .unwrap();
15271527+ assert_eq!(resp.status(), StatusCode::NOT_FOUND);
15281528+ let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
15291529+ .await
15301530+ .unwrap();
15311531+ let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
15321532+ assert_eq!(json["error"], "RecordNotFound");
15331533+}
15341534+15351535+#[tokio::test]
15361536+async fn app_parts_download_route_is_gone() {
15371537+ let state = state().await;
15381538+ let app = super::router().with_state(state);
15391539+ let resp = app
15401540+ .oneshot(
15411541+ Request::builder()
15421542+ .uri("/app/parts/did:plc:x/abc/download")
15431543+ .body(Body::empty())
15441544+ .unwrap(),
15451545+ )
15461546+ .await
15471547+ .unwrap();
15481548+ assert_eq!(resp.status(), StatusCode::NOT_FOUND);
15491549+}