fix(cloud): Range-streaming proxy for peer media so it plays/seeks (B3)
Peer media (music/video) wouldn't play: the frontend downloaded the whole file via RPC as base64 and made a non-seekable Blob URL, so <video>/large <audio> stalled and big files hit the RPC timeout. Add GET /api/peer-content/<onion>/<id> — a same-origin, session-gated proxy that forwards the browser's Range header to the peer's /content/<id> (which already returns 206 Partial Content) and passes status + Content-Range + Content-Type back. PeerFiles.playMedia() now points <video>/<audio> at this streaming URL for free content instead of buffering a base64 blob, so the player can seek and start immediately. Onion/id validated to prevent SSRF/path traversal. (Paid preview keeps its existing flow.) Verified: cargo build --release EXIT 0; vue-tsc --noEmit EXIT 0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
4cac6bc835
commit
5c8707432b
@ -522,6 +522,15 @@ impl ApiHandler {
|
||||
Self::handle_container_logs_http(self.rpc_handler.clone(), path, &origin).await
|
||||
}
|
||||
|
||||
// Peer content streaming proxy — Range-streams a peer's media file
|
||||
// so <video>/<audio> can seek/play (B3). Same-origin, session-gated.
|
||||
(Method::GET, p) if p.starts_with("/api/peer-content/") => {
|
||||
if !self.is_authenticated(&headers).await {
|
||||
return Ok(Self::unauthorized());
|
||||
}
|
||||
self.handle_peer_content_stream(p, &headers).await
|
||||
}
|
||||
|
||||
// LND proxy — requires session. The LND wallet UI calls this
|
||||
// cross-origin from its own app port, so even the 401 must carry
|
||||
// CORS headers; otherwise the browser reports a bare CORS failure
|
||||
|
||||
@ -185,4 +185,77 @@ impl ApiHandler {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Range-streaming proxy for a peer's content file (B3). The browser's
|
||||
/// `<video>`/`<audio>` element makes Range requests; we forward the Range
|
||||
/// header to the peer's `/content/<id>` (which already returns 206 Partial
|
||||
/// Content) and pass the bytes + Content-Range/Content-Type straight back.
|
||||
/// This replaces the old path of downloading the whole file as base64 into
|
||||
/// a non-seekable Blob URL, which broke playback/seeking for video and
|
||||
/// large audio. Same-origin + session-authenticated (checked by caller).
|
||||
/// Path: `/api/peer-content/<onion>/<content_id>`.
|
||||
pub(super) async fn handle_peer_content_stream(
|
||||
&self,
|
||||
path: &str,
|
||||
headers: &hyper::HeaderMap,
|
||||
) -> Result<Response<hyper::Body>> {
|
||||
let bad = |msg: &str| {
|
||||
Ok(build_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"application/json",
|
||||
hyper::Body::from(serde_json::json!({ "error": msg }).to_string()),
|
||||
))
|
||||
};
|
||||
let rest = path.strip_prefix("/api/peer-content/").unwrap_or("");
|
||||
let (onion, content_id) = match rest.split_once('/') {
|
||||
Some((o, c)) if !o.is_empty() && !c.is_empty() => (o, c),
|
||||
_ => return bad("expected /api/peer-content/<onion>/<content_id>"),
|
||||
};
|
||||
// Validate to prevent SSRF / path traversal.
|
||||
let onion_norm = onion.trim_end_matches(".onion");
|
||||
let onion_ok = onion_norm.len() == 56
|
||||
&& onion_norm
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit());
|
||||
let id_ok = !content_id.contains("..")
|
||||
&& content_id
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.'));
|
||||
if !onion_ok || !id_ok {
|
||||
return bad("invalid onion or content id");
|
||||
}
|
||||
|
||||
let fips_npub =
|
||||
crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
|
||||
let peer_path = format!("/content/{}", content_id);
|
||||
let mut req = crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, &peer_path)
|
||||
.service(crate::settings::transport::PeerService::PeerFiles)
|
||||
.timeout(std::time::Duration::from_secs(60));
|
||||
if let Some(r) = headers.get("range").and_then(|v| v.to_str().ok()) {
|
||||
req = req.header("Range", r.to_string());
|
||||
}
|
||||
match req.send_get().await {
|
||||
Ok((resp, _transport)) => {
|
||||
let status = resp.status().as_u16();
|
||||
let rh = resp.headers().clone();
|
||||
let bytes = resp.bytes().await.unwrap_or_default();
|
||||
let mut builder = Response::builder()
|
||||
.status(status)
|
||||
.header("Accept-Ranges", "bytes");
|
||||
for h in ["content-type", "content-range", "content-length"] {
|
||||
if let Some(v) = rh.get(h).and_then(|v| v.to_str().ok()) {
|
||||
builder = builder.header(h, v);
|
||||
}
|
||||
}
|
||||
Ok(builder
|
||||
.body(hyper::Body::from(bytes))
|
||||
.unwrap_or_else(|_| Response::new(hyper::Body::empty())))
|
||||
}
|
||||
Err(e) => Ok(build_response(
|
||||
StatusCode::BAD_GATEWAY,
|
||||
"application/json",
|
||||
hyper::Body::from(serde_json::json!({ "error": e.to_string() }).to_string()),
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -537,6 +537,22 @@ async function playMedia(item: CatalogItem) {
|
||||
|
||||
const paid = isPaidItem(item.access)
|
||||
|
||||
// Free content: stream via the Range-capable proxy (B3) so the player can
|
||||
// seek and start instantly, instead of downloading the whole file as a
|
||||
// base64 blob into a non-seekable Blob URL (which broke video/large audio).
|
||||
if (!paid) {
|
||||
const streamUrl = `/api/peer-content/${encodeURIComponent(onion)}/${encodeURIComponent(item.id)}`
|
||||
if (item.mime_type.startsWith('audio/')) {
|
||||
audioPlayer.play(streamUrl, item.filename.split('/').pop() || item.filename)
|
||||
} else if (item.mime_type.startsWith('video/')) {
|
||||
videoPlayerItem.value = item
|
||||
videoPlayerUrl.value = streamUrl
|
||||
videoPlayerPaid.value = false
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Paid content: use the preview/download flow below.
|
||||
// If we already have a preview blob URL, use it
|
||||
const existingUrl = previewUrls[item.id]
|
||||
if (existingUrl) {
|
||||
|
||||
@ -53,7 +53,7 @@ Dupes, erroneous names, and non-convergent group membership across nodes. Expect
|
||||
### B2 — Duplicate chat contact for one node — PASSED (resolved by load-dedup feeding mesh seed; unit-tested). UI visual-confirm recommended.
|
||||
Federated peer "sapien" shows TWO chats: one "sapien" WITHOUT archy logo (looks non-federated) + one named by raw DID `did:key:z6MkoSbN5CM7fBaQg2nWbCymEkFXsHnuXvec9Mjo5RtJf9dQ`. Same node keyed by both federated identity and raw DID → merge to one. Code: core/archipelago/src/mesh + mesh/typed_messages.rs (note :233 — meshcore adverts don't carry archy pubkey).
|
||||
|
||||
### B3 — Cloud peer media won't preview/play — ROOT-CAUSED (plan ready: streaming proxy endpoint)
|
||||
### B3 — Cloud peer media won't preview/play — FIXING (code done: /api/peer-content streaming proxy + playMedia streams free content)
|
||||
Music/video preview files on peer nodes' cloud don't play (streaming/range/content-type over mesh+Tor peer fetch).
|
||||
|
||||
### B4 — Cloud "my folders" fails (JSON parse / 502) — PASSED (content-type guard; built, guard in bundle, deployed .198). UI visual-confirm recommended.
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user