diff --git a/core/archipelago/src/api/handler/blob.rs b/core/archipelago/src/api/handler/blob.rs index b5a54947..80467760 100644 --- a/core/archipelago/src/api/handler/blob.rs +++ b/core/archipelago/src/api/handler/blob.rs @@ -61,6 +61,65 @@ impl ApiHandler { } } + /// Share-to-mesh iframe intent. Mirrors `handle_blob_upload` but adds + /// CORS headers for the requesting app origin and returns a small JSON + /// payload the app forwards to its parent via postMessage: + /// `{ type: "share-to-mesh", cid, size, mime, filename }`. + pub(super) async fn handle_share_to_mesh( + store: &Arc, + self_pubkey_hex: &str, + headers: &HeaderMap, + body: hyper::body::Bytes, + origin: &str, + ) -> Result> { + let mime = headers + .get("x-blob-mime") + .and_then(|v| v.to_str().ok()) + .unwrap_or("application/octet-stream") + .to_string(); + let filename = headers + .get("x-blob-filename") + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()); + + let bytes = body.to_vec(); + let meta = match store.put(&bytes, &mime, filename, None).await { + Ok(m) => m, + Err(e) => { + return Ok(build_response( + StatusCode::BAD_REQUEST, + "text/plain", + Body::from(format!("share-to-mesh failed: {}", e)), + )); + } + }; + // Self-signed capability so the app can preview/download its own + // upload before the user has picked a peer. + let exp = (chrono::Utc::now().timestamp() as u64) + crate::blobs::DEFAULT_CAP_TTL_SECS; + let cap = store.issue_capability(&meta.cid, self_pubkey_hex, exp); + let self_url = format!( + "/blob/{}?cap={}&exp={}&peer={}", + meta.cid, cap, exp, self_pubkey_hex + ); + let resp = serde_json::json!({ + "type": "share-to-mesh", + "cid": meta.cid, + "size": meta.size, + "mime": meta.mime, + "filename": meta.filename, + "self_url": self_url, + }); + let body_vec = serde_json::to_vec(&resp).unwrap_or_default(); + Ok(Response::builder() + .status(StatusCode::OK) + .header("Content-Type", "application/json") + .header("Access-Control-Allow-Origin", origin) + .header("Access-Control-Allow-Credentials", "true") + .header("Vary", "Origin") + .body(Body::from(body_vec)) + .unwrap_or_else(|_| Response::new(Body::from("internal error")))) + } + pub(super) async fn handle_blob_download( store: &Arc, path: &str, diff --git a/core/archipelago/src/api/handler/mod.rs b/core/archipelago/src/api/handler/mod.rs index 86ed579b..d1193819 100644 --- a/core/archipelago/src/api/handler/mod.rs +++ b/core/archipelago/src/api/handler/mod.rs @@ -147,6 +147,36 @@ impl ApiHandler { } } + /// Permissive origin check for the share-to-mesh iframe intent: any scheme + /// http(s):// followed by the configured host_ip, optionally `:port`. Apps + /// proxied under other ports (APP_PORTS) call this from within the same + /// node, so they share host_ip but not port. The session cookie still has + /// to be valid — this is a sanity check, not the primary auth. + fn validate_app_origin(&self, headers: &hyper::HeaderMap) -> Option { + let origin = headers.get("origin").and_then(|v| v.to_str().ok())?; + // Allow localhost dev server too so the Vite frontend can exercise it. + if self.config.dev_mode && origin == "http://localhost:8100" { + return Some(origin.to_string()); + } + let host_ip = &self.config.host_ip; + let matches = |scheme: &str| -> bool { + let prefix = format!("{}{}", scheme, host_ip); + if origin == prefix { + return true; + } + let with_port = format!("{}:", prefix); + origin.starts_with(&with_port) + && origin[with_port.len()..] + .bytes() + .all(|b| b.is_ascii_digit()) + }; + if matches("http://") || matches("https://") { + Some(origin.to_string()) + } else { + None + } + } + pub async fn handle_request( &self, req: Request, @@ -252,6 +282,38 @@ impl ApiHandler { .await } + // Share-to-mesh intent — marketplace app iframes POST a file here + // to stage it as a mesh attachment. Same body format as /api/blob + // (raw bytes + X-Blob-Mime/X-Blob-Filename headers). The app is + // expected to postMessage `{type:'share-to-mesh', cid, ...}` to + // its parent window afterwards so the Mesh view can pick it up. + // Authenticated by session cookie + a relaxed Origin check (any + // port on the archipelago host is allowed, so proxied apps on + // their own ports can reach it with credentials:'include'). + (Method::POST, "/api/share-to-mesh") => { + if !self.is_authenticated(&headers).await { + return Ok(Self::unauthorized()); + } + let origin = match self.validate_app_origin(&headers) { + Some(o) => o, + None => { + return Ok(build_response( + StatusCode::FORBIDDEN, + "text/plain", + hyper::Body::from("origin not allowed"), + )) + } + }; + Self::handle_share_to_mesh( + &self.blob_store, + &self.self_pubkey_hex, + &headers, + body_bytes, + &origin, + ) + .await + } + // Blob download — peer-facing. No session required; authenticated // by HMAC capability token signed when the blob ref was shared. (Method::GET, p) if p.starts_with("/blob/") => {