mirror of
https://github.com/block/buzz.git
synced 2026-10-30 22:11:36 +00:00
Brings `buzz-media`, `buzz-relay`, all first-party kind-24242 minters, and desktop token minting into compliance with [NIP-FI §kind-24242](https://github.com/block/buzz/blob/main/docs/nips/NIP-FI.md). ## What changed **`buzz-media/src/auth.rs`** - Add `BlossomStrictness` enum (`Strict` | `Permissive`). `Strict` applies full NIP-FI rules (active modes); `Permissive` preserves pre-NIP-FI Off-mode behavior byte-identical [FI-INV-15]. - Rewrite `verify_blossom_auth_event_for_verb` with count-based cardinality: exactly one `t`/`expiration`/`server` in `Strict`; duplicate `x` also rejected. `Permissive` keeps main's boolean semantics exactly: any valued `t` other than the verb (including `""`) rejects, valueless `t` is ignored. - `Strict`: mandatory `server` tag on all proofs (upload + read); absent or mismatched → `evidence_rejected` (`ServerMismatch`). - `Strict`: 60-second proof window (`now - created_at <= 60s`, `expiration <= created_at + 60s`). `Permissive`: 3600s / optional server. - **[F1]** Permissive server validation uses any-match semantics: a proof with multiple `server` tags is accepted if any tag matches the bound host. This restores origin/main base behavior. Strict duplicate-rejection unchanged. - **[F1 compat]** Permissive server gate uses `server_values.is_empty()` (valued tags only) rather than `server_count > 0` (includes valueless tags). A lone valueless `["server"]` tag is treated as absent in Permissive/Off mode, matching pre-NIP-FI base behavior [FI-INV-15]. - **[F4]** Valueless `x` tags in Strict get reads are tracked before `filter_map` so they cannot become absent scope (host-wide authorization). Any `x` tag present in Strict must contain the exact requested sha256. - **[F3 unit]** Cases A–D in `auth.rs` are helper-level unit tests: they call `verify_blossom_upload_auth` and `verify_upload_hash_only` directly. They do not enter the upload pipeline (`process_upload`/`process_video_upload`). Retained as helper evidence. **`buzz-media/src/error.rs`** - Add `DuplicateTag(&'static str)` variant. - Add `blossom_denial_kind()` method: classifies each auth error as `MissingEvidence`, `EvidenceRejected`, or `AuthorizationDenied`. Non-auth errors return `None`. - `IntoResponse` retains the legacy JSON 401 shape [FI-INV-15]. **`buzz-relay/src/api/media.rs`** - Add `MediaDenial(MediaError, BlossomStrictness)` wrapper mapping to byte-exact NIP-FI fixed responses in `Strict`; falls through to legacy JSON in `Permissive`. - `blossom_strictness_from_state()` selects `Strict` when `state.config.nip_fi.is_enforce()`, otherwise `Permissive`. Strictness is threaded through the `admit_nip_fi_http_on_state` NIP-98 closures on upload and read, and through the post-admission X-SHA-256, hash-binding, and membership denials. - `extract_blossom_auth` keeps first-value `Authorization` extraction; Enforce rejects repeated fields at the `admit_nip_fi_http` cardinality gate, and Off keeps legacy behavior [FI-INV-15]. - Both membership-denial call sites use `media_denial(RelayMembershipRequired, strictness)` so Strict mode produces the correct fixed-text response. - `upload_blob` serving-write fence conversion (`:406`) uses `map_err(|e| media_denial(e, strictness))` — a community fence committed between auth admission and lease acquisition no longer resets Strict to Permissive. - Mode-driven regressions in `mod postgres_tests` (selected by the nextest `postgres-ci` profile via `test(/postgres_tests::/)`, `#[ignore = "requires Postgres"]`), run through the full relay router with a real Enforce verifier and assertion: Enforce rejects Permissive-only proofs (300s lifetime, missing `server` tag) on both upload routes and reads with `evidence rejected`; Off accepts the same proofs with pre-NIP-FI responses; DenyProtected answers 503 before any proof check; membership denials are `authorization denied` text/plain in Enforce and exact legacy JSON 403 in Off, with a member positive control. GET and HEAD are each discriminated (HEAD with a suppressed body). Both upload routes pin the handler's `X-SHA-256` missing/malformed/unsigned and post-body hash-mismatch denials in both modes. Off rejects an empty-valued `t` beside a valid one with legacy 401 JSON in either order, and ignores a valueless one. Exact status, Content-Type, challenge, and body bytes are pinned. **`buzz-media/src/upload.rs`** (`mod minio_tests`) - **[F3 pipeline]** Four tests exercising the actual post-body call sites (`process_upload` `:85`, `process_video_upload` `:413`) with real MinIO I/O: buffered/streaming × expired-proof-accept + mismatched-hash-reject. The positive cases use `fresh_strict_upload_auth` (`created_at = now-58`, `expiration = now+2`, lifetime = 60s) — Strict-admissible at sign time — followed by a real 3s sleep so the proof is expired when the upload completes. `nostr::Timestamp::now()` reads the OS wall clock directly; paused Tokio time does not advance it. The negative cases use a pre-expired proof with a mismatched `x` tag. All four are `#[ignore = "requires MinIO"]`; no CI lane runs them automatically. **`buzz-media/src/validation.rs`** - `minimal_valid_mp4()` promoted to `#[cfg(test)] pub(crate)` — a 618-byte pre-computed H.264/avc1 fast-start MP4 available to all `buzz-media` test modules; smoke-tested by `test_minimal_valid_mp4_passes_validate_video_file`. **`desktop/src-tauri/src/commands/media.rs`** - `sign_blossom_upload_auth`: `server` tag mandatory; upload expiry 60s (was 3600s/300s). - `MEDIA_GET_AUTH_EXPIRY_SECS`: 60s (was 600s). **`desktop/src-tauri/src/media_proxy.rs`** - Single re-mint+retry on 401/403 for range requests; non-auth failures do not retry. **`crates/buzz-cli/src/client.rs`** - Read expiry: 60s (was 600s). Upload expiry: 60s. `server` tag mandatory on upload. **`crates/buzz-dev-mcp/src/view_image.rs`** - `MEDIA_GET_AUTH_EXPIRY_SECS`: 60s (was 600s). Test updated. **`mobile/lib/shared/relay/media_auth.dart`** - `_mediaGetAuthLifetimeSeconds`: 60 (was 600). Refresh margin updated. **`mobile/lib/shared/relay/media_upload.dart`** - `_uploadAuthLifetimeSeconds`: 60 (was 300). `server` tag mandatory. **`mobile/lib/features/channels/media_viewer_page/video_viewer.dart`** - **[F2]** Remove Android-specific `VideoPlayerController.networkUrl` streaming path. `video_player_android` 2.9.5 freezes headers into static `DefaultHttpDataSource` request properties — a 60s proof minted at controller creation time becomes stale on seeks. All platforms use the authenticated local-file download path. - **[F2 transport]** `unawaited(request.sink.close())` before `client.send()`. `AbortableStreamedRequest` carries no request body; without closing the sink, `IOClient.send()` awaits `stream.pipe(ioRequest)` which blocks until the sink ends — every video download hung in loading indefinitely on every platform. - **[F2 pending-ctrl]** `pendingController` ref set before the first `await (initialize())`, cleared in all exit paths. Effect cleanup disposes `pendingController.value` so a close-during-init releases the native player even if the initialized event never arrives. - **[F2r(a)]** On native init failure (post-create, pre-initialized-event), the inner catch starts disposal detached via `unawaited(localController.dispose().catchError(...))` — the `.catchError` handler absorbs any disposal exception so it does not reach the test error zone — then rethrows the original load error. `video_player` 2.11.1 completes the init future with an error without disposing the native player. - **[F2r(b)]** Non-2xx error body cancelled via `_cancelVideoResponse()` (subscribe+cancel) instead of `drain()`. `drain()` waits for the upstream to close; a stalled server body blocked `initializeVideo` indefinitely. - **[F2r(d)]** `unawaited(localController.dispose().catchError(...))` in the catch — not `await`. Two failure mechanisms exist: (1) when `createWithOptions()` throws, `_creatingCompleter` is never completed and `await dispose()` deadlocks at `:682-683`, blocking the outer catch from setting error state. (2) when `createWithOptions()` succeeds but initialization fails, `dispose()` runs but its thrown exception becomes an unhandled async error reaching the test zone. Both are fixed: `unawaited` lets the outer catch run immediately and set `error.value`; `.catchError(...)` on the detached disposal future absorbs any disposal exception. **`mobile/test/features/channels/media_viewer_page/video_viewer_test.dart`** - `_FinalizingFakeClient`: calls `request.finalize().drain()` — a test without the sink fix times out because the drain never completes, validating the transport contract. - F2r(a): `forceInitError=true` fake emits `PlatformException` on subscribe; `disposeCallCount >= 1` verifies disposal even on init failure. - F2r(b): `stalledBody` `StreamController` with `onCancel` Completer (bounded completion signal, not a sleep). Asserts: (1) `bodyStreamCancelled=true` while body stream is still open (impossible with `drain()`); (2) error UI visible while body remains open. Restoring `drain()` breaks both. - F2r(c): `neverInitialize=true` fake never sends initialized event; unmount asserts `disposeCallCount >= 1` via `pendingController`. Distinct from the event-error case. - F2r(d): `forceCreateError=true` fake throws `PlatformException` from `createWithOptions()` itself (before any player ID is returned). Asserts error UI visible within a bounded deadline. With the old `await dispose()` the outer catch is blocked — `error.value` is never set and the error UI assertion fails at the deadline; with `unawaited(dispose())` the error text appears immediately. **`mobile/test/features/channels/media_viewer_page/video_viewer_transport_test.dart`** (separate file) - Real-IO loopback probes using `flutter_test` (not `package:test`) to avoid `depend_on_referenced_packages` lint. No `TestWidgetsFlutterBinding` — no HttpClient override interfering with `dart:io`. - Probe 1: `HttpServer.bind(loopbackIPv4, 0)` reads the full request body before replying. Without `sink.close()`, `req.drain()` hangs and the test times out. - Probe 2: server signals request arrival via a Completer (not a sleep); abort fires after confirmed arrival; `client.send()` must throw `RequestAbortedException` (typed assertion) within a bounded 5s deadline. **`scripts/test-video-upload.sh`** - Proof minted with `+60` expiry and mandatory `server` tag. **`buzz-test-client/tests/e2e_media*.rs`** + **`buzz-relay/src/api/media.rs` test fixtures** - All fixtures updated to 55s expiry + `server` tag. **`RELEASING.md`** - New "NIP-FI upload proof freshness (relay floor)" section documenting that 60s proofs require the first relay release carrying this PR's post-body hash-only check (the first tag > `v0.2.1` — update once cut) for reliable large-file uploads. Operators on older self-hosted relays (≤ `v0.2.1`) must upgrade before receiving these clients. ## Finding 3: 60s upload proofs vs old-relay compatibility Desktop/CLI/mobile upload proofs are now 60s. Older relays re-run full expiry verification after the body is transferred. A valid upload whose body transfer takes >60s will fail on an old relay. **Resolution (relay floor prerequisite):** `RELEASING.md` requires that verified supported deployments (relay releases confirmed to carry the post-body hash-only check, commit `75e9bef748`) be in place before distributing 60s-proof client builds. Unknown or unverified self-hosted relay deployments are excluded from distribution until they upgrade. This makes the verified-deployment status a prerequisite for release distribution, not a post-distribution advisory. The concrete tag (`> v0.2.1`) is to be recorded once cut. ## Sequencing note Builds on #7264 (merged): Blossom strictness is derived from its `config.nip_fi` mode in this PR, so enabling `BUZZ_NIP_FI_MODE=enforce` activates the strict verifier with no further wiring. ## GIF compatibility exception An unauthenticated Off-mode request on a configured tenant with no GIF provider changes from 404 to 401 (base `gifs.rs:269-275` checked an unconfigured provider first; the current code authenticates first). All other Off-mode Blossom responses are byte-identical to pre-NIP-FI behavior. --------- Signed-off-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz> Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
372 lines
14 KiB
Rust
372 lines
14 KiB
Rust
use axum::{
|
|
body::Body,
|
|
extract::{Request, State as AxumState},
|
|
http::{HeaderMap, HeaderValue, StatusCode},
|
|
response::{IntoResponse, Response},
|
|
routing::get,
|
|
Router,
|
|
};
|
|
use futures_util::TryStreamExt;
|
|
use tauri::{http, Manager};
|
|
use tokio::net::TcpListener;
|
|
|
|
use crate::app_state::AppState;
|
|
use crate::commands::media::mint_media_get_auth;
|
|
use crate::relay;
|
|
|
|
/// Defense-in-depth cap: refuse to buffer responses larger than this into RAM.
|
|
/// Range requests (≤16 MiB from server) always fit. Full GETs for huge videos
|
|
/// get a clear 413 instead of OOM — the <video> element always uses range
|
|
/// requests for seeking, so this only catches edge cases.
|
|
const MAX_PROXY_RESPONSE: u64 = 20 * 1024 * 1024;
|
|
|
|
#[derive(Clone)]
|
|
struct ProxyState {
|
|
client: reqwest::Client,
|
|
app_handle: tauri::AppHandle,
|
|
}
|
|
|
|
async fn proxy_handler(AxumState(state): AxumState<ProxyState>, req: Request) -> Response {
|
|
// Allow requests with no Origin (e.g. <video> element fetches) or from
|
|
// the Tauri webview origin. Blocks cross-origin JS fetches from other
|
|
// tabs/apps while letting HTML media resource loads through.
|
|
let origin = req
|
|
.headers()
|
|
.get("origin")
|
|
.and_then(|v| v.to_str().ok())
|
|
.unwrap_or("");
|
|
if !origin.is_empty() && origin != "tauri://localhost" && origin != "http://tauri.localhost" {
|
|
return (StatusCode::FORBIDDEN, "forbidden: invalid origin").into_response();
|
|
}
|
|
|
|
let path_and_query = req
|
|
.uri()
|
|
.path_and_query()
|
|
.map(|pq| pq.as_str())
|
|
.unwrap_or("/");
|
|
|
|
// Resolve relay URL dynamically so workspace switches take effect immediately.
|
|
let app_state = state.app_handle.state::<AppState>();
|
|
let base_url = relay::relay_api_base_url_with_override(&app_state);
|
|
let upstream_url = format!("{base_url}{path_and_query}");
|
|
|
|
let has_range = req.headers().contains_key("range");
|
|
|
|
let mut upstream = state
|
|
.client
|
|
.get(&upstream_url)
|
|
.timeout(std::time::Duration::from_secs(120));
|
|
|
|
// `upstream_url` is always `{relay base}{path}`, so the token can't reach
|
|
// a third-party origin (mint_media_get_auth safety contract).
|
|
if let Some(auth) = mint_media_get_auth(&app_state, &base_url) {
|
|
upstream = upstream.header("authorization", auth);
|
|
}
|
|
|
|
if let Some(range) = req.headers().get("range") {
|
|
if let Ok(v) = range.to_str() {
|
|
upstream = upstream.header("range", v);
|
|
}
|
|
}
|
|
|
|
let resp = match upstream.send().await {
|
|
Ok(r) => r,
|
|
Err(_) => {
|
|
return (StatusCode::BAD_GATEWAY, "upstream request failed").into_response();
|
|
}
|
|
};
|
|
|
|
// Re-mint and retry once on an auth failure (401 missing_evidence or 403
|
|
// evidence_rejected). Under NIP-FI strict mode a read token expires after
|
|
// 60s, so a long video range-request stream will exhaust its token mid-flight.
|
|
// Single retry; if the fresh token also fails, propagate the error to the caller.
|
|
let resp = if (resp.status() == reqwest::StatusCode::UNAUTHORIZED
|
|
|| resp.status() == reqwest::StatusCode::FORBIDDEN)
|
|
&& has_range
|
|
{
|
|
if let Some(fresh_auth) = mint_media_get_auth(&app_state, &base_url) {
|
|
let mut retry = state
|
|
.client
|
|
.get(&upstream_url)
|
|
.timeout(std::time::Duration::from_secs(120))
|
|
.header("authorization", fresh_auth);
|
|
if let Some(range) = req.headers().get("range") {
|
|
if let Ok(v) = range.to_str() {
|
|
retry = retry.header("range", v);
|
|
}
|
|
}
|
|
match retry.send().await {
|
|
Ok(r) => r,
|
|
Err(_) => {
|
|
return (StatusCode::BAD_GATEWAY, "upstream request failed").into_response();
|
|
}
|
|
}
|
|
} else {
|
|
resp
|
|
}
|
|
} else {
|
|
resp
|
|
};
|
|
|
|
let status =
|
|
StatusCode::from_u16(resp.status().as_u16()).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
|
|
|
|
let mut headers = HeaderMap::new();
|
|
for key in &[
|
|
"content-type",
|
|
"content-range",
|
|
"accept-ranges",
|
|
"content-length",
|
|
// Cache-related headers — let WKWebView's HTTP cache do its job so
|
|
// images don't re-fetch on every channel switch. Media URLs are
|
|
// content-addressed (sha256 in path), so the relay sends
|
|
// `Cache-Control: public, max-age=31536000, immutable` and we
|
|
// forward it verbatim. `etag`/`last-modified` are forwarded for
|
|
// future-proofing if upstream ever adds them.
|
|
"cache-control",
|
|
"etag",
|
|
"last-modified",
|
|
] {
|
|
if let Some(val) = resp.headers().get(*key) {
|
|
if let Ok(v) = HeaderValue::from_bytes(val.as_bytes()) {
|
|
headers.insert(*key, v);
|
|
}
|
|
}
|
|
}
|
|
|
|
// OOM guard for non-range full GETs (same 20 MB cap as the protocol handler).
|
|
if !has_range {
|
|
if let Some(cl) = headers.get("content-length") {
|
|
if let Ok(len) = cl.to_str().unwrap_or("0").parse::<u64>() {
|
|
if len > MAX_PROXY_RESPONSE {
|
|
return (
|
|
StatusCode::PAYLOAD_TOO_LARGE,
|
|
"response too large — use range requests for video playback",
|
|
)
|
|
.into_response();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Stream the body — no buffering.
|
|
let stream = resp.bytes_stream().map_err(std::io::Error::other);
|
|
let body = Body::from_stream(stream);
|
|
|
|
(status, headers, body).into_response()
|
|
}
|
|
|
|
/// Spawn a localhost HTTP proxy that streams media via reqwest, avoiding the
|
|
/// Tauri protocol handler's requirement to buffer the entire response into
|
|
/// `Vec<u8>`. Returns the OS-assigned port.
|
|
pub async fn spawn_media_proxy(http_client: reqwest::Client, app_handle: tauri::AppHandle) -> u16 {
|
|
let proxy_state = ProxyState {
|
|
client: http_client,
|
|
app_handle,
|
|
};
|
|
|
|
let app = Router::new()
|
|
.route("/media/{*path}", get(proxy_handler))
|
|
.with_state(proxy_state);
|
|
|
|
let listener = TcpListener::bind("127.0.0.1:0")
|
|
.await
|
|
.expect("failed to bind media proxy");
|
|
let port = listener.local_addr().unwrap().port();
|
|
|
|
tokio::spawn(async move {
|
|
axum::serve(listener, app).await.ok();
|
|
});
|
|
|
|
eprintln!("buzz-desktop: media proxy listening on 127.0.0.1:{port}");
|
|
port
|
|
}
|
|
|
|
/// Proxy media requests through the Rust backend so they traverse the VPN tunnel.
|
|
///
|
|
/// WKWebView's networking stack bypasses the VPN tunnel, causing 403s from Cloudflare Access.
|
|
/// This handler routes `buzz-media://localhost/{path}` through reqwest, which
|
|
/// runs in the Tauri process and goes through the VPN.
|
|
pub async fn handle_buzz_media(
|
|
app: &tauri::AppHandle,
|
|
request: &http::Request<Vec<u8>>,
|
|
) -> http::Response<Vec<u8>> {
|
|
use tauri::Manager;
|
|
|
|
let state = app.state::<AppState>();
|
|
let base = relay::relay_api_base_url_with_override(&state);
|
|
|
|
// Preserve path + query (thumbnails may have query params).
|
|
// Only proxy /media/ paths — reject anything else.
|
|
let path_and_query = request
|
|
.uri()
|
|
.path_and_query()
|
|
.map(|pq| pq.as_str())
|
|
.unwrap_or("/");
|
|
|
|
if !path_and_query.starts_with("/media/") {
|
|
return error_response(404, "not found");
|
|
}
|
|
|
|
let has_range = request.headers().contains_key("range");
|
|
let upstream_url = format!("{base}{path_and_query}");
|
|
|
|
// Forward Range header if present — enables video seeking through the proxy.
|
|
let mut upstream = state
|
|
.http_client
|
|
.get(&upstream_url)
|
|
.timeout(std::time::Duration::from_secs(60));
|
|
|
|
// `upstream_url` is always `{relay base}{path}`, so the token can't reach
|
|
// a third-party origin (mint_media_get_auth safety contract).
|
|
if let Some(auth) = mint_media_get_auth(&state, &base) {
|
|
upstream = upstream.header("authorization", auth);
|
|
}
|
|
|
|
if let Some(range) = request.headers().get("range") {
|
|
if let Ok(v) = range.to_str() {
|
|
upstream = upstream.header("range", v);
|
|
}
|
|
}
|
|
|
|
let result = upstream.send().await;
|
|
|
|
// Re-mint and retry once on auth failure (401/403) for range requests.
|
|
// Under NIP-FI strict mode a read token expires after 60s, exhausting
|
|
// mid-stream. Single retry with a fresh token; propagate on second failure.
|
|
let result = match result {
|
|
Ok(resp)
|
|
if (resp.status() == reqwest::StatusCode::UNAUTHORIZED
|
|
|| resp.status() == reqwest::StatusCode::FORBIDDEN)
|
|
&& has_range =>
|
|
{
|
|
if let Some(fresh_auth) = mint_media_get_auth(&state, &base) {
|
|
let mut retry = state
|
|
.http_client
|
|
.get(&upstream_url)
|
|
.timeout(std::time::Duration::from_secs(60))
|
|
.header("authorization", fresh_auth);
|
|
if let Some(range) = request.headers().get("range") {
|
|
if let Ok(v) = range.to_str() {
|
|
retry = retry.header("range", v);
|
|
}
|
|
}
|
|
retry.send().await
|
|
} else {
|
|
Ok(resp)
|
|
}
|
|
}
|
|
other => other,
|
|
};
|
|
|
|
match result {
|
|
Ok(resp) => {
|
|
let status = resp.status().as_u16();
|
|
let content_type = resp
|
|
.headers()
|
|
.get("content-type")
|
|
.and_then(|v| v.to_str().ok())
|
|
.unwrap_or("application/octet-stream")
|
|
.to_string();
|
|
|
|
// Propagate range-related headers so <video> seeking works.
|
|
let content_range = resp
|
|
.headers()
|
|
.get("content-range")
|
|
.and_then(|v| v.to_str().ok())
|
|
.map(|s| s.to_string());
|
|
let accept_ranges = resp
|
|
.headers()
|
|
.get("accept-ranges")
|
|
.and_then(|v| v.to_str().ok())
|
|
.map(|s| s.to_string());
|
|
let content_length = resp
|
|
.headers()
|
|
.get("content-length")
|
|
.and_then(|v| v.to_str().ok())
|
|
.map(|s| s.to_string());
|
|
|
|
// Propagate cache-related headers so WKWebView's HTTP cache
|
|
// can avoid re-fetching content-addressed media on every
|
|
// channel switch. The relay sends
|
|
// `Cache-Control: public, max-age=31536000, immutable`;
|
|
// `etag`/`last-modified` are forwarded if upstream supplies them.
|
|
let cache_control = resp
|
|
.headers()
|
|
.get("cache-control")
|
|
.and_then(|v| v.to_str().ok())
|
|
.map(|s| s.to_string());
|
|
let etag = resp
|
|
.headers()
|
|
.get("etag")
|
|
.and_then(|v| v.to_str().ok())
|
|
.map(|s| s.to_string());
|
|
let last_modified = resp
|
|
.headers()
|
|
.get("last-modified")
|
|
.and_then(|v| v.to_str().ok())
|
|
.map(|s| s.to_string());
|
|
|
|
// OOM guard: if this is a non-range GET and the upstream body is
|
|
// larger than our cap, bail with 413 instead of buffering into RAM.
|
|
// Tauri's protocol handler requires Vec<u8> so we can't truly stream.
|
|
if !has_range {
|
|
if let Some(ref cl) = content_length {
|
|
if let Ok(len) = cl.parse::<u64>() {
|
|
if len > MAX_PROXY_RESPONSE {
|
|
return error_response(
|
|
413,
|
|
"response too large — use range requests for video playback",
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
match resp.bytes().await {
|
|
Ok(bytes) => {
|
|
let mut builder = http::Response::builder()
|
|
.status(status)
|
|
.header("content-type", &content_type);
|
|
if let Some(ref cr) = content_range {
|
|
builder = builder.header("content-range", cr);
|
|
}
|
|
if let Some(ref ar) = accept_ranges {
|
|
builder = builder.header("accept-ranges", ar);
|
|
}
|
|
if let Some(ref cl) = content_length {
|
|
builder = builder.header("content-length", cl);
|
|
}
|
|
if let Some(ref cc) = cache_control {
|
|
builder = builder.header("cache-control", cc);
|
|
}
|
|
if let Some(ref e) = etag {
|
|
builder = builder.header("etag", e);
|
|
}
|
|
if let Some(ref lm) = last_modified {
|
|
builder = builder.header("last-modified", lm);
|
|
}
|
|
builder
|
|
.body(bytes.to_vec())
|
|
.unwrap_or_else(|_| error_response(500, "response build failed"))
|
|
}
|
|
Err(_) => error_response(502, "failed to read upstream body"),
|
|
}
|
|
}
|
|
Err(_) => error_response(502, "upstream request failed"),
|
|
}
|
|
}
|
|
|
|
fn error_response(status: u16, msg: &str) -> http::Response<Vec<u8>> {
|
|
http::Response::builder()
|
|
.status(status)
|
|
.header("content-type", "text/plain")
|
|
.body(msg.as_bytes().to_vec())
|
|
.unwrap_or_else(|_| {
|
|
http::Response::builder()
|
|
.status(500)
|
|
.body(Vec::new())
|
|
.unwrap()
|
|
})
|
|
}
|