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
|
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
|
// LND proxy — requires session. The LND wallet UI calls this
|
||||||
// cross-origin from its own app port, so even the 401 must carry
|
// cross-origin from its own app port, so even the 401 must carry
|
||||||
// CORS headers; otherwise the browser reports a bare CORS failure
|
// 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)
|
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
|
// If we already have a preview blob URL, use it
|
||||||
const existingUrl = previewUrls[item.id]
|
const existingUrl = previewUrls[item.id]
|
||||||
if (existingUrl) {
|
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.
|
### 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).
|
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).
|
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.
|
### 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