From 5f8df1da69f1970b49936023a84bdd3dc80da203 Mon Sep 17 00:00:00 2001 From: Orual Date: Fri, 7 Aug 2026 23:04:21 -0400 Subject: [PATCH] PM-83: harden generated preview lifecycle Epic: PM-86 Task: PM-83 --- crates/polymodel-renderer-protocol/src/lib.rs | 120 ++- src/appview/drafts.rs | 42 +- src/appview/ldraw.rs | 78 ++ src/appview/mod.rs | 4 + src/appview/writes.rs | 6 +- src/publish.rs | 730 ++++++++++-------- src/publish/draft.rs | 257 +++--- src/publish/draft_client.rs | 11 + src/viewer.rs | 7 +- 9 files changed, 811 insertions(+), 444 deletions(-) diff --git a/crates/polymodel-renderer-protocol/src/lib.rs b/crates/polymodel-renderer-protocol/src/lib.rs index 458b212..943ab99 100644 --- a/crates/polymodel-renderer-protocol/src/lib.rs +++ b/crates/polymodel-renderer-protocol/src/lib.rs @@ -321,30 +321,20 @@ pub mod format_registry { return Evidence::OverBound; } let mut candidates = Vec::new(); - if bytes.starts_with(b"glTF") { + if recognizes_glb(bytes) { candidates.push(FormatId::Gltf); } - if bytes.starts_with(b"ISO-10303-21;") { + if recognizes_step(bytes) { candidates.push(FormatId::Step); } - if bytes.starts_with(b"PK") - && (bytes - .windows(b"[Content_Types].xml".len()) - .any(|w| w == b"[Content_Types].xml") - || bytes - .windows(b"3D/3dmodel.model".len()) - .any(|w| w == b"3D/3dmodel.model")) - { + if recognizes_threemf(bytes) { candidates.push(FormatId::Threemf); } if let Ok(text) = core::str::from_utf8(bytes) { - if text.contains("\"asset\"") && text.contains("\"version\"") { + if recognizes_gltf_json(text) { candidates.push(FormatId::Gltf); } - if text - .lines() - .any(|line| line.trim_start().starts_with("0 FILE ")) - { + if recognizes_ldraw(text) { candidates.push(FormatId::Ldraw); } } @@ -357,6 +347,55 @@ pub mod format_registry { } } + fn recognizes_glb(bytes: &[u8]) -> bool { + let Some(header) = bytes.get(..12) else { + return false; + }; + header.starts_with(b"glTF") + && u32::from_le_bytes(header[4..8].try_into().unwrap_or_default()) == 2 + && usize::try_from(u32::from_le_bytes( + header[8..12].try_into().unwrap_or_default(), + )) + .is_ok_and(|length| length == bytes.len()) + } + + fn recognizes_step(bytes: &[u8]) -> bool { + bytes.starts_with(b"ISO-10303-21;") + && bytes + .strip_suffix(b"END-ISO-10303-21;") + .is_some_and(|body| body.windows(b"DATA;".len()).any(|part| part == b"DATA;")) + } + + fn recognizes_threemf(bytes: &[u8]) -> bool { + matches!( + bytes.get(..4), + Some(b"PK\x03\x04" | b"PK\x05\x06" | b"PK\x07\x08") + ) && bytes + .windows(b"[Content_Types].xml".len()) + .any(|part| part == b"[Content_Types].xml") + && bytes + .windows(b"3D/3dmodel.model".len()) + .any(|part| part == b"3D/3dmodel.model") + } + + fn recognizes_gltf_json(text: &str) -> bool { + let compact: String = text + .chars() + .filter(|character| !character.is_whitespace()) + .collect(); + compact.starts_with('{') + && compact.ends_with('}') + && compact.contains("\"asset\":{") + && (compact.contains("\"version\":\"2.0\"") || compact.contains("\"version\":\"1.0\"")) + } + + fn recognizes_ldraw(text: &str) -> bool { + text.lines().any(|line| { + let mut fields = line.split_ascii_whitespace(); + matches!((fields.next(), fields.next(), fields.next()), (Some("0"), Some("FILE"), Some(name)) if !name.is_empty()) + }) + } + #[cfg(test)] mod tests { use super::*; @@ -496,20 +535,18 @@ pub mod format_registry { classify(Some(GENERIC_MIME), Some("x.dat"), &[]), Classification::Known(FormatId::Ldraw) ); + let glb = b"glTF\x02\0\0\0\x0c\0\0\0"; assert_eq!( - classify(None, None, b"glTF\x02\0\0\0"), + classify(None, None, glb), Classification::Known(FormatId::Gltf) ); - assert_eq!( - canonical_mime_for_evidence(b"glTF\x02\0\0\0"), - Some("model/gltf-binary") - ); + assert_eq!(canonical_mime_for_evidence(glb), Some("model/gltf-binary")); assert_eq!( canonical_mime_for_evidence(br#"{"asset":{"version":"2.0"}}"#), Some("model/gltf+json") ); assert_eq!( - recognize_evidence(b"PK\x03\x04[Content_Types].xml"), + recognize_evidence(b"PK\x03\x04[Content_Types].xml 3D/3dmodel.model"), Evidence::One(FormatId::Threemf) ); assert_eq!( @@ -529,9 +566,46 @@ pub mod format_registry { classify( None, None, - b"0 FILE main.dat\n{\"asset\":{\"version\":\"2.0\"}}" + b"0 FILE main.dat\n{\"asset\":{\"version\":\"2.0\"}}}" ), - Classification::Ambiguous + Classification::Known(FormatId::Ldraw) + ); + } + + #[test] + fn evidence_recognizers_reject_near_misses() { + assert_eq!( + recognize_evidence(b"glTF\x02\0\0\0\xff\0\0\0"), + Evidence::None + ); + assert_eq!( + recognize_evidence(b"ISO-10303-21;\nDATA;\n"), + Evidence::None + ); + assert_eq!( + recognize_evidence(b"PK\x03\x04[Content_Types].xml"), + Evidence::None + ); + assert_eq!( + recognize_evidence(b"prefix 3D/3dmodel.model [Content_Types].xml"), + Evidence::None + ); + assert_eq!( + recognize_evidence(br#"{"note":"asset version 2.0"}"#), + Evidence::None + ); + assert_eq!(recognize_evidence(b"0 FILE\n"), Evidence::None); + } + + #[test] + fn evidence_recognizers_accept_strict_minimal_shapes() { + assert_eq!( + recognize_evidence(b"ISO-10303-21;\nDATA;\nEND-ISO-10303-21;"), + Evidence::One(FormatId::Step) + ); + assert_eq!( + recognize_evidence(b" 0 FILE main.dat\n"), + Evidence::One(FormatId::Ldraw) ); } } diff --git a/src/appview/drafts.rs b/src/appview/drafts.rs index 33c2099..4a75531 100644 --- a/src/appview/drafts.rs +++ b/src/appview/drafts.rs @@ -21,7 +21,7 @@ use axum::Json; use axum::body::Body; use axum::extract::{Path, State}; -use axum::http::HeaderMap; +use axum::http::{HeaderMap, HeaderValue, Response}; use jacquard::client::Agent; use jacquard_axum::oauth::ExtractOAuthSession; use jacquard_common::types::blob::BlobRef; @@ -252,6 +252,46 @@ pub(super) async fn publish_draft( Ok(Json(output)) } +pub(super) async fn get_staged_resource( + State(state): State, + ExtractOAuthSession(session): ExtractSession, + Path(upload_id): Path, +) -> AppResult> { + let agent = Agent::from(session); + let actor = authenticated_did(&agent).await?; + let file = super::writes::load_upload_file(&state, &actor, &upload_id).await?; + let size = u64::try_from(file.size) + .map_err(|_| invalid_request("staged resource has negative length"))?; + let digest = file + .digest + .as_deref() + .ok_or_else(|| invalid_request("staged resource has no digest"))?; + if size == 0 || size > crate::publish::draft::MAX_PREVIEW_RESOURCE_BYTES || digest.len() != 32 { + return Err(invalid_request("staged resource exceeds preview bounds")); + } + super::downloads::validate_file_manifest(&file)?; + let cids = super::downloads::ordered_chunks(&file) + .iter() + .map(|chunk| chunk.blob.blob().r#ref.as_str().to_owned()) + .collect::>(); + let bytes = super::ldraw::fetch_staged_bytes( + &state, + &actor, + &cids, + size, + digest, + crate::publish::draft::MAX_PREVIEW_RESOURCE_BYTES, + ) + .await?; + let mut response = Response::new(Body::from(bytes)); + response.headers_mut().insert( + axum::http::header::CONTENT_TYPE, + HeaderValue::from_str(file.mime_type.as_str()) + .map_err(|_| invalid_request("invalid staged resource MIME"))?, + ); + Ok(response) +} + pub(super) async fn upload_image( State(state): State, ExtractOAuthSession(session): ExtractSession, diff --git a/src/appview/ldraw.rs b/src/appview/ldraw.rs index 23c472d..d682b0e 100644 --- a/src/appview/ldraw.rs +++ b/src/appview/ldraw.rs @@ -585,6 +585,84 @@ async fn fetch_verified_bytes( Ok(bytes) } +pub(super) async fn fetch_staged_bytes( + state: &AppState, + did: &Did, + blob_cids: &[String], + expected_length: u64, + expected_sha256: &[u8], + max_bytes: u64, +) -> AppResult> { + if expected_length == 0 + || expected_length > max_bytes + || expected_length > MAX_MODEL_RESOURCE_BYTES + || expected_sha256.len() != 32 + { + return Err(invalid_request("staged preview byte admission failed")); + } + let did_doc_response = state + .resolver + .resolve_did_doc(did) + .await + .map_err(|error| internal(format!("DID document resolution failed: {error}")))?; + let did_doc = did_doc_response + .parse() + .map_err(|error| internal(format!("DID document parse failed: {error}")))?; + let endpoint = did_doc + .pds_endpoint() + .ok_or_else(|| internal("DID document does not advertise a PDS endpoint"))? + .to_owned(); + let mut bytes = Vec::with_capacity( + usize::try_from(expected_length) + .map_err(|_| invalid_request("staged preview byte length is too large"))?, + ); + for value in blob_cids { + let cid = Cid::new_owned(value.as_bytes()) + .map_err(|error| internal(format!("invalid staged blob CID: {error}")))?; + let response = state + .resolver + .xrpc(endpoint.borrow()) + .download(&GetBlob { + did: did.clone(), + cid, + }) + .await + .map_err(|error| internal(format!("blob fetch failed: {error}")))?; + if !response.status().is_success() { + return Err(internal(format!( + "blob fetch failed with HTTP {}", + response.status() + ))); + } + let mut stream = response.into_parts().1.into_inner(); + let mut chunk = Vec::new(); + while let Some(part) = stream + .try_next() + .await + .map_err(|error| internal(format!("blob stream failed: {error}")))? + { + if bytes + .len() + .saturating_add(chunk.len()) + .saturating_add(part.len()) + > expected_length as usize + { + return Err(invalid_request("staged preview byte admission exceeded")); + } + chunk.extend_from_slice(&part); + } + verify_chunk_cid(value, &chunk) + .map_err(|error| invalid_request(format!("chunk integrity check failed: {error}")))?; + bytes.extend_from_slice(&chunk); + } + if bytes.len() as u64 != expected_length || &Sha256::digest(&bytes)[..] != expected_sha256 { + return Err(invalid_request( + "staged preview integrity verification failed", + )); + } + Ok(bytes) +} + fn verify_chunk_cid(value: &str, bytes: &[u8]) -> Result<(), String> { let cid = cid::Cid::try_from(value).map_err(|error| format!("invalid CID: {error}"))?; if cid.hash().code() != 0x12 { diff --git a/src/appview/mod.rs b/src/appview/mod.rs index 8c3895b..ce8d7f2 100644 --- a/src/appview/mod.rs +++ b/src/appview/mod.rs @@ -156,6 +156,10 @@ pub fn router() -> Router { axum::routing::post(drafts::publish_draft), ) .route("/app/images", axum::routing::post(drafts::upload_image)) + .route( + "/app/staged/{upload_id}", + axum::routing::get(drafts::get_staged_resource), + ) // Per-request timing: logs total server-side handling time for every // appview request, *including* the OAuth session-restore extractor that // runs before each handler body. Use this to localize latency (server diff --git a/src/appview/writes.rs b/src/appview/writes.rs index ce9e98d..8685861 100644 --- a/src/appview/writes.rs +++ b/src/appview/writes.rs @@ -1522,7 +1522,11 @@ async fn persist_upload( Ok(()) } -async fn load_upload_file(state: &AppState, actor: &Did, upload_id: &str) -> AppResult { +pub(super) async fn load_upload_file( + state: &AppState, + actor: &Did, + upload_id: &str, +) -> AppResult { #[derive(sqlx::FromRow)] struct Row { file_json: String, diff --git a/src/publish.rs b/src/publish.rs index 31bb2ac..da4e4d8 100644 --- a/src/publish.rs +++ b/src/publish.rs @@ -19,13 +19,16 @@ use crate::Route; use crate::client::PolymodelClient; use crate::session::SessionIdentity; use draft::{ - Bbox, DraftBundlePlan, DraftModelInput, DraftPartInput, DraftStagedResource, DraftSummary, - DraftThingInput, ensure_default_model_for_upload, parse_thing_token, plan_draft_ldraw_bundle, + AcquiredDraftResource, Bbox, DraftBundlePlan, DraftModelInput, DraftPartInput, + DraftStagedResource, DraftSummary, DraftThingInput, GeneratedPreview, + MAX_PREVIEW_AGGREGATE_BYTES, MAX_PREVIEW_RESOURCE_BYTES, MAX_PREVIEW_RESOURCES, + ensure_default_model_for_upload, parse_thing_token, plan_draft_ldraw_bundle, }; use jacquard_common::deps::smol_str::SmolStr; use polymodel_api::com_atproto::repo::strong_ref::StrongRef; use polymodel_api::space_polymodel::library::Image; use polymodel_renderer_protocol::format_registry; +use sha2::Digest; use state::PublishPhase; pub(crate) mod draft; @@ -178,9 +181,9 @@ struct Flow { save_in_flight: Signal, /// The mounted name input element, focused once after the first upload. name_element: Signal>>, - /// Monotonic draft-local preview generation. A completion may install only - /// when it still names the current staged resource set. - preview_generation: Signal, + /// Current preview identity per stable model. Independent models do not + /// cancel each other; stale completions must match the complete identity. + preview_generations: Signal>, } #[component] @@ -224,7 +227,7 @@ fn PublishFlow(resume: String) -> Element { let save_in_flight = use_signal(|| false); // The mounted name input element, focused once after the first upload. let name_element: Signal>> = use_signal(|| None); - let preview_generation = use_signal(|| 0_u64); + let preview_generations = use_signal(HashMap::new); let flow = Flow { client, @@ -246,7 +249,7 @@ fn PublishFlow(resume: String) -> Element { works_with_status, save_in_flight, name_element, - preview_generation, + preview_generations, }; // Resume a draft once on mount when `?draft=` is present; always load @@ -787,7 +790,7 @@ impl Flow { let size = part.file.as_ref().map(|f| human_size(f.size)).unwrap_or_default(); let format = part.format.clone().unwrap_or_default(); let pname_w = self.clone(); - let premove_w = self.clone(); + let mut premove_w = self.clone(); rsx! { li { key: "part-0-{pi}", class: "wizard-part", label { class: "field wizard-part-name", @@ -811,13 +814,25 @@ impl Flow { class: "button button-ghost wizard-remove", onclick: move |_| { let mut c = premove_w.composition; - let removed = c.write().models[0].parts.remove(pi); - if let Some(name) = removed.name.as_deref() { - c.write().models[0].staged_resources.retain(|resource| resource.name != name); + let mut guard = c.write(); + let removed_primary = { + let model = &mut guard.models[0]; + let removed = model.parts.remove(pi); + let removed_primary = removed.resource_id.as_deref() + == model.primary_resource_id.as_deref(); + if removed_primary { + model.primary_resource_id = None; + } + prune_orphan_resources(model); + removed_primary + }; + if removed_primary { + clear_generated_preview(&mut guard, 0); + } + if let Some(model_id) = guard.models[0].model_id.clone() { + premove_w.preview_generations.write().remove(&model_id); } - let mut generation = premove_w.preview_generation; - let next_generation = generation.read().saturating_add(1); - generation.set(next_generation); + drop(guard); let flow = premove_w.clone(); spawn(async move { flow.recompute_model_preview(0).await; }); premove_w.clone().mark_dirty_and_save(); @@ -989,7 +1004,7 @@ impl Flow { let print_settings = part.print_settings.clone().unwrap_or_default().join("\n"); let bbox = part.dimensions.clone(); let pname_w = self.clone(); - let premove_w = self.clone(); + let mut premove_w = self.clone(); let notes_w = self.clone(); let units_w = self.clone(); let prints_w = self.clone(); @@ -1017,13 +1032,25 @@ impl Flow { class: "button button-ghost wizard-remove", onclick: move |_| { let mut c = premove_w.composition; - let removed = c.write().models[mi].parts.remove(pi); - if let Some(name) = removed.name.as_deref() { - c.write().models[mi].staged_resources.retain(|resource| resource.name != name); + let mut guard = c.write(); + let removed_primary = { + let model = &mut guard.models[mi]; + let removed = model.parts.remove(pi); + let removed_primary = removed.resource_id.as_deref() + == model.primary_resource_id.as_deref(); + if removed_primary { + model.primary_resource_id = None; + } + prune_orphan_resources(model); + removed_primary + }; + if removed_primary { + clear_generated_preview(&mut guard, mi); + } + if let Some(model_id) = guard.models[mi].model_id.clone() { + premove_w.preview_generations.write().remove(&model_id); } - let mut generation = premove_w.preview_generation; - let next_generation = generation.read().saturating_add(1); - generation.set(next_generation); + drop(guard); let flow = premove_w.clone(); spawn(async move { flow.recompute_model_preview(mi).await; }); premove_w.clone().mark_dirty_and_save(); @@ -2031,9 +2058,23 @@ impl Flow { .await { Ok(staged) => { + let resource_id = staged.upload_id.to_string(); + let Some(resource) = staged_resource( + resource_id.clone(), + filename.clone(), + mime.clone(), + format.clone(), + &staged.file, + ) else { + upload.set(UploadStatus::Error(format!( + "Upload failed for {filename}: staged metadata is incomplete" + ))); + return; + }; let part = DraftPartInput { name: Some(filename.clone()), - upload_id: Some(staged.upload_id.to_string()), + resource_id: Some(resource_id.clone()), + upload_id: Some(resource_id.clone()), file: Some(staged.file), format: format.clone(), ..Default::default() @@ -2045,16 +2086,8 @@ impl Flow { let mut composition = self.composition; let mut g = composition.write(); if let Some(model) = g.models.get_mut(0) { - install_staged_resource( - model, - DraftStagedResource { - name: filename.clone(), - mime_type: mime.clone(), - bytes: bytes.clone(), - digest: None, - byte_length: bytes.len() as u64, - }, - ); + install_staged_resource(model, resource); + ensure_model_identity(model, &resource_id); model.parts.push(part); } drop(g); @@ -2117,9 +2150,23 @@ impl Flow { }; match draft_client::stage_file(&self.client, &filename, &mime, bytes.clone()).await { Ok(staged) => { + let resource_id = staged.upload_id.to_string(); + let Some(resource) = staged_resource( + resource_id.clone(), + filename.clone(), + mime.clone(), + format.clone(), + &staged.file, + ) else { + upload.set(UploadStatus::Error( + "Upload failed: staged metadata is incomplete".into(), + )); + return; + }; let part = DraftPartInput { name: Some(filename.clone()), - upload_id: Some(staged.upload_id.to_string()), + resource_id: Some(resource_id.clone()), + upload_id: Some(resource_id.clone()), file: Some(staged.file), format: format.clone(), ..Default::default() @@ -2128,16 +2175,8 @@ impl Flow { let mut composition = self.composition; let mut guard = composition.write(); if let Some(model) = guard.models.get_mut(model_index) { - install_staged_resource( - model, - DraftStagedResource { - name: filename.clone(), - mime_type: mime.clone(), - bytes: bytes.clone(), - digest: None, - byte_length: bytes.len() as u64, - }, - ); + install_staged_resource(model, resource); + ensure_model_identity(model, &resource_id); model.parts.push(part); } } @@ -2152,87 +2191,109 @@ impl Flow { } } - /// Recompute the generated preview from the complete staged resource set. - /// Missing companions are transient: the next staged-set mutation retries. - async fn recompute_model_preview(self, model_index: usize) { - let generation = { - let mut generation = self.preview_generation; - let next = generation.read().saturating_add(1); - generation.set(next); - next - }; - let (primary_name, staged) = { + async fn recompute_model_preview(mut self, model_index: usize) { + let draft_scope = self + .draft_id + .read() + .clone() + .unwrap_or_else(|| "unsaved".into()); + let (model_id, primary_id, primary_name, staged) = { let composition = self.composition.read(); let Some(model) = composition.models.get(model_index) else { return; }; - let primary = model.parts.iter().find_map(|part| { - part.name - .as_deref() - .filter(|name| supports_generated_preview(name)) - .map(str::to_owned) - }); - (primary, model.staged_resources.clone()) - }; - let Some(primary_name) = primary_name else { - return; - }; - let image = if format_registry::format_for_extension(&primary_name) - == Some(format_registry::FormatId::Ldraw) - { - let plan = plan_draft_ldraw_bundle(&primary_name, &staged); - let DraftBundlePlan::Complete(bundle) = plan else { - if let DraftBundlePlan::Incomplete(error) = plan { - tracing::debug!(%error, %primary_name, "generated preview pending staged companions"); - } + let (Some(model_id), Some(primary_id)) = + (model.model_id.clone(), model.primary_resource_id.clone()) + else { return; }; - generate_preview_bundle( - self.client.clone(), - primary_name.clone(), - bundle, - self.upload, - self.image_data_uris, - ) - .await - } else { - let Some(resource) = staged.iter().find(|resource| resource.name == primary_name) + let Some(primary) = model + .staged_resources + .iter() + .find(|resource| resource.resource_id == primary_id) else { return; }; - generate_preview( - self.client.clone(), - primary_name.clone(), - resource.bytes.clone(), - self.upload, - self.image_data_uris, + ( + model_id, + primary_id, + primary.source_name.clone(), + model.staged_resources.clone(), ) - .await }; - if *self.preview_generation.read() != generation { - tracing::debug!(%primary_name, "discarding stale generated preview"); + let Some(identity) = preview_identity(&draft_scope, &model_id, &primary_id, &staged) else { + self.clear_model_preview(model_index, &model_id); + return; + }; + self.preview_generations + .write() + .insert(model_id.clone(), identity.clone()); + let acquired = match acquire_preview_resources(&self.client, &staged).await { + Ok(resources) => resources, + Err(error) => { + tracing::debug!(%error, %model_id, "generated preview acquisition incomplete"); + self.clear_model_preview(model_index, &model_id); + return; + } + }; + if !self.preview_is_current(&model_id, &identity) { return; } - if let Some(image) = image { - let mut composition = self.composition; - let mut guard = composition.write(); - let Some(model) = guard.models.get_mut(model_index) else { + let png = if format_registry::format_for_extension(&primary_name) + == Some(format_registry::FormatId::Ldraw) + { + let DraftBundlePlan::Complete(bundle) = plan_draft_ldraw_bundle(&primary_id, &acquired) + else { + self.clear_model_preview(model_index, &model_id); return; }; - let Some(part_index) = model - .parts + render_preview_bundle(bundle).await + } else { + let Some(primary) = acquired .iter() - .position(|part| part.name.as_deref() == Some(primary_name.as_str())) + .find(|resource| resource.metadata.resource_id == primary_id) else { return; }; - let parts_before_push = part_index; - let mut part = model.parts.remove(part_index); - attach_generated_preview(&mut guard, model_index, parts_before_push, &mut part, image); - if let Some(model) = guard.models.get_mut(model_index) { - model.parts.insert(part_index, part); - } + render_preview(&primary_name, primary.bytes.clone()).await + }; + let Ok(png_bytes) = png else { + self.clear_model_preview(model_index, &model_id); + return; + }; + if !self.preview_is_current(&model_id, &identity) { + return; } + let Some(image) = upload_generated_preview( + self.client.clone(), + primary_name.clone(), + png_bytes, + self.upload, + self.image_data_uris, + ) + .await + else { + return; + }; + if !self.preview_is_current(&model_id, &identity) { + return; + } + let mut composition = self.composition; + let mut guard = composition.write(); + attach_generated_preview(&mut guard, model_index, &primary_id, image); + } + + fn preview_is_current(&self, model_id: &str, identity: &str) -> bool { + self.preview_generations + .read() + .get(model_id) + .is_some_and(|current| current == identity) + } + + fn clear_model_preview(&mut self, model_index: usize, model_id: &str) { + self.preview_generations.write().remove(model_id); + let mut composition = self.composition; + clear_generated_preview(&mut composition.write(), model_index); } /// Upload an image and attach it to the target's cover/previews, then save @@ -2449,11 +2510,6 @@ fn classify_geometry_upload( )) } -fn supports_generated_preview(name: &str) -> bool { - format_registry::format_for_extension(name) - .is_some_and(|format| format.capability().generated_preview) -} - fn extension(name: &str) -> Option { name.rsplit_once('.') .map(|(_, ext)| ext.to_ascii_lowercase()) @@ -2482,99 +2538,236 @@ fn human_size(bytes: i64) -> String { fn install_staged_resource(model: &mut DraftModelInput, resource: DraftStagedResource) { model .staged_resources - .retain(|existing| existing.name != resource.name); + .retain(|existing| existing.resource_id != resource.resource_id); model.staged_resources.push(resource); } -fn attach_preview(part: &mut DraftPartInput, image: Image) { - part.previews = Some(vec![image]); +fn prune_orphan_resources(model: &mut DraftModelInput) { + let referenced = model + .parts + .iter() + .filter_map(|part| part.resource_id.as_deref()) + .collect::>(); + model + .staged_resources + .retain(|resource| referenced.contains(resource.resource_id.as_str())); +} + +fn ensure_model_identity(model: &mut DraftModelInput, primary_resource_id: &str) { + if model.primary_resource_id.is_none() { + model.primary_resource_id = Some(primary_resource_id.to_owned()); + } + if model.model_id.is_none() { + model.model_id = Some(format!("model-{primary_resource_id}")); + } +} + +fn staged_resource( + upload_id: String, + source_name: String, + mime_type: String, + format: Option, + file: &polymodel_api::space_polymodel::library::File, +) -> Option { + let digest = file.digest.as_deref()?.to_vec(); + let byte_length = u64::try_from(file.size).ok()?; + (digest.len() == 32).then_some(DraftStagedResource { + resource_id: upload_id.clone(), + upload_id, + source_name, + mime_type, + digest, + byte_length, + format, + }) +} + +fn preview_identity( + draft_scope: &str, + model_id: &str, + primary_id: &str, + resources: &[DraftStagedResource], +) -> Option { + if resources.is_empty() || resources.len() > MAX_PREVIEW_RESOURCES { + return None; + } + let mut aggregate = 0_u64; + let mut entries = resources.iter().collect::>(); + entries.sort_by(|left, right| left.resource_id.cmp(&right.resource_id)); + let mut hasher = ::new(); + hasher.update(draft_scope.as_bytes()); + hasher.update(model_id.as_bytes()); + hasher.update(primary_id.as_bytes()); + for resource in entries { + if resource.digest.len() != 32 || resource.byte_length > MAX_PREVIEW_RESOURCE_BYTES { + return None; + } + aggregate = aggregate.checked_add(resource.byte_length)?; + if aggregate > MAX_PREVIEW_AGGREGATE_BYTES { + return None; + } + hasher.update(resource.resource_id.as_bytes()); + hasher.update(resource.source_name.as_bytes()); + hasher.update(resource.byte_length.to_le_bytes()); + hasher.update(&resource.digest); + } + Some( + hasher + .finalize() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect(), + ) +} + +#[cfg(all(target_family = "wasm", target_os = "unknown"))] +async fn acquire_preview_resources( + client: &PolymodelClient, + resources: &[DraftStagedResource], +) -> Result, String> { + if preview_identity("admission", "model", "primary", resources).is_none() { + return Err("preview resource bounds exceeded".into()); + } + let mut acquired = Vec::with_capacity(resources.len()); + for metadata in resources { + let bytes = draft_client::get_staged_resource(client, &metadata.upload_id) + .await + .map_err(|error| error.to_string())?; + if bytes.len() as u64 != metadata.byte_length + || &sha2::Sha256::digest(&bytes)[..] != metadata.digest.as_slice() + { + return Err("reacquired staged resource failed integrity check".into()); + } + acquired.push(AcquiredDraftResource { + metadata: metadata.clone(), + bytes, + }); + } + Ok(acquired) +} + +#[cfg(not(all(target_family = "wasm", target_os = "unknown")))] +async fn acquire_preview_resources( + _client: &PolymodelClient, + _resources: &[DraftStagedResource], +) -> Result, String> { + Err("preview acquisition is browser-only".into()) +} + +fn remove_image(list: &mut Option>, image: &Image) { + if let Some(images) = list { + images.retain(|candidate| candidate != image); + if images.is_empty() { + *list = None; + } + } +} + +fn clear_generated_preview(composition: &mut DraftThingInput, model_index: usize) { + let generated = composition + .models + .get(model_index) + .and_then(|model| model.generated_preview.as_ref()) + .map(|preview| preview.image.clone()); + let Some(image) = generated else { return }; + remove_image(&mut composition.previews, &image); + if composition + .cover + .as_ref() + .is_some_and(|cover| cover == &vec![image.clone()]) + { + composition.cover = None; + } + if let Some(model) = composition.models.get_mut(model_index) { + remove_image(&mut model.previews, &image); + if model + .cover + .as_ref() + .is_some_and(|cover| cover == &vec![image.clone()]) + { + model.cover = None; + } + model.generated_preview = None; + for part in &mut model.parts { + if part + .generated_preview + .as_ref() + .is_some_and(|preview| preview.image == image) + { + remove_image(&mut part.previews, &image); + part.generated_preview = None; + } + } + } } fn attach_generated_preview( composition: &mut DraftThingInput, model_index: usize, - parts_before_push: usize, - part: &mut DraftPartInput, + source_resource_id: &str, image: Image, ) { - maybe_seed_cover(composition, model_index, parts_before_push, &image); + clear_generated_preview(composition, model_index); + let seed_cover = model_index == 0 && composition.cover.as_ref().is_none_or(Vec::is_empty); + let provenance = GeneratedPreview { + source_resource_id: source_resource_id.to_owned(), + image: image.clone(), + }; composition .previews .get_or_insert_with(Vec::new) .push(image.clone()); + if seed_cover { + composition.cover = Some(vec![image.clone()]); + } if let Some(model) = composition.models.get_mut(model_index) { model .previews .get_or_insert_with(Vec::new) .push(image.clone()); - } - attach_preview(part, image); -} - -/// Seed the thing-level cover from the first file of the first model when no -/// cover exists. Only fires for `model_index == 0` when the model has zero -/// parts *before* pushing the new one (the first file overall) and cover is -/// empty. Non-empty cover is never overwritten. -fn maybe_seed_cover( - composition: &mut DraftThingInput, - model_index: usize, - parts_before_push: usize, - image: &Image, -) { - if model_index == 0 - && parts_before_push == 0 - && composition.cover.as_ref().is_none_or(Vec::is_empty) - { - composition.cover = Some(vec![image.clone()]); + model.generated_preview = Some(provenance.clone()); + if let Some(part) = model + .parts + .iter_mut() + .find(|part| part.resource_id.as_deref() == Some(source_resource_id)) + { + part.previews.get_or_insert_with(Vec::new).push(image); + part.generated_preview = Some(provenance); + } } } -/// Render a PNG preview from staged file bytes and upload it. Wasm-only: on -/// non-wasm targets this is a no-op stub returning `None`. #[cfg(all(target_family = "wasm", target_os = "unknown"))] -async fn generate_preview_bundle( - client: PolymodelClient, - filename: String, +async fn render_preview_bundle( bundle: polymodel_renderer_protocol::ModelLoadBundle, - mut upload: Signal, - mut image_data_uris: Signal>, -) -> Option { - let png_bytes = match renderer_preview::render_preview_bundle_png(bundle, 1024, 1024).await { - Ok(bytes) => bytes, - Err(err) => { - tracing::debug!(%err, "preview render pending or failed"); - return None; - } - }; - let alt = format!("Rendered preview of {filename}"); - upload.set(UploadStatus::Uploading(format!("preview of {filename}"))); - match draft_client::upload_image(&client, &alt, "image/png", png_bytes.clone()).await { - Ok(image) => { - let cid = image.image.blob().r#ref.as_str().to_string(); - image_data_uris - .write() - .insert(cid.into(), data_uri_from_bytes(&png_bytes, "image/png")); - Some(image) - } - Err(err) => { - tracing::warn!(%err, "preview upload failed for {filename}"); - upload.set(UploadStatus::Idle); - None - } - } +) -> Result, String> { + renderer_preview::render_preview_bundle_png(bundle, 1024, 1024).await } #[cfg(all(target_family = "wasm", target_os = "unknown"))] -async fn generate_preview( +async fn render_preview(filename: &str, bytes: Vec) -> Result, String> { + renderer_preview::render_preview_png(filename, bytes, 1024, 1024).await +} + +#[cfg(not(all(target_family = "wasm", target_os = "unknown")))] +async fn render_preview_bundle( + _bundle: polymodel_renderer_protocol::ModelLoadBundle, +) -> Result, String> { + Err("preview rendering is browser-only".into()) +} + +#[cfg(not(all(target_family = "wasm", target_os = "unknown")))] +async fn render_preview(_filename: &str, _bytes: Vec) -> Result, String> { + Err("preview rendering is browser-only".into()) +} + +async fn upload_generated_preview( client: PolymodelClient, filename: String, - bytes: Vec, + png_bytes: Vec, mut upload: Signal, mut image_data_uris: Signal>, ) -> Option { - let png_bytes = renderer_preview::render_preview_png(&filename, bytes, 1024, 1024) - .await - .ok()?; let alt = format!("Rendered preview of {filename}"); upload.set(UploadStatus::Uploading(format!("preview of {filename}"))); match draft_client::upload_image(&client, &alt, "image/png", png_bytes.clone()).await { @@ -2593,28 +2786,6 @@ async fn generate_preview( } } -#[cfg(not(all(target_family = "wasm", target_os = "unknown")))] -async fn generate_preview( - _client: PolymodelClient, - _filename: String, - _bytes: Vec, - _upload: Signal, - _image_data_uris: Signal>, -) -> Option { - None -} - -#[cfg(not(all(target_family = "wasm", target_os = "unknown")))] -async fn generate_preview_bundle( - _client: PolymodelClient, - _filename: String, - _bundle: polymodel_renderer_protocol::ModelLoadBundle, - _upload: Signal, - _image_data_uris: Signal>, -) -> Option { - None -} - #[cfg(all(target_family = "wasm", target_os = "unknown"))] mod renderer_preview { use polymodel_renderer_protocol::{ @@ -3014,10 +3185,6 @@ mod tests { use super::*; use std::str::FromStr; - fn alt_str(image: &Image) -> &str { - >::as_ref(&image.alt) - } - #[test] fn mime_and_format_from_extension() { assert_eq!(mime_from_filename("Tray.STL"), "model/stl"); @@ -3081,20 +3248,6 @@ mod tests { assert!(classify_geometry_upload(Some("text/plain"), "scene.glb", &[]).is_err()); } - #[test] - fn generated_preview_support_includes_renderer_formats_and_step() { - assert!(supports_generated_preview("part.stl")); - assert!(supports_generated_preview("part.obj")); - assert!(supports_generated_preview("part.glb")); - assert!(supports_generated_preview("part.step")); - assert!(supports_generated_preview("part.STP")); - assert!(supports_generated_preview("part.ldr")); - assert!(supports_generated_preview("part.mpd")); - assert!(supports_generated_preview("part.dat")); - assert!(!supports_generated_preview("part.3mf")); - assert!(!supports_generated_preview("notes.txt")); - } - #[test] fn human_size_scales_units() { assert_eq!(human_size(512), "512 B"); @@ -3126,127 +3279,58 @@ mod tests { } #[test] - fn attach_preview_sets_previews_when_empty() { - let mut part = DraftPartInput { - name: Some("Bracket".into()), - upload_id: Some("upload-1".into()), - ..Default::default() - }; - assert!(part.previews.is_none()); - attach_preview(&mut part, test_image("Rendered preview of Bracket.stl")); - assert_eq!(part.previews.as_ref().unwrap().len(), 1); - assert_eq!( - alt_str(&part.previews.as_ref().unwrap()[0]), - "Rendered preview of Bracket.stl" - ); - } - - #[test] - fn attach_preview_overwrites_existing_previews() { - let mut part = DraftPartInput { - name: Some("Bracket".into()), - previews: Some(vec![test_image("old user-uploaded preview")]), - ..Default::default() - }; - assert_eq!(part.previews.as_ref().unwrap().len(), 1); - attach_preview(&mut part, test_image("Rendered preview of Bracket.stl")); - assert_eq!(part.previews.as_ref().unwrap().len(), 1); - assert_eq!( - alt_str(&part.previews.as_ref().unwrap()[0]), - "Rendered preview of Bracket.stl" - ); - } - - #[test] - fn attach_generated_preview_sets_part_model_and_thing_preview() { - let mut composition = DraftThingInput { - models: vec![DraftModelInput::default()], - ..Default::default() - }; - let mut part = DraftPartInput::default(); - attach_generated_preview( - &mut composition, - 0, - 0, - &mut part, - test_image("Rendered preview of part.step"), - ); - assert_eq!(composition.cover.as_ref().unwrap().len(), 1); - assert_eq!(composition.previews.as_ref().unwrap().len(), 1); - assert_eq!(composition.models[0].previews.as_ref().unwrap().len(), 1); - assert_eq!(part.previews.as_ref().unwrap().len(), 1); - } - - #[test] - fn maybe_seed_cover_seeds_first_file_first_model() { - let mut composition = DraftThingInput::default(); - let image = test_image("Rendered preview of Tray.stl"); - maybe_seed_cover(&mut composition, 0, 0, &image); - assert_eq!(composition.cover.as_ref().unwrap().len(), 1); - assert_eq!( - alt_str(&composition.cover.as_ref().unwrap()[0]), - "Rendered preview of Tray.stl" - ); - } - - #[test] - fn maybe_seed_cover_does_not_seed_for_nonzero_model() { - let mut composition = DraftThingInput::default(); - let image = test_image("Rendered preview of Lid.stl"); - maybe_seed_cover(&mut composition, 1, 0, &image); - assert!(composition.cover.is_none()); - } - - #[test] - fn maybe_seed_cover_does_not_seed_when_parts_already_exist() { - let mut composition = DraftThingInput::default(); - let image = test_image("Rendered preview of second.stl"); - maybe_seed_cover(&mut composition, 0, 1, &image); - assert!(composition.cover.is_none()); - } - - #[test] - fn maybe_seed_cover_does_not_overwrite_nonempty_cover() { + fn generated_preview_replaces_its_slot_and_preserves_user_images() { + let user = test_image("user preview"); + let first = test_image("generated one"); + let second = test_image("generated two"); let mut composition = DraftThingInput { - cover: Some(vec![test_image("user cover")]), + cover: Some(vec![user.clone()]), + previews: Some(vec![user.clone()]), + models: vec![DraftModelInput { + previews: Some(vec![user.clone()]), + parts: vec![DraftPartInput { + resource_id: Some("resource-1".into()), + previews: Some(vec![user.clone()]), + ..Default::default() + }], + ..Default::default() + }], ..Default::default() }; - let image = test_image("Rendered preview of Tray.stl"); - maybe_seed_cover(&mut composition, 0, 0, &image); - assert_eq!(composition.cover.as_ref().unwrap().len(), 1); - assert_eq!( - alt_str(&composition.cover.as_ref().unwrap()[0]), - "user cover" - ); + attach_generated_preview(&mut composition, 0, "resource-1", first); + attach_generated_preview(&mut composition, 0, "resource-1", second.clone()); + for images in [ + composition.previews.as_ref().unwrap(), + composition.models[0].previews.as_ref().unwrap(), + composition.models[0].parts[0].previews.as_ref().unwrap(), + ] { + assert_eq!(images.len(), 2); + assert!(images.contains(&user)); + assert!(images.contains(&second)); + } + assert_eq!(composition.cover, Some(vec![user])); } #[test] - fn maybe_seed_cover_re_stage_after_delete_does_not_seed_over_user_cover() { - // Edge: model-0 parts deleted back to zero, but a user manually set a - // cover. The cover must NOT be overwritten by a new auto-preview. + fn clearing_generated_preview_removes_only_generated_cover_and_refs() { + let generated = test_image("generated"); + let user = test_image("user"); let mut composition = DraftThingInput { - cover: Some(vec![test_image("user cover")]), + models: vec![DraftModelInput { + parts: vec![DraftPartInput { + resource_id: Some("resource-1".into()), + previews: Some(vec![user.clone()]), + ..Default::default() + }], + ..Default::default() + }], ..Default::default() }; - let image = test_image("Rendered preview of re-staged.stl"); - maybe_seed_cover(&mut composition, 0, 0, &image); - assert_eq!( - alt_str(&composition.cover.as_ref().unwrap()[0]), - "user cover" - ); - } - - #[test] - fn maybe_seed_cover_re_stage_after_delete_seeds_when_both_empty() { - // Edge: model-0 parts deleted back to zero AND cover is empty. A new - // first file should re-seed the cover. - let mut composition = DraftThingInput::default(); - let image = test_image("Rendered preview of fresh.stl"); - maybe_seed_cover(&mut composition, 0, 0, &image); - assert_eq!(composition.cover.as_ref().unwrap().len(), 1); - assert_eq!( - alt_str(&composition.cover.as_ref().unwrap()[0]), - "Rendered preview of fresh.stl" - ); + attach_generated_preview(&mut composition, 0, "resource-1", generated); + clear_generated_preview(&mut composition, 0); + assert!(composition.cover.is_none()); + assert!(composition.previews.is_none()); + assert_eq!(composition.models[0].parts[0].previews, Some(vec![user])); + assert!(composition.models[0].generated_preview.is_none()); } } diff --git a/src/publish/draft.rs b/src/publish/draft.rs index 682061d..84bae37 100644 --- a/src/publish/draft.rs +++ b/src/publish/draft.rs @@ -101,38 +101,66 @@ pub struct DraftThingInput { pub models: Vec, } -/// Browser-local staged geometry retained with a draft so compound previews can -/// be recomputed after later companion uploads. The bytes are never assembled -/// into a publish record; `file` remains the server-owned publish manifest. +/// Metadata-only reference to owner-scoped geometry in `upload_staging`. +/// Geometry bytes are reacquired through the authenticated staging boundary and +/// never serialized into draft JSON. #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct DraftStagedResource { - pub name: String, + pub resource_id: String, + pub upload_id: String, + /// Immutable filename supplied when the source was staged. + pub source_name: String, pub mime_type: String, - pub bytes: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub digest: Option>, + pub digest: Vec, pub byte_length: u64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub format: Option, +} + +/// Ephemeral verified bytes paired with persisted staging metadata. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AcquiredDraftResource { + pub metadata: DraftStagedResource, + pub bytes: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct GeneratedPreview { + pub source_resource_id: String, + pub image: Image, } #[derive(Debug, Clone, PartialEq, Eq)] pub enum DraftBundlePlan { Complete(ModelLoadBundle), Incomplete(String), - Unsupported, } -/// Plan a draft-local LDraw bundle using the same path/root normalization that -/// the projected resolver uses. No incomplete plan is eligible for dispatch. +pub const MAX_PREVIEW_RESOURCES: usize = 64; +pub const MAX_PREVIEW_RESOURCE_BYTES: u64 = 32 * 1024 * 1024; +pub const MAX_PREVIEW_AGGREGATE_BYTES: u64 = 128 * 1024 * 1024; + +/// Plan a complete transitive draft-local LDraw bundle. Stable IDs choose the +/// primary; immutable source names participate only in canonical path resolution. pub fn plan_draft_ldraw_bundle( - primary_name: &str, - staged: &[DraftStagedResource], + primary_resource_id: &str, + staged: &[AcquiredDraftResource], ) -> DraftBundlePlan { - let Some(primary) = staged.iter().find(|resource| resource.name == primary_name) else { + if staged.is_empty() || staged.len() > MAX_PREVIEW_RESOURCES { + return DraftBundlePlan::Incomplete("preview resource count exceeds bounds".into()); + } + let mut aggregate = 0_u64; + let Some(primary) = staged + .iter() + .find(|resource| resource.metadata.resource_id == primary_resource_id) + else { return DraftBundlePlan::Incomplete("primary staged resource is missing".into()); }; + let primary_name = &primary.metadata.source_name; let primary_path = if primary_name.contains('/') { - primary_name.to_owned() + primary_name.clone() } else { format!("models/{primary_name}") }; @@ -140,70 +168,82 @@ pub fn plan_draft_ldraw_bundle( Ok(path) => path, Err(error) => return DraftBundlePlan::Incomplete(error.to_string()), }; - let mut resources = Vec::new(); - let mut by_path = std::collections::HashMap::new(); - for resource in staged { - let raw_path = if resource.name == primary_name { + let mut resources = Vec::with_capacity(staged.len()); + let mut acquired_by_path = std::collections::HashMap::new(); + for acquired in staged { + let metadata = &acquired.metadata; + if metadata.digest.len() != 32 + || metadata.byte_length != acquired.bytes.len() as u64 + || metadata.byte_length > MAX_PREVIEW_RESOURCE_BYTES + || metadata.digest.as_slice() != &sha2::Sha256::digest(&acquired.bytes)[..] + { + return DraftBundlePlan::Incomplete("staged resource integrity mismatch".into()); + } + aggregate = match aggregate.checked_add(metadata.byte_length) { + Some(value) if value <= MAX_PREVIEW_AGGREGATE_BYTES => value, + _ => return DraftBundlePlan::Incomplete("preview aggregate exceeds bounds".into()), + }; + let raw_path = if metadata.resource_id == primary_resource_id { primary_path.as_str().to_owned() - } else if resource.name.contains('/') { - resource.name.clone() + } else if metadata.source_name.contains('/') { + metadata.source_name.clone() } else { - format!("p/{}", resource.name) + format!("p/{}", metadata.source_name) }; let path = match polymodel_ldraw_core::NormalizedPath::new(&raw_path) { Ok(path) => path, Err(error) => return DraftBundlePlan::Incomplete(error.to_string()), }; - let root = match path.as_str().split('/').next().unwrap_or_default() { - "models" => "models", - "parts" => "parts", - "p" => "p", - _ => "models", - }; let key = path.as_str().to_owned(); - if by_path.insert(key.clone(), ()).is_some() { + if acquired_by_path.insert(key.clone(), acquired).is_some() { return DraftBundlePlan::Incomplete(format!("duplicate staged path: {key}")); } - let digest = resource - .digest - .clone() - .unwrap_or_else(|| sha2::Sha256::digest(&resource.bytes).to_vec()); - let model_resource = ModelResource { + let root = key.split('/').next().unwrap_or("models").to_owned(); + let resource = ModelResource { key: key.clone(), path: key, - root: root.into(), - byte_length: resource.byte_length, - sha256: digest, - mime_type: resource.mime_type.clone(), + root, + byte_length: metadata.byte_length, + sha256: metadata.digest.clone(), + mime_type: metadata.mime_type.clone(), target_cids: Vec::new(), - bytes: resource.bytes.clone(), + bytes: acquired.bytes.clone(), }; - if let Err(error) = model_resource.validate() { + if let Err(error) = resource.validate() { return DraftBundlePlan::Incomplete(format!("staged resource rejected: {error:?}")); } - resources.push(model_resource); + resources.push(resource); } - let references = match polymodel_ldraw_core::extract_references(&primary.bytes) { - Ok(references) => references, - Err(error) => return DraftBundlePlan::Incomplete(error.to_string()), - }; - for reference in references { - if reference.target_virtual_file { + + let mut pending = std::collections::VecDeque::from([primary_path.clone()]); + let mut visited = std::collections::HashSet::new(); + while let Some(current) = pending.pop_front() { + if !visited.insert(current.as_str().to_owned()) { continue; } - let current = primary_path.clone(); - let resolved = match polymodel_ldraw_core::resolve_reference_path(¤t, &reference) { - Ok(resolved) => resolved, + let Some(source) = acquired_by_path.get(current.as_str()) else { + return DraftBundlePlan::Incomplete(format!("staged companion is missing: {current}")); + }; + let references = match polymodel_ldraw_core::extract_references(&source.bytes) { + Ok(references) => references, Err(error) => return DraftBundlePlan::Incomplete(error.to_string()), }; - if !resources - .iter() - .any(|resource| resource.path == resolved.path.as_str()) - { - return DraftBundlePlan::Incomplete(format!( - "staged companion is missing: {}", - resolved.path - )); + for reference in references { + if reference.target_virtual_file { + continue; + } + let resolved = match polymodel_ldraw_core::resolve_reference_path(¤t, &reference) + { + Ok(resolved) => resolved, + Err(error) => return DraftBundlePlan::Incomplete(error.to_string()), + }; + if !acquired_by_path.contains_key(resolved.path.as_str()) { + return DraftBundlePlan::Incomplete(format!( + "staged companion is missing: {}", + resolved.path + )); + } + pending.push_back(resolved.path); } } let bundle = ModelLoadBundle { @@ -223,6 +263,12 @@ pub fn plan_draft_ldraw_bundle( #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq)] #[serde(rename_all = "camelCase")] pub struct DraftModelInput { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub primary_resource_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub generated_preview: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub name: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -257,6 +303,10 @@ pub struct DraftModelInput { pub struct DraftPartInput { #[serde(default, skip_serializing_if = "Option::is_none")] pub name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub resource_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub generated_preview: Option, /// stageFile upload id; the server resolves it to a `File` manifest at publish. #[serde(default, skip_serializing_if = "Option::is_none")] pub upload_id: Option, @@ -657,61 +707,84 @@ pub fn parse_thing_token(token: &str) -> Result { mod tests { use super::*; - fn resource(name: &str, bytes: &[u8]) -> DraftStagedResource { - DraftStagedResource { - name: name.into(), - mime_type: "application/x-ldraw".into(), + fn resource(id: &str, name: &str, bytes: &[u8]) -> AcquiredDraftResource { + AcquiredDraftResource { + metadata: DraftStagedResource { + resource_id: id.into(), + upload_id: id.into(), + source_name: name.into(), + mime_type: "application/x-ldraw".into(), + digest: sha2::Sha256::digest(bytes).to_vec(), + byte_length: bytes.len() as u64, + format: Some("ldraw".into()), + }, bytes: bytes.to_vec(), - digest: None, - byte_length: bytes.len() as u64, } } const MAIN: &[u8] = b"1 16 0 0 0 1 0 0 0 1 0 0 0 0 child.dat\n"; - const CHILD: &[u8] = b"0 Child\n3 16 0 0 0 1 0 0 0 1 0 0\n"; + const CHILD: &[u8] = b"1 16 0 0 0 1 0 0 0 1 0 0 0 0 grand.dat\n"; + const GRAND: &[u8] = b"0 Grand\n3 16 0 0 0 1 0 0 0 1 0 0\n"; #[test] - fn ldraw_preview_plan_is_incomplete_until_companion_arrives() { - let main = resource("main.ldr", MAIN); - assert!(matches!( - plan_draft_ldraw_bundle("main.ldr", std::slice::from_ref(&main)), - DraftBundlePlan::Incomplete(_) - )); - let companion = resource("child.dat", CHILD); - let plan = plan_draft_ldraw_bundle("main.ldr", &[main, companion]).clone(); - let DraftBundlePlan::Complete(bundle) = plan else { - panic!("bundle should become complete") - }; - assert_eq!(bundle.primary, "models/main.ldr"); - assert_eq!(bundle.resources.len(), 2); + fn draft_staging_json_contains_metadata_not_geometry_bytes() { + let acquired = resource("upload-main", "main.ldr", MAIN); + let json = serde_json::to_string(&acquired.metadata).unwrap(); + assert!(json.contains("upload-main")); + assert!(!json.contains("bytes")); + assert!(!json.contains("49,16")); } #[test] - fn ldraw_preview_plan_is_order_independent() { - let main = resource("main.ldr", MAIN); - let companion = resource("child.dat", CHILD); - let first = plan_draft_ldraw_bundle("main.ldr", &[main.clone(), companion.clone()]); - let second = plan_draft_ldraw_bundle("main.ldr", &[companion, main]); - assert!(matches!(first, DraftBundlePlan::Complete(_))); - assert!(matches!(second, DraftBundlePlan::Complete(_))); + fn ldraw_preview_requires_complete_transitive_closure_in_any_upload_order() { + let main = resource("main-id", "main.ldr", MAIN); + let child = resource("child-id", "child.dat", CHILD); + let grand = resource("grand-id", "grand.dat", GRAND); + assert!(matches!( + plan_draft_ldraw_bundle("main-id", &[main.clone(), child.clone()]), + DraftBundlePlan::Incomplete(_) + )); + for resources in [ + vec![main.clone(), child.clone(), grand.clone()], + vec![grand.clone(), child.clone(), main.clone()], + ] { + let plan = plan_draft_ldraw_bundle("main-id", &resources); + let DraftBundlePlan::Complete(bundle) = plan else { + panic!("transitive bundle should be complete: {plan:?}") + }; + assert_eq!(bundle.primary, "models/main.ldr"); + assert_eq!(bundle.resources.len(), 3); + } } #[test] - fn ldraw_preview_plan_rejects_replacement_with_bad_digest_and_isolates_sets() { - let main = resource("main.ldr", MAIN); - let companion = resource("child.dat", CHILD); + fn ldraw_preview_handles_cycles_but_rejects_duplicate_paths_and_bad_integrity() { + let main = resource("main-id", "main.ldr", MAIN); + let child_cycle = resource( + "child-id", + "child.dat", + b"1 16 0 0 0 1 0 0 0 1 0 0 0 0 models/main.ldr\n", + ); assert!(matches!( - plan_draft_ldraw_bundle("main.ldr", &[main.clone(), companion.clone()]), + plan_draft_ldraw_bundle("main-id", &[main.clone(), child_cycle]), DraftBundlePlan::Complete(_) )); - let mut replacement = resource("child.dat", b"changed"); - replacement.digest = Some(vec![0; 32]); + let duplicate = resource("other-id", "child.dat", GRAND); assert!(matches!( - plan_draft_ldraw_bundle("main.ldr", &[main, replacement]), + plan_draft_ldraw_bundle( + "main-id", + &[ + main.clone(), + resource("child-id", "child.dat", GRAND), + duplicate + ] + ), DraftBundlePlan::Incomplete(_) )); + let mut bad = resource("main-id", "main.ldr", MAIN); + bad.metadata.digest = vec![0; 32]; assert!(matches!( - plan_draft_ldraw_bundle("other.ldr", &[companion]), + plan_draft_ldraw_bundle("main-id", &[bad]), DraftBundlePlan::Incomplete(_) )); } diff --git a/src/publish/draft_client.rs b/src/publish/draft_client.rs index 12a9570..812f2bb 100644 --- a/src/publish/draft_client.rs +++ b/src/publish/draft_client.rs @@ -71,6 +71,8 @@ pub async fn resolve_strong_ref(uri: &AtUri) -> Result { const STAGE_FILE_PATH: &str = "/xrpc/space.polymodel.library.stageFile"; const DRAFTS_PATH: &str = "/app/drafts"; const IMAGES_PATH: &str = "/app/images"; +#[cfg(all(target_family = "wasm", target_os = "unknown"))] +const STAGED_PATH: &str = "/app/staged"; /// Create a new server-side draft; returns the server-assigned opaque id. pub async fn create_draft( @@ -148,6 +150,15 @@ pub async fn stage_file( .await } +#[cfg(all(target_family = "wasm", target_os = "unknown"))] +pub async fn get_staged_resource( + client: &PolymodelClient, + upload_id: &str, +) -> Result, RawError> { + let path = format!("{STAGED_PATH}/{}", urlencoding::encode(upload_id)); + client.app_bytes::<()>(Method::GET, &path, None).await +} + /// Upload an image and receive a fully-formed `#image` (blobref + server-decoded /// aspect ratio + the given alt text). pub async fn upload_image( diff --git a/src/viewer.rs b/src/viewer.rs index 7f610bd..ee9deb5 100644 --- a/src/viewer.rs +++ b/src/viewer.rs @@ -916,10 +916,9 @@ pub fn ModelViewer(props: ModelViewerProps) -> Element { "data-session-wanted": "{status_val.should_have_session()}", "data-resize-count": "{resize_count.read()}", "data-session-label": session_label.as_str(), - "data-current-identity": if matches!(status_val, ViewerStatus::Ready(_)) { - session_label.as_str() - } else { - "" + "data-current-identity": match status_val { + ViewerStatus::Ready(_) => session_label.as_str(), + _ => "", }, "data-interactive-namespace": "Interactive", "data-preview-namespace": "Preview", -- knotmirror 0.0.1