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:
parent
7497fd8a0d
commit
471d57f4ff
@ -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(
|
pub(super) async fn handle_blob_download(
|
||||||
store: &Arc<BlobStore>,
|
store: &Arc<BlobStore>,
|
||||||
path: &str,
|
path: &str,
|
||||||
|
|||||||
@ -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(
|
pub async fn handle_request(
|
||||||
&self,
|
&self,
|
||||||
req: Request<hyper::Body>,
|
req: Request<hyper::Body>,
|
||||||
@ -252,6 +282,38 @@ impl ApiHandler {
|
|||||||
.await
|
.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
|
// Blob download — peer-facing. No session required; authenticated
|
||||||
// by HMAC capability token signed when the blob ref was shared.
|
// by HMAC capability token signed when the blob ref was shared.
|
||||||
(Method::GET, p) if p.starts_with("/blob/") => {
|
(Method::GET, p) if p.starts_with("/blob/") => {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user