feat(mesh): /api/share-to-mesh iframe intent endpoint (Phase 3c)

Marketplace app iframes (Penpot, Gitea, IndeedHub, ...) can POST a file
to /api/share-to-mesh and postMessage the returned CID to the parent
window. The endpoint mirrors /api/blob's body format but adds CORS for
the requesting app origin (any port on host_ip) so proxied apps can
reach it with credentials:'include'. Session cookie is still the primary
auth; the origin check is a sanity guard.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-04-13 12:58:03 -04:00
parent 7497fd8a0d
commit 471d57f4ff
2 changed files with 121 additions and 0 deletions

View File

@@ -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<BlobStore>,
self_pubkey_hex: &str,
headers: &HeaderMap,
body: hyper::body::Bytes,
origin: &str,
) -> Result<Response<Body>> {
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<BlobStore>,
path: &str,

View File

@@ -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<String> {
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<hyper::Body>,
@@ -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/") => {