Strip media metadata on clients and reject it at the relay (#2006)

Signed-off-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Atish Patel <atish@squareup.com>
Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Tyler
2026-07-17 13:39:10 -04:00
committed by GitHub
parent e179f88ada
commit 5cfd69cb0c
21 changed files with 2187 additions and 218 deletions

View File

@@ -117,6 +117,13 @@ fn relay_server_tag(relay_url: &str) -> Option<String> {
}
}
fn should_retry_legacy_upload(status: reqwest::StatusCode) -> bool {
matches!(
status,
reqwest::StatusCode::NOT_FOUND | reqwest::StatusCode::METHOD_NOT_ALLOWED
)
}
fn is_safe_media_path_segment(sha256_ext: &str) -> bool {
let segments: Vec<&str> = sha256_ext.split('.').collect();
match segments.as_slice() {
@@ -312,6 +319,20 @@ mod media_download_tests {
.iter()
.any(|tag| tag.first().map(String::as_str) == Some("x")));
}
#[test]
fn legacy_upload_retry_statuses_are_narrow() {
assert!(should_retry_legacy_upload(reqwest::StatusCode::NOT_FOUND));
assert!(should_retry_legacy_upload(
reqwest::StatusCode::METHOD_NOT_ALLOWED
));
assert!(!should_retry_legacy_upload(
reqwest::StatusCode::UNPROCESSABLE_ENTITY
));
assert!(!should_retry_legacy_upload(
reqwest::StatusCode::UNSUPPORTED_MEDIA_TYPE
));
}
}
pub struct BuzzClient {
@@ -581,13 +602,14 @@ impl BuzzClient {
URL_SAFE_NO_PAD.encode(auth_event.as_json().as_bytes())
);
// 7. PUT request to /media/upload — with generous per-request timeout.
// 7. PUT request to the BUD-02 /upload endpoint with a generous timeout.
let upload_timeout = if mime.starts_with("video/") {
Duration::from_secs(600)
} else {
Duration::from_secs(120)
};
let url = format!("{}/media/upload", self.relay_url);
let url = format!("{}/upload", self.relay_url);
let upload_body = bytes::Bytes::from(bytes);
let req = self
.http
.put(&url)
@@ -596,7 +618,26 @@ impl BuzzClient {
.header("Content-Type", &mime)
.header("X-SHA-256", &sha256);
let resp = self.with_auth_tag(req).body(bytes).send().await?;
let mut resp = self
.with_auth_tag(req)
.body(upload_body.clone())
.send()
.await?;
if should_retry_legacy_upload(resp.status()) {
let legacy_url = format!("{}/media/upload", self.relay_url);
let legacy_req = self
.http
.put(&legacy_url)
.timeout(upload_timeout)
.header("Authorization", &auth_header)
.header("Content-Type", &mime)
.header("X-SHA-256", &sha256);
resp = self
.with_auth_tag(legacy_req)
.body(upload_body)
.send()
.await?;
}
if !resp.status().is_success() {
let status = resp.status().as_u16();
let body = resp.text().await.unwrap_or_default();

View File

@@ -16,6 +16,8 @@ pub enum MediaError {
ImageTooLarge,
#[error("invalid image data")]
InvalidImage,
#[error("media contains metadata or a non-canonical metadata channel")]
MetadataForbidden,
#[error("invalid signature")]
InvalidSignature,
#[error("invalid auth event kind")]
@@ -60,8 +62,8 @@ pub enum MediaError {
UploadRateLimitExceeded,
#[error("upload concurrency limit reached")]
UploadConcurrencyLimitReached,
/// Video codec is not H.264 (avc1).
#[error("unsupported video codec: only H.264 (avc1) is accepted")]
/// A video/audio track does not use the canonical H.264/AAC codecs.
#[error("unsupported media codec: only H.264 video and AAC audio are accepted")]
WrongCodec,
/// Video duration exceeds the 600-second limit.
#[error("video too long: duration exceeds 600 seconds")]
@@ -140,15 +142,15 @@ impl IntoResponse for MediaError {
Self::UploadRateLimitExceeded | Self::UploadConcurrencyLimitReached => {
(StatusCode::TOO_MANY_REQUESTS, self.to_string())
}
Self::UnsupportedContainer => (StatusCode::UNSUPPORTED_MEDIA_TYPE, self.to_string()),
Self::WrongCodec
| Self::DurationTooLong
Self::UnknownContentType | Self::UnsupportedContainer | Self::WrongCodec => {
(StatusCode::UNSUPPORTED_MEDIA_TYPE, self.to_string())
}
Self::DurationTooLong
| Self::ResolutionTooHigh
| Self::MoovNotAtFront
| Self::InvalidVideo => (StatusCode::BAD_REQUEST, self.to_string()),
Self::UnknownContentType | Self::InvalidImage => {
(StatusCode::BAD_REQUEST, self.to_string())
}
| Self::InvalidVideo
| Self::InvalidImage
| Self::MetadataForbidden => (StatusCode::UNPROCESSABLE_ENTITY, self.to_string()),
Self::Io(_) | Self::StorageError(_) | Self::Internal => {
tracing::error!(error = %self, "media storage error");
(StatusCode::INTERNAL_SERVER_ERROR, "internal error".into())
@@ -157,3 +159,40 @@ impl IntoResponse for MediaError {
(status, axum::Json(serde_json::json!({"error": msg}))).into_response()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn unsupported_media_maps_to_415() {
for error in [
MediaError::UnknownContentType,
MediaError::DisallowedContentType("audio/mpeg".to_string()),
MediaError::UnsupportedContainer,
MediaError::WrongCodec,
] {
assert_eq!(
error.into_response().status(),
StatusCode::UNSUPPORTED_MEDIA_TYPE
);
}
}
#[test]
fn invalid_or_noncanonical_media_maps_to_422() {
for error in [
MediaError::InvalidImage,
MediaError::InvalidVideo,
MediaError::MetadataForbidden,
MediaError::MoovNotAtFront,
MediaError::DurationTooLong,
MediaError::ResolutionTooHigh,
] {
assert_eq!(
error.into_response().status(),
StatusCode::UNPROCESSABLE_ENTITY
);
}
}
}

View File

@@ -21,4 +21,4 @@ pub use upload_record::{
parse_port, parse_public_ip, upload_record_key, UploadAttribution, UploadNetworkInfo,
UploadRecord, UPLOAD_RECORD_VERSION,
};
pub use validation::{serve_inline, validate_video_file, VideoMeta};
pub use validation::{looks_like_iso_bmff, serve_inline, validate_video_file, VideoMeta};

View File

@@ -2,7 +2,7 @@
use serde::{Deserialize, Serialize};
/// Blossom BlobDescriptor — returned by PUT /media/upload.
/// Blossom BlobDescriptor — returned by `PUT /upload` and the legacy media alias.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BlobDescriptor {
/// Full URL to the blob.

View File

@@ -13,7 +13,8 @@ use crate::thumbnail::generate_image_metadata_sync;
use crate::types::BlobDescriptor;
use crate::upload_record::{record_upload_event, UploadAttribution, UploadEventFacts};
use crate::validation::{
mime_to_ext, validate_content, validate_file_content, validate_video_file,
looks_like_mp4_iso_bmff, mime_to_ext, validate_content, validate_file_content,
validate_video_file,
};
/// Shared buffered-upload pipeline for the image and generic-file paths.
@@ -230,13 +231,14 @@ pub async fn process_upload(
.await
}
/// Process a generic (non-image, non-video) file upload end-to-end.
/// Process a generic non-media file upload end-to-end.
///
/// This is the catch-all attachment path: documents, archives, audio, text,
/// data — anything that isn't a previewable image or an H.264 MP4. The body is
/// fully buffered in RAM (bounded by `config.max_file_bytes` at the transport
/// layer), validated against the deny-list + size cap, stored, and recorded in
/// a minimal sidecar. No thumbnail, no dimensions, no duration.
/// This is the catch-all attachment path for documents, archives, text, and
/// data. Recognized image, video, and audio formats fail closed instead of
/// entering exact-byte storage without their format-specific location policy.
/// The body is fully buffered in RAM (bounded by `config.max_file_bytes` at the
/// transport layer), validated against the deny-list + size cap, stored, and
/// recorded in a minimal sidecar. No thumbnail, dimensions, or duration.
///
/// The resulting blob is served with `Content-Disposition: attachment`, so the
/// client always downloads it rather than rendering it inline.
@@ -391,15 +393,13 @@ pub async fn process_video_upload(
(sha256_hex, total, sniff_buf)
};
// --- 2. Magic-byte check (video/mp4 only) ---
// sniff_buf has up to MIN_SNIFF_BYTES (4 KiB) of leading bytes — enough for
// infer::get() to detect MP4 ftyp even if the first network chunk was tiny.
let mime = infer::get(&first_bytes)
.map(|t| t.mime_type().to_string())
.ok_or(MediaError::UnknownContentType)?;
if mime != "video/mp4" {
return Err(MediaError::DisallowedContentType(mime));
// --- 2. ISO-BMFF/MP4 structural check ---
// Do not depend on `infer`'s finite major-brand list: valid MP4 producers
// may use a proprietary major brand while declaring `isom` compatibility.
if !looks_like_mp4_iso_bmff(&first_bytes) {
return Err(MediaError::UnsupportedContainer);
}
let mime = "video/mp4".to_string();
// --- 3. Verify Blossom auth: x tag must match computed SHA-256 ---
let auth = auth_event.clone();

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,8 @@
//! Blossom-compatible media upload, retrieval, and existence check handlers.
//!
//! Routes:
//! PUT /media/upload — BUD-02 upload (auth required)
//! PUT /upload — BUD-02 exact-byte upload (auth required)
//! PUT /media/upload — temporary media-only legacy alias
//! GET /media/{sha256_ext} — BUD-01 serve blob
//! HEAD /media/{sha256_ext} — BUD-01 existence check
@@ -35,9 +36,29 @@ pub(crate) struct AuthenticatedUpload {
/// this HTTP door), identical to the WS door in `router.rs` and the bridge
/// door in `bridge.rs`. Server-resolved, never client-supplied.
tenant: TenantContext,
route_mode: UploadRouteMode,
_upload_permit: UploadPermit,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum UploadRouteMode {
Upload,
LegacyMedia,
}
fn should_stream_as_video(sniff: &[u8]) -> bool {
infer::get(sniff).is_some_and(|kind| kind.mime_type() == "video/mp4")
|| buzz_media::looks_like_iso_bmff(sniff)
}
fn upload_route_mode(path: &str) -> Result<UploadRouteMode, MediaError> {
match path {
"/upload" => Ok(UploadRouteMode::Upload),
"/media/upload" => Ok(UploadRouteMode::LegacyMedia),
_ => Err(MediaError::NotFound),
}
}
struct MediaReadAuth {
tenant: TenantContext,
}
@@ -145,6 +166,8 @@ impl FromRequestParts<Arc<AppState>> for AuthenticatedUpload {
.await
.map_err(|_| MediaError::NotFound)?;
let route_mode = upload_route_mode(parts.uri.path())?;
// 2. Extract and validate Blossom auth event against the bound host.
let auth_event = extract_blossom_auth(headers)?;
// Use the permissive window (3600s) here because we don't know the
@@ -208,6 +231,7 @@ impl FromRequestParts<Arc<AppState>> for AuthenticatedUpload {
Ok(AuthenticatedUpload {
auth_event,
tenant,
route_mode,
_upload_permit: upload_permit,
})
}
@@ -259,7 +283,7 @@ async fn upload_attribution(
})
}
/// PUT /media/upload — Blossom BUD-02 upload.
/// PUT `/upload` or the temporary media-only `/media/upload` alias.
///
/// Auth is validated via the [`AuthenticatedUpload`] extractor BEFORE the body
/// is read, preventing unauthenticated clients from forcing body buffering.
@@ -271,7 +295,8 @@ async fn upload_attribution(
/// Expects:
/// - `Authorization: Nostr <base64(kind:24242 event)>` — Blossom auth
/// - `X-SHA-256: <hex>` — Required per BUD-11
/// - `Content-Type: video/mp4` — routes to video validation path; all other types use image path
/// - `Content-Type` is advisory only; a bounded body prefix selects the
/// streaming video path from actual bytes
/// - Raw binary body (the file bytes)
///
/// Returns a [`BlobDescriptor`] JSON on success.
@@ -283,14 +308,34 @@ pub async fn upload_blob(
headers: HeaderMap,
body: axum::body::Body,
) -> Result<Json<BlobDescriptor>, MediaError> {
let content_type = headers
.get("content-type")
.and_then(|v| v.to_str().ok())
.unwrap_or("");
let attribution = upload_attribution(&state, &auth, &headers).await;
let mut descriptor = if content_type.starts_with("video/") {
if auth.route_mode == UploadRouteMode::LegacyMedia {
metrics::counter!("buzz_media_legacy_upload_route_total").increment(1);
}
// Probe actual bytes without trusting Content-Type. Keep the chunks used
// for the bounded probe and replay them into the selected pipeline so the
// stored/hash-verified body remains byte-identical.
use futures_util::StreamExt;
const SNIFF_BYTES: usize = 4096;
let mut source = body.into_data_stream();
let mut replay_chunks = Vec::new();
let mut sniff = Vec::with_capacity(SNIFF_BYTES);
while sniff.len() < SNIFF_BYTES {
match source.next().await {
Some(Ok(chunk)) => {
let needed = SNIFF_BYTES - sniff.len();
sniff.extend_from_slice(&chunk[..chunk.len().min(needed)]);
replay_chunks.push(chunk);
}
Some(Err(error)) => return Err(MediaError::Io(error.to_string())),
None => break,
}
}
let replay = futures_util::stream::iter(replay_chunks.into_iter().map(Ok)).chain(source);
let mut descriptor = if should_stream_as_video(&sniff) {
// Video path: stream body directly to disk — never fully buffered in RAM.
let content_length = headers
.get("content-length")
@@ -301,7 +346,7 @@ pub async fn upload_blob(
&state.config.media,
&auth.tenant,
&auth.auth_event,
body.into_data_stream(),
replay,
content_length,
attribution,
)
@@ -309,15 +354,15 @@ pub async fn upload_blob(
} else {
// Non-video path: buffer the body (bounded by the larger of the image
// and generic-file caps), then decide image-vs-generic by sniffed MIME.
// Images go through the thumbnailing pipeline; everything else (docs,
// archives, audio, text, data) takes the generic file path and is
// served as a download.
// Images go through the thumbnailing pipeline; non-media attachments
// (docs, archives, text, data) take the generic file path and are
// served as downloads. Recognized audio/video cannot fall through it.
let max = state
.config
.media
.max_image_bytes
.max(state.config.media.max_file_bytes);
let bytes = axum::body::to_bytes(body, max as usize)
let bytes = axum::body::to_bytes(axum::body::Body::from_stream(replay), max as usize)
.await
.map_err(|_| MediaError::FileTooLarge { size: 0, max })?;
@@ -336,6 +381,11 @@ pub async fn upload_blob(
attribution,
)
.await?
} else if auth.route_mode == UploadRouteMode::LegacyMedia {
let mime = infer::get(&bytes)
.map(|kind| kind.mime_type().to_string())
.unwrap_or_else(|| "application/octet-stream".to_string());
return Err(MediaError::DisallowedContentType(mime));
} else {
buzz_media::process_file_upload(
&state.media_storage,
@@ -863,6 +913,29 @@ mod tests {
const VALID_HASH: &str = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789";
#[test]
fn upload_routes_distinguish_standard_and_legacy_modes() {
assert_eq!(
upload_route_mode("/upload").expect("standard upload route"),
UploadRouteMode::Upload
);
assert_eq!(
upload_route_mode("/media/upload").expect("legacy upload route"),
UploadRouteMode::LegacyMedia
);
assert!(matches!(
upload_route_mode("/media"),
Err(MediaError::NotFound)
));
}
#[test]
fn proprietary_iso_bmff_brand_still_uses_video_pipeline() {
let bytes = b"\x00\x00\x00\x18ftypPRIV\x00\x00\x00\x00isommp42";
assert!(infer::get(bytes).is_none());
assert!(should_stream_as_video(bytes));
}
async fn test_state() -> Arc<AppState> {
test_state_with_media_get_auth(false).await
}

View File

@@ -36,6 +36,7 @@ pub fn build_router(state: Arc<AppState>) -> Router {
.max_image_bytes
.max(state.config.media.max_video_bytes) as usize;
let media_router = Router::new()
.route("/upload", put(api::media::upload_blob))
.route("/media/upload", put(api::media::upload_blob))
.route(
"/media/{sha256_ext}",

View File

@@ -99,10 +99,10 @@ async fn test_upload_and_get() {
println!("sha256: {sha256}");
println!("relay: {}", relay_http_url());
// PUT /media/upload
// Standards-compliant BUD-02 upload route.
let auth = sign_blossom_auth(&keys, &sha256);
let resp = client
.put(format!("{}/media/upload", relay_http_url()))
.put(format!("{}/upload", relay_http_url()))
.header("Authorization", blossom_auth_header(&auth))
.header("Content-Type", "image/jpeg")
.header("X-SHA-256", &sha256)
@@ -113,7 +113,7 @@ async fn test_upload_and_get() {
let status = resp.status();
let body_text = resp.text().await.unwrap_or_default();
println!("PUT /media/upload → {status}: {body_text}");
println!("PUT /upload → {status}: {body_text}");
assert_eq!(status, 200, "upload should succeed");
// Parse BlobDescriptor

View File

@@ -47,10 +47,19 @@ fn blossom_auth_header(event: &nostr::Event) -> String {
}
async fn upload(client: &Client, keys: &Keys, body: &[u8]) -> reqwest::Response {
upload_to_path(client, keys, "/upload", body).await
}
async fn upload_to_path(
client: &Client,
keys: &Keys,
path: &str,
body: &[u8],
) -> reqwest::Response {
let sha256 = hex::encode(Sha256::digest(body));
let auth = sign_blossom_auth(keys, &sha256);
client
.put(format!("{}/media/upload", relay_http_url()))
.put(format!("{}{path}", relay_http_url()))
.header("Authorization", blossom_auth_header(&auth))
.header("X-SHA-256", &sha256)
.body(body.to_vec())
@@ -137,7 +146,7 @@ async fn upload_with_auth(
body: &[u8],
) -> reqwest::Response {
client
.put(format!("{}/media/upload", relay_http_url()))
.put(format!("{}/upload", relay_http_url()))
.header("Authorization", blossom_auth_header(auth_event))
.header("X-SHA-256", sha256)
.body(body.to_vec())
@@ -408,6 +417,43 @@ async fn test_upload_pdf_accepted() {
println!("✅ PDF → 200");
}
#[tokio::test]
#[ignore]
async fn test_legacy_media_route_rejects_non_media() {
let client = http_client();
let keys = Keys::generate();
let pdf = b"%PDF-1.4 fake pdf content here for testing";
let resp = upload_to_path(&client, &keys, "/media/upload", pdf).await;
assert_eq!(
resp.status(),
reqwest::StatusCode::UNSUPPORTED_MEDIA_TYPE,
"legacy media route must not accept generic attachments"
);
}
#[tokio::test]
#[ignore]
async fn test_legacy_media_route_still_accepts_canonical_media() {
let client = http_client();
let keys = Keys::generate();
let resp = upload_to_path(&client, &keys, "/media/upload", &tiny_jpeg()).await;
assert_eq!(resp.status(), reqwest::StatusCode::OK);
}
#[tokio::test]
#[ignore]
async fn test_standard_upload_rejects_recognized_audio() {
let client = http_client();
let keys = Keys::generate();
let mp3 = b"ID3\x04\x00\x00\x00\x00\x00\x00";
let resp = upload(&client, &keys, mp3).await;
assert_eq!(
resp.status(),
reqwest::StatusCode::UNSUPPORTED_MEDIA_TYPE,
"recognized audio must not bypass the location policy as an attachment"
);
}
#[tokio::test]
#[ignore]
async fn test_upload_zero_bytes_accepted() {

View File

@@ -246,7 +246,7 @@ async fn test_video_upload_and_get() {
let sha256 = hex::encode(Sha256::digest(&mp4));
let auth = sign_blossom_auth(&keys, &sha256);
let url = format!("{}/media/upload", relay_http_url());
let url = format!("{}/upload", relay_http_url());
let resp = client
.put(&url)
@@ -280,7 +280,8 @@ async fn test_video_upload_and_get() {
/// The relay ignores the Content-Type header and sniffs magic bytes. MP4
/// uploaded with a spoofed image/jpeg header is detected as video/mp4 and
/// accepted via the generic file path.
/// accepted through the streaming media validator, including on the legacy
/// compatibility route.
#[tokio::test]
#[ignore]
async fn test_video_content_type_header_ignored() {

View File

@@ -153,6 +153,98 @@ pub(crate) fn sanitize_filename(name: &str) -> String {
}
}
/// Return true when a PNG/WebP payload declares animation.
///
/// Animated payloads are left byte-identical here so frame timing, looping,
/// and disposal semantics are preserved. The relay's structural validator is
/// still the authority that rejects any metadata-bearing animation.
fn is_animated_image(body: &[u8], mime: &str) -> bool {
match mime {
"image/png" if body.starts_with(b"\x89PNG\r\n\x1a\n") => {
let mut offset = 8usize;
while offset.checked_add(12).is_some_and(|end| end <= body.len()) {
let length = u32::from_be_bytes([
body[offset],
body[offset + 1],
body[offset + 2],
body[offset + 3],
]) as usize;
let Some(end) = offset.checked_add(12).and_then(|v| v.checked_add(length)) else {
return false;
};
if end > body.len() {
return false;
}
if &body[offset + 4..offset + 8] == b"acTL" {
return true;
}
offset = end;
}
false
}
"image/webp"
if body.len() >= 12 && body.starts_with(b"RIFF") && &body[8..12] == b"WEBP" =>
{
let mut offset = 12usize;
while offset.checked_add(8).is_some_and(|end| end <= body.len()) {
let chunk = &body[offset..offset + 4];
if chunk == b"ANIM" || chunk == b"ANMF" {
return true;
}
let length = u32::from_le_bytes([
body[offset + 4],
body[offset + 5],
body[offset + 6],
body[offset + 7],
]) as usize;
let padded = length.checked_add(length & 1);
let Some(end) = padded.and_then(|v| offset.checked_add(8 + v)) else {
return false;
};
if end > body.len() {
return false;
}
offset = end;
}
false
}
_ => false,
}
}
fn sanitize_image_for_upload(body: Vec<u8>, mime: &str) -> Result<Vec<u8>, String> {
let format = match mime {
"image/jpeg" => image::ImageFormat::Jpeg,
"image/png" => image::ImageFormat::Png,
"image/webp" => image::ImageFormat::WebP,
_ => return Ok(body),
};
if is_animated_image(&body, mime) {
return Ok(body);
}
use image::ImageDecoder;
let reader = image::ImageReader::with_format(std::io::Cursor::new(&body), format);
let mut decoder = reader
.into_decoder()
.map_err(|_| "failed to decode image for metadata removal".to_string())?;
decoder
.set_limits(image::Limits::default())
.map_err(|_| "image exceeds safe decoding limits".to_string())?;
let orientation = decoder
.orientation()
.map_err(|_| "failed to read image orientation".to_string())?;
let mut image = image::DynamicImage::from_decoder(decoder)
.map_err(|_| "failed to decode image for metadata removal".to_string())?;
image.apply_orientation(orientation);
let mut output = std::io::Cursor::new(Vec::new());
image
.write_to(&mut output, format)
.map_err(|_| "failed to encode image without metadata".to_string())?;
Ok(output.into_inner())
}
pub(crate) fn detect_and_validate_mime(body: &[u8]) -> Result<String, String> {
let mime = infer::get(body)
.map(|t| t.mime_type().to_string())
@@ -257,6 +349,58 @@ fn sign_blossom_upload_auth(
// Current approach works for videos up to ~100MB but will OOM on 500MB files.
// Fix: use reqwest's Body::wrap_stream() to stream from the temp file directly.
// The server already supports streaming upload via process_video_upload.
fn should_retry_legacy_upload(status: reqwest::StatusCode) -> bool {
matches!(
status,
reqwest::StatusCode::NOT_FOUND | reqwest::StatusCode::METHOD_NOT_ALLOWED
)
}
async fn send_upload_attempt(
state: &State<'_, AppState>,
url: String,
auth_header: &str,
mime: &str,
sha256: &str,
body: bytes::Bytes,
progress: Option<&(tauri::AppHandle, String)>,
) -> Result<reqwest::Response, String> {
let req = state
.http_client
.put(url)
.header("Authorization", auth_header)
.header("Content-Type", mime)
.header("X-SHA-256", sha256);
let response = if let Some((app, progress_id)) = progress {
use tauri::Emitter;
let app = app.clone();
let progress_id = progress_id.clone();
let total = body.len() as u64;
let chunk_size = 64 * 1024;
let chunk_count = body.len().div_ceil(chunk_size);
let mut sent: u64 = 0;
let stream = futures_util::stream::iter((0..chunk_count).map(move |i| {
let start = i * chunk_size;
let end = usize::min(start + chunk_size, body.len());
let chunk = body.slice(start..end);
sent += chunk.len() as u64;
let _ = app.emit(
"media-upload-progress",
serde_json::json!({ "id": progress_id, "sent": sent, "total": total }),
);
Ok::<bytes::Bytes, std::io::Error>(chunk)
}));
req.header(reqwest::header::CONTENT_LENGTH, total)
.body(reqwest::Body::wrap_stream(stream))
.send()
.await
} else {
req.body(body).send().await
};
response.map_err(|error| classify_request_error(&error))
}
async fn do_upload(
body: Vec<u8>,
mime: &str,
@@ -283,43 +427,29 @@ async fn do_upload(
"Nostr {}",
URL_SAFE_NO_PAD.encode(auth_event.as_json().as_bytes())
);
let req = state
.http_client
.put(format!("{base_url}/media/upload"))
.header("Authorization", &auth_header)
.header("Content-Type", mime)
.header("X-SHA-256", &sha256);
// With a progress channel, stream the body in chunks and emit a
// `media-upload-progress` event as each chunk is handed to the socket,
// so the renderer can draw a determinate progress bar.
let resp = if let Some((app, progress_id)) = progress {
use tauri::Emitter;
let total = body.len() as u64;
// Ref-counted slices of one buffer — no second copy of the payload.
let body = bytes::Bytes::from(body);
let chunk_size = 64 * 1024;
let chunk_count = body.len().div_ceil(chunk_size);
let mut sent: u64 = 0;
let stream = futures_util::stream::iter((0..chunk_count).map(move |i| {
let start = i * chunk_size;
let end = usize::min(start + chunk_size, body.len());
let chunk = body.slice(start..end);
sent += chunk.len() as u64;
let _ = app.emit(
"media-upload-progress",
serde_json::json!({ "id": progress_id, "sent": sent, "total": total }),
);
Ok::<bytes::Bytes, std::io::Error>(chunk)
}));
req.header(reqwest::header::CONTENT_LENGTH, total)
.body(reqwest::Body::wrap_stream(stream))
.send()
.await
} else {
req.body(body).send().await
let body = bytes::Bytes::from(body);
let mut resp = send_upload_attempt(
state,
format!("{base_url}/upload"),
&auth_header,
mime,
&sha256,
body.clone(),
progress.as_ref(),
)
.await?;
if should_retry_legacy_upload(resp.status()) {
resp = send_upload_attempt(
state,
format!("{base_url}/media/upload"),
&auth_header,
mime,
&sha256,
body,
progress.as_ref(),
)
.await?;
}
.map_err(|e| classify_request_error(&e))?;
if !resp.status().is_success() {
return Err(relay_error_message(resp).await);
@@ -362,6 +492,7 @@ pub async fn upload_media(
}
let mime = detect_and_validate_mime(&body)?;
let body = sanitize_image_for_upload(body, &mime)?;
do_upload(body, &mime, &state, None).await
}
@@ -432,6 +563,7 @@ async fn process_picked_path(
.map_err(|e| format!("transcode task failed: {e}"))??;
let mime = detect_and_validate_mime(&body)?;
let body = sanitize_image_for_upload(body, &mime)?;
// Image-only surfaces (e.g. "Send feedback"): reject anything that didn't
// sniff as an image, BEFORE the upload leaves the client.
@@ -598,6 +730,7 @@ pub async fn upload_media_bytes(
};
let mime = detect_and_validate_mime(&body)?;
let body = sanitize_image_for_upload(body, &mime)?;
// Upload video first, then poster (best-effort).
let progress = progress_id.map(|id| (app, id));
@@ -719,6 +852,75 @@ mod tests {
assert!(detect_and_validate_mime(html).is_err());
}
#[test]
fn test_image_sanitizer_bakes_exif_orientation() {
let source = image::RgbImage::from_fn(2, 3, |x, y| {
image::Rgb([(x * 80) as u8, (y * 60) as u8, 32])
});
let mut encoded = Vec::new();
image::codecs::jpeg::JpegEncoder::new_with_quality(&mut encoded, 95)
.encode_image(&source)
.unwrap();
// Minimal little-endian Exif IFD with Orientation=6 (rotate 90°).
let mut exif = b"Exif\0\0II\x2a\0\x08\0\0\0\x01\0".to_vec();
exif.extend_from_slice(&[
0x12, 0x01, // Orientation tag
0x03, 0x00, // SHORT
0x01, 0x00, 0x00, 0x00, // count=1
0x06, 0x00, 0x00, 0x00, // value=6
0x00, 0x00, 0x00, 0x00, // next IFD
]);
let segment_len = (exif.len() + 2) as u16;
let mut oriented = encoded[..2].to_vec();
oriented.extend_from_slice(&[0xff, 0xe1]);
oriented.extend_from_slice(&segment_len.to_be_bytes());
oriented.extend_from_slice(&exif);
oriented.extend_from_slice(&encoded[2..]);
let sanitized = sanitize_image_for_upload(oriented, "image/jpeg").unwrap();
let decoded =
image::load_from_memory_with_format(&sanitized, image::ImageFormat::Jpeg).unwrap();
assert_eq!((decoded.width(), decoded.height()), (3, 2));
assert!(!sanitized.windows(6).any(|bytes| bytes == b"Exif\0\0"));
}
#[test]
fn test_animated_png_and_webp_are_not_flattened() {
let mut apng = b"\x89PNG\r\n\x1a\n".to_vec();
apng.extend_from_slice(&8u32.to_be_bytes());
apng.extend_from_slice(b"acTL");
apng.extend_from_slice(&[0; 8]);
apng.extend_from_slice(&[0; 4]);
assert!(is_animated_image(&apng, "image/png"));
assert_eq!(
sanitize_image_for_upload(apng.clone(), "image/png").unwrap(),
apng
);
let mut webp = b"RIFF\x0c\0\0\0WEBPANIM".to_vec();
webp.extend_from_slice(&0u32.to_le_bytes());
assert!(is_animated_image(&webp, "image/webp"));
assert_eq!(
sanitize_image_for_upload(webp.clone(), "image/webp").unwrap(),
webp
);
}
#[test]
fn test_legacy_upload_retry_statuses_are_narrow() {
assert!(should_retry_legacy_upload(reqwest::StatusCode::NOT_FOUND));
assert!(should_retry_legacy_upload(
reqwest::StatusCode::METHOD_NOT_ALLOWED
));
assert!(!should_retry_legacy_upload(
reqwest::StatusCode::UNPROCESSABLE_ENTITY
));
assert!(!should_retry_legacy_upload(
reqwest::StatusCode::UNSUPPORTED_MEDIA_TYPE
));
}
#[test]
fn test_sanitize_filename() {
assert_eq!(sanitize_filename("report.pdf"), "report.pdf");

View File

@@ -7,6 +7,26 @@
use crate::managed_agents::resolve_command;
/// Build an ffmpeg command without inheriting user-controlled process knobs.
///
/// The binary path is resolved before this point, so a shell and `PATH` are not
/// needed. Windows keeps only the OS variables required for process/DLL lookup.
fn ffmpeg_command(path: &std::path::Path) -> std::process::Command {
let mut command = std::process::Command::new(path);
#[cfg(target_os = "windows")]
let required_windows_env: Vec<(&'static str, std::ffi::OsString)> =
["SystemRoot", "WINDIR", "TEMP", "TMP"]
.into_iter()
.filter_map(|name| std::env::var_os(name).map(|value| (name, value)))
.collect();
command.env_clear().env("LANG", "C");
#[cfg(target_os = "windows")]
for (name, value) in required_windows_env {
command.env(name, value);
}
command
}
/// Locate ffmpeg using the same discovery logic as managed agents
/// (login shell PATH, /opt/homebrew/bin, /usr/local/bin, etc.).
/// Returns the resolved absolute path on success.
@@ -20,7 +40,7 @@ pub(super) fn find_ffmpeg() -> Result<std::path::PathBuf, String> {
.to_string()
})?;
match std::process::Command::new(&ffmpeg_path)
match ffmpeg_command(&ffmpeg_path)
.arg("-version")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
@@ -168,11 +188,34 @@ pub(super) fn transcode_to_mp4(
let output = std::env::temp_dir().join(format!("buzz-transcode-{}.mp4", uuid::Uuid::new_v4()));
let result = run_ffmpeg_with_timeout(
std::process::Command::new(ffmpeg)
.args(["-y", "-loglevel", "error"]) // suppress progress spam — prevents stderr pipe deadlock
ffmpeg_command(ffmpeg)
.args([
"-y",
"-nostdin",
"-loglevel",
"error",
"-protocol_whitelist",
"file,pipe",
]) // suppress progress spam — prevents stderr pipe deadlock
.arg("-i")
.arg(source) // OsStr — handles non-UTF-8 paths on Unix
.args([
"-map",
"0:v:0",
"-map",
"0:a:0?",
"-map_metadata",
"-1",
"-map_chapters",
"-1",
"-sn",
"-dn",
"-fflags",
"+bitexact",
"-flags:v",
"+bitexact",
"-flags:a",
"+bitexact",
"-c:v",
"libx264",
"-preset",
@@ -181,12 +224,16 @@ pub(super) fn transcode_to_mp4(
"23",
"-pix_fmt",
"yuv420p",
"-vf",
"pad=ceil(iw/2)*2:ceil(ih/2)*2",
"-c:a",
"aac",
"-b:a",
"128k",
"-movflags",
"+faststart",
"-metadata",
"encoder=",
])
.arg(&output)
.stdout(std::process::Stdio::null())
@@ -228,11 +275,27 @@ pub(super) fn transcode_heic_to_jpeg(
let heic_timeout = std::time::Duration::from_secs(60);
let result = run_ffmpeg_with_timeout(
std::process::Command::new(ffmpeg)
.args(["-y", "-loglevel", "error"]) // suppress progress spam — prevents stderr pipe deadlock
ffmpeg_command(ffmpeg)
.args([
"-y",
"-nostdin",
"-loglevel",
"error",
"-protocol_whitelist",
"file,pipe",
]) // suppress progress spam — prevents stderr pipe deadlock
.arg("-i")
.arg(source) // OsStr — handles non-UTF-8 paths on Unix
.args(["-frames:v", "1", "-q:v", "2"])
.args([
"-map",
"0:v:0",
"-map_metadata",
"-1",
"-frames:v",
"1",
"-q:v",
"2",
])
.arg(&output)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::piped()),
@@ -287,8 +350,15 @@ pub(super) fn extract_poster_frame(
// Try seeking to 1s first (avoids black first frames from fade-ins).
let result = run_ffmpeg_with_timeout(
std::process::Command::new(ffmpeg)
.args(["-y", "-loglevel", "error"])
ffmpeg_command(ffmpeg)
.args([
"-y",
"-nostdin",
"-loglevel",
"error",
"-protocol_whitelist",
"file,pipe",
])
.arg("-ss")
.arg("1")
.arg("-i")
@@ -311,8 +381,15 @@ pub(super) fn extract_poster_frame(
}
let _ = std::fs::remove_file(&output);
let fallback = run_ffmpeg_with_timeout(
std::process::Command::new(ffmpeg)
.args(["-y", "-loglevel", "error"])
ffmpeg_command(ffmpeg)
.args([
"-y",
"-nostdin",
"-loglevel",
"error",
"-protocol_whitelist",
"file,pipe",
])
.arg("-i")
.arg(mp4_path)
.args(["-vframes", "1", "-vf", "scale=640:-2", "-q:v", "2"])
@@ -491,6 +568,48 @@ mod tests {
assert!(!has_heic_extension(Path::new("noextension")));
}
#[test]
fn test_transcode_to_mp4_drops_source_metadata() {
let Ok(ffmpeg) = find_ffmpeg() else {
eprintln!("skipping metadata round-trip: ffmpeg not found");
return;
};
let source =
std::env::temp_dir().join(format!("buzz-metadata-test-{}.mp4", uuid::Uuid::new_v4()));
let generated = std::process::Command::new(&ffmpeg)
.args(["-y", "-loglevel", "error", "-f", "lavfi", "-i"])
.arg("testsrc2=size=64x64:rate=1")
.args([
"-t",
"1",
"-c:v",
"libx264",
"-metadata",
"location=+37.7-122.4/",
"-metadata:s:v:0",
"title=private camera stream",
])
.arg(&source)
.output()
.expect("run ffmpeg fixture generation");
if !generated.status.success() {
eprintln!("skipping metadata round-trip: ffmpeg cannot encode H.264");
let _ = std::fs::remove_file(&source);
return;
}
let output = transcode_to_mp4(&source, &ffmpeg).expect("transcode fixture");
let bytes = std::fs::read(&output).expect("read transcoded video");
let _ = std::fs::remove_file(&source);
let _ = std::fs::remove_file(&output);
for secret in [b"+37.7-122.4/".as_slice(), b"private camera stream"] {
assert!(
!bytes.windows(secret.len()).any(|window| window == secret),
"source metadata survived transcode"
);
}
}
/// Round-trip transcode test, gated on ffmpeg being present so CI without
/// ffmpeg doesn't fail. Generates a HEIC via ffmpeg, then transcodes it
/// back to JPEG and asserts the output is a valid JPEG.

View File

@@ -123,7 +123,7 @@ class MainActivity : FlutterActivity() {
): Bitmap.CompressFormat? {
return when (mimeType) {
"image/jpeg" -> Bitmap.CompressFormat.JPEG
"image/png" -> Bitmap.CompressFormat.PNG
"image/png", "image/webp" -> Bitmap.CompressFormat.PNG
else -> null
}
}
@@ -175,11 +175,21 @@ class MainActivity : FlutterActivity() {
muxer = MediaMuxer(outputFile.absolutePath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4)
val trackIndices = mutableMapOf<Int, Int>()
var copiedVideo = false
var copiedAudio = false
for (i in 0 until extractor.trackCount) {
val format = extractor.getTrackFormat(i)
val mime = format.getString(android.media.MediaFormat.KEY_MIME) ?: continue
val isVideo = mime.startsWith("video/")
val isAudio = mime.startsWith("audio/")
if ((!isVideo && !isAudio) || (isVideo && copiedVideo) || (isAudio && copiedAudio)) {
continue
}
val newIndex = muxer.addTrack(format)
trackIndices[i] = newIndex
extractor.selectTrack(i)
copiedVideo = copiedVideo || isVideo
copiedAudio = copiedAudio || isAudio
}
muxer.start()
@@ -189,7 +199,11 @@ class MainActivity : FlutterActivity() {
while (true) {
val sampleSize = extractor.readSampleData(buffer, 0)
if (sampleSize < 0) break
val muxerTrack = trackIndices[extractor.sampleTrackIndex]!!
val muxerTrack = trackIndices[extractor.sampleTrackIndex]
if (muxerTrack == null) {
extractor.advance()
continue
}
bufferInfo.offset = 0
bufferInfo.size = sampleSize
bufferInfo.presentationTimeUs = extractor.sampleTime

View File

@@ -64,6 +64,8 @@ import UserNotifications
sanitizedData = image.pngData()
case "image/jpeg":
sanitizedData = image.jpegData(compressionQuality: 1.0)
case "image/webp":
sanitizedData = image.pngData()
default:
sanitizedData = nil
}
@@ -177,6 +179,7 @@ import UserNotifications
exportSession.outputURL = outputURL
exportSession.outputFileType = .mp4
exportSession.shouldOptimizeForNetworkUse = true
exportSession.metadataItemFilter = AVMetadataItemFilter.forSharing()
exportSession.exportAsynchronously {
switch exportSession.status {

View File

@@ -10,9 +10,11 @@ import 'package:nostr/nostr.dart' as nostr;
import 'package:pointycastle/digests/sha256.dart';
import 'media_auth.dart';
import 'mp4_fast_start.dart';
import 'relay_provider.dart';
const _mediaUploadPath = '/media/upload';
const _mediaUploadPath = '/upload';
const _legacyMediaUploadPath = '/media/upload';
const _mediaUploadPlatformChannelName = 'buzz/media_upload';
const _sanitizeImageForUploadMethod = 'sanitizeImageForUpload';
const _transcodeVideoToMp4Method = 'transcodeVideoToMp4';
@@ -197,16 +199,8 @@ class MediaUploadService {
);
}
// Read first 32 bytes to check if it's already an MP4 container.
final header = await _readFileHeader(pickedVideo.path, 32);
if (_isAlreadyMp4Container(header)) {
// Already MP4 — upload directly.
final bytes = await pickedVideo.readAsBytes();
return uploadBytes(bytes, mimeType: 'video/mp4');
}
// Non-MP4 container (e.g. QuickTime .mov) — remux to MP4 via platform.
// Always rebuild the container. Passing an existing MP4 through would retain
// QuickTime GPS, global metadata, chapters, or non-A/V tracks.
String? transcodedPath;
try {
transcodedPath = await _transcodeVideoToMp4(pickedVideo.path);
@@ -241,14 +235,26 @@ class MediaUploadService {
}
final sha256 = _sha256Hex(bytes);
final request = _buildUploadRequest(
var request = _buildUploadRequest(
bytes: bytes,
mimeType: mimeType,
sha256: sha256,
path: _mediaUploadPath,
);
final streamed = await _http.send(request);
final response = await http.Response.fromStream(streamed);
var streamed = await _http.send(request);
var response = await http.Response.fromStream(streamed);
if (response.statusCode == HttpStatus.notFound ||
response.statusCode == HttpStatus.methodNotAllowed) {
request = _buildUploadRequest(
bytes: bytes,
mimeType: mimeType,
sha256: sha256,
path: _legacyMediaUploadPath,
);
streamed = await _http.send(request);
response = await http.Response.fromStream(streamed);
}
if (response.statusCode < 200 || response.statusCode >= 300) {
throw Exception(
'upload failed (${response.statusCode}): ${response.body}',
@@ -264,11 +270,9 @@ class MediaUploadService {
required Uint8List bytes,
required String mimeType,
required String sha256,
required String path,
}) {
final request = http.Request(
'PUT',
Uri.parse(_baseUrl).resolve(_mediaUploadPath),
);
final request = http.Request('PUT', Uri.parse(_baseUrl).resolve(path));
request.bodyBytes = bytes;
request.headers.addAll(
_buildUploadHeaders(mimeType: mimeType, sha256: sha256),
@@ -500,7 +504,9 @@ bool _isAnimatedWebp(Uint8List bytes) {
bool _shouldSanitizePickedImage(String mimeType) {
return _supportsNativeUploadImageProcessing() &&
(mimeType == 'image/jpeg' || mimeType == 'image/png');
(mimeType == 'image/jpeg' ||
mimeType == 'image/png' ||
mimeType == 'image/webp');
}
bool _supportsNativeUploadImageProcessing() {
@@ -574,36 +580,6 @@ int _readUint32LittleEndian(Uint8List bytes, int offset) {
/// Always returns `video/mp4` — the relay only accepts MP4 and does its own
/// magic-byte validation. Most iPhone `.mov` files are ftyp-isom containers
/// that the relay accepts as MP4.
/// Known MP4 ftyp major brands. If the file's major brand (bytes 8–11)
/// matches one of these, it's already an MP4-compatible container.
const _mp4FtypBrands = {'isom', 'mp41', 'mp42', 'M4V ', 'avc1', 'iso5'};
/// Checks whether [bytes] (at least 12 bytes of file header) represent
/// an MP4-family container by inspecting the ftyp box major brand.
///
/// Exposed for testing as [isAlreadyMp4Container].
@visibleForTesting
bool isAlreadyMp4Container(Uint8List bytes) => _isAlreadyMp4Container(bytes);
bool _isAlreadyMp4Container(Uint8List bytes) {
if (bytes.length < 12) return false;
if (!_matchesAscii(bytes, 4, 'ftyp')) return false;
final brand = ascii.decode(bytes.sublist(8, 12), allowInvalid: true);
return _mp4FtypBrands.contains(brand);
}
/// Reads the first [count] bytes of a file without loading it entirely.
Future<Uint8List> _readFileHeader(String path, int count) async {
final file = File(path);
final raf = await file.open(mode: FileMode.read);
try {
final bytes = await raf.read(count);
return bytes;
} finally {
await raf.close();
}
}
Future<Uint8List?> _readPlatformClipboardImage() async {
return _mediaUploadPlatformChannel.invokeMethod<Uint8List>(
_readClipboardImageMethod,
@@ -618,6 +594,24 @@ Future<String> _transcodePickedVideoToMp4(String filePath) async {
if (result == null || result.isEmpty) {
throw Exception('Failed to convert video to MP4.');
}
if (defaultTargetPlatform == TargetPlatform.android) {
final source = File(result);
final destination = File(
'$result.faststart-${DateTime.now().microsecondsSinceEpoch}.mp4',
);
try {
await rewriteMp4ForFastStart(source, destination);
await source.delete();
return destination.path;
} catch (_) {
try {
await destination.delete();
} on FileSystemException {
// Best-effort cleanup; preserve the original platform error.
}
rethrow;
}
}
return result;
}

View File

@@ -0,0 +1,345 @@
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
const _maxTopLevelBoxes = 4096;
const _maxNestedBoxes = 100000;
const _maxBoxDepth = 32;
const _maxMoovBytes = 64 * 1024 * 1024;
const _copyBufferBytes = 1024 * 1024;
const _uint32Max = 0xffffffff;
const _uint64Max = 0x7fffffffffffffff;
const _containerTypes = {
'moov',
'trak',
'mdia',
'minf',
'stbl',
'edts',
'dinf',
};
final class _Mp4Box {
final int offset;
final int size;
final int headerSize;
final String type;
const _Mp4Box({
required this.offset,
required this.size,
required this.headerSize,
required this.type,
});
}
/// Rewrites [source] to [destination] with `moov` before the first `mdat`.
///
/// Only chunk offsets into the region moved by the relocation are adjusted.
/// The files must be distinct, and malformed or excessively nested inputs fail
/// closed rather than producing a partially valid upload.
Future<void> rewriteMp4ForFastStart(File source, File destination) async {
if (source.absolute.path == destination.absolute.path) {
throw const FormatException(
'MP4 fast-start rewrite requires a distinct destination',
);
}
final input = await source.open();
try {
final boxes = await _readTopLevelBoxes(input);
if (boxes.isEmpty || boxes.first.type != 'ftyp') {
throw const FormatException('MP4 must start with an ftyp box');
}
final moovBoxes = boxes.where((box) => box.type == 'moov').toList();
if (moovBoxes.length != 1) {
throw const FormatException('MP4 must contain exactly one moov box');
}
final moov = moovBoxes.single;
final mdatBoxes = boxes.where((box) => box.type == 'mdat').toList();
final firstMdat = mdatBoxes.isEmpty ? null : mdatBoxes.first;
if (firstMdat == null) {
throw const FormatException('MP4 is missing an mdat box');
}
if (moov.size > _maxMoovBytes) {
throw const FormatException('MP4 moov box is too large');
}
await input.setPosition(moov.offset);
final moovBytes = await input.read(moov.size);
if (moovBytes.length != moov.size) {
throw const FormatException('truncated MP4 moov box');
}
if (moov.offset > firstMdat.offset) {
_patchChunkOffsets(
moovBytes,
moov.headerSize,
moovBytes.length,
firstMdat.offset,
moov.offset,
moov.size,
0,
[0],
);
}
final output = await destination.open(mode: FileMode.write);
try {
var insertedMoov = false;
for (final box in boxes) {
if (box.type == 'moov') continue;
if (!insertedMoov && box.type == 'mdat') {
await output.writeFrom(moovBytes);
insertedMoov = true;
}
await _copyRange(input, output, box.offset, box.size);
}
if (!insertedMoov) {
throw const FormatException('MP4 is missing an mdat box');
}
await output.flush();
} finally {
await output.close();
}
} catch (_) {
try {
await destination.delete();
} on FileSystemException {
// Best-effort cleanup of a partial rewrite.
}
rethrow;
} finally {
await input.close();
}
}
Future<List<_Mp4Box>> _readTopLevelBoxes(RandomAccessFile input) async {
final length = await input.length();
final boxes = <_Mp4Box>[];
var offset = 0;
while (offset < length) {
if (boxes.length >= _maxTopLevelBoxes) {
throw const FormatException('MP4 has too many top-level boxes');
}
final box = await _readFileBoxHeader(input, offset, length);
boxes.add(box);
offset += box.size;
}
if (offset != length) {
throw const FormatException('MP4 boxes do not cover the file');
}
return boxes;
}
Future<_Mp4Box> _readFileBoxHeader(
RandomAccessFile input,
int offset,
int end,
) async {
if (end - offset < 8) {
throw const FormatException('truncated MP4 box header');
}
await input.setPosition(offset);
final compactHeader = await input.read(8);
final compactSize = _readUint32(compactHeader, 0);
final type = latin1.decode(compactHeader.sublist(4, 8));
final int headerSize;
final int size;
if (compactSize == 1) {
if (end - offset < 16) {
throw const FormatException('truncated extended MP4 box header');
}
final extended = await input.read(8);
headerSize = 16;
size = _readUint64(extended, 0);
} else if (compactSize == 0) {
headerSize = 8;
size = end - offset;
} else {
headerSize = 8;
size = compactSize;
}
if (size < headerSize || size > end - offset) {
throw const FormatException('invalid MP4 box size');
}
return _Mp4Box(
offset: offset,
size: size,
headerSize: headerSize,
type: type,
);
}
Future<void> _copyRange(
RandomAccessFile input,
RandomAccessFile output,
int offset,
int size,
) async {
await input.setPosition(offset);
var remaining = size;
while (remaining > 0) {
final bytes = await input.read(
remaining < _copyBufferBytes ? remaining : _copyBufferBytes,
);
if (bytes.isEmpty) {
throw const FormatException('truncated MP4 while copying');
}
await output.writeFrom(bytes);
remaining -= bytes.length;
}
}
void _patchChunkOffsets(
Uint8List bytes,
int start,
int end,
int movedRegionStart,
int movedRegionEnd,
int delta,
int depth,
List<int> boxesSeen,
) {
if (depth > _maxBoxDepth) {
throw const FormatException('MP4 box nesting is too deep');
}
var offset = start;
while (offset < end) {
boxesSeen[0]++;
if (boxesSeen[0] > _maxNestedBoxes) {
throw const FormatException('MP4 has too many nested boxes');
}
final box = _readMemoryBoxHeader(bytes, offset, end);
final boxEnd = offset + box.size;
switch (box.type) {
case 'stco':
_patchStco(
bytes,
offset + box.headerSize,
boxEnd,
movedRegionStart,
movedRegionEnd,
delta,
);
break;
case 'co64':
_patchCo64(
bytes,
offset + box.headerSize,
boxEnd,
movedRegionStart,
movedRegionEnd,
delta,
);
break;
case final type when _containerTypes.contains(type):
_patchChunkOffsets(
bytes,
offset + box.headerSize,
boxEnd,
movedRegionStart,
movedRegionEnd,
delta,
depth + 1,
boxesSeen,
);
break;
}
offset = boxEnd;
}
if (offset != end) {
throw const FormatException('MP4 child boxes do not cover their parent');
}
}
_Mp4Box _readMemoryBoxHeader(Uint8List bytes, int offset, int end) {
if (end - offset < 8) {
throw const FormatException('truncated MP4 child box');
}
final compactSize = _readUint32(bytes, offset);
final type = latin1.decode(bytes.sublist(offset + 4, offset + 8));
final int headerSize;
final int size;
if (compactSize == 1) {
if (end - offset < 16) {
throw const FormatException('truncated extended MP4 child box');
}
headerSize = 16;
size = _readUint64(bytes, offset + 8);
} else if (compactSize == 0) {
headerSize = 8;
size = end - offset;
} else {
headerSize = 8;
size = compactSize;
}
if (size < headerSize || size > end - offset) {
throw const FormatException('invalid MP4 child box size');
}
return _Mp4Box(
offset: offset,
size: size,
headerSize: headerSize,
type: type,
);
}
void _patchStco(
Uint8List bytes,
int start,
int end,
int movedRegionStart,
int movedRegionEnd,
int delta,
) {
if (end - start < 8) throw const FormatException('truncated stco box');
final count = _readUint32(bytes, start + 4);
if (count > (end - start - 8) ~/ 4) {
throw const FormatException('invalid stco entry count');
}
final data = ByteData.sublistView(bytes);
for (var index = 0; index < count; index++) {
final entry = start + 8 + index * 4;
final value = data.getUint32(entry, Endian.big);
if (value >= movedRegionStart && value < movedRegionEnd) {
final adjusted = value + delta;
if (adjusted > _uint32Max) {
throw const FormatException('stco offset overflow');
}
data.setUint32(entry, adjusted, Endian.big);
}
}
}
void _patchCo64(
Uint8List bytes,
int start,
int end,
int movedRegionStart,
int movedRegionEnd,
int delta,
) {
if (end - start < 8) throw const FormatException('truncated co64 box');
final count = _readUint32(bytes, start + 4);
if (count > (end - start - 8) ~/ 8) {
throw const FormatException('invalid co64 entry count');
}
final data = ByteData.sublistView(bytes);
for (var index = 0; index < count; index++) {
final entry = start + 8 + index * 8;
final value = data.getUint64(entry, Endian.big);
if (value >= movedRegionStart && value < movedRegionEnd) {
final adjusted = value + delta;
if (adjusted > _uint64Max) {
throw const FormatException('co64 offset overflow');
}
data.setUint64(entry, adjusted, Endian.big);
}
}
}
int _readUint32(Uint8List bytes, int offset) =>
ByteData.sublistView(bytes).getUint32(offset, Endian.big);
int _readUint64(Uint8List bytes, int offset) =>
ByteData.sublistView(bytes).getUint64(offset, Endian.big);

View File

@@ -6,7 +6,7 @@ import 'package:http/http.dart' as http;
/// WebSocket. This client now exists only to provide a base URL (and a
/// shared HTTP client) for the media upload endpoint, which is the one
/// remaining HTTP path because Blossom uses kind:24242 NIP-98 auth on a
/// regular HTTP POST.
/// regular HTTP PUT.
class RelayClient {
final String baseUrl;
final http.Client _http;
@@ -20,7 +20,7 @@ class RelayClient {
/// Fully-qualified URL for the relay's Blossom-style media upload endpoint.
String get mediaUploadUrl {
final base = Uri.parse(baseUrl);
return base.resolve('/media/upload').toString();
return base.resolve('/upload').toString();
}
void dispose() => _http.close();

View File

@@ -360,7 +360,7 @@ void main() {
expect(capturedRequest, isNotNull);
expect(
capturedRequest!.url.toString(),
'https://relay.example:8443/media/upload',
'https://relay.example:8443/upload',
);
expect(capturedRequest!.headers['Content-Type'], 'image/png');
expect(capturedRequest!.headers['X-SHA-256'], isNotEmpty);
@@ -394,6 +394,50 @@ void main() {
);
});
test(
'retries the legacy upload route when the standard route is absent',
() async {
final requests = <http.Request>[];
final client = http_testing.MockClient((request) async {
requests.add(request);
if (request.url.path == '/upload') {
return http.Response('not found', HttpStatus.notFound);
}
return http.Response(
jsonEncode({
'url': 'https://relay.example/media/test.png',
'sha256': request.headers['X-SHA-256'],
'size': _pngBytes.length,
'type': 'image/png',
'uploaded': 1,
}),
200,
);
});
final service = MediaUploadService(
baseUrl: 'https://relay.example',
nsec: nostr.Keys.generate().nsec,
httpClient: client,
pickGalleryVideo: () async => null,
pickGalleryImage: () async => null,
);
await service.uploadBytes(_pngBytes, mimeType: 'image/png');
expect(requests.map((request) => request.url.path), [
'/upload',
'/media/upload',
]);
expect(requests[1].bodyBytes, requests[0].bodyBytes);
expect(requests[0].headers['Authorization'], startsWith('Nostr '));
expect(requests[1].headers['Authorization'], startsWith('Nostr '));
expect(
requests[1].headers['X-SHA-256'],
requests[0].headers['X-SHA-256'],
);
},
);
test(
'checks clipboard image availability through the platform channel',
() async {
@@ -978,58 +1022,6 @@ void main() {
});
});
group('_isAlreadyMp4Container', () {
// ftyp box: [size(4 bytes)][ftyp(4 bytes)][major_brand(4 bytes)]...
Uint8List buildFtypHeader(String brand) {
final bytes = Uint8List(32);
// Box size = 32
bytes[0] = 0;
bytes[1] = 0;
bytes[2] = 0;
bytes[3] = 32;
// 'ftyp'
bytes[4] = 0x66; // f
bytes[5] = 0x74; // t
bytes[6] = 0x79; // y
bytes[7] = 0x70; // p
// Major brand
final brandBytes = ascii.encode(brand);
for (var i = 0; i < 4 && i < brandBytes.length; i++) {
bytes[8 + i] = brandBytes[i];
}
return bytes;
}
test('returns true for isom brand', () {
expect(isAlreadyMp4Container(buildFtypHeader('isom')), isTrue);
});
test('returns true for mp41 brand', () {
expect(isAlreadyMp4Container(buildFtypHeader('mp41')), isTrue);
});
test('returns true for mp42 brand', () {
expect(isAlreadyMp4Container(buildFtypHeader('mp42')), isTrue);
});
test('returns false for QuickTime qt brand', () {
expect(isAlreadyMp4Container(buildFtypHeader('qt ')), isFalse);
});
test('returns false for too-short header', () {
expect(isAlreadyMp4Container(Uint8List(8)), isFalse);
});
test('returns false when no ftyp box', () {
final bytes = Uint8List(32);
bytes[4] = 0x6D; // m
bytes[5] = 0x6F; // o
bytes[6] = 0x6F; // o
bytes[7] = 0x76; // v
expect(isAlreadyMp4Container(bytes), isFalse);
});
});
group('pickAndUploadVideo', () {
// Helper: build ftyp header bytes for a given brand.
Uint8List buildFtypHeader(String brand) {
@@ -1054,7 +1046,7 @@ void main() {
return (XFile(file.path), file);
}
test('uploads MP4 container directly without transcoding', () async {
test('rebuilds an existing MP4 container before upload', () async {
final keychain = nostr.Keys.generate();
final nsec = keychain.nsec;
var transcodeCalled = false;
@@ -1083,7 +1075,10 @@ void main() {
pickGalleryImage: () async => null,
transcodeVideoToMp4: (path) async {
transcodeCalled = true;
return path;
final outDir = await Directory.systemTemp.createTemp('transcode_');
final outFile = File('${outDir.path}/out.mp4');
await outFile.writeAsBytes(buildFtypHeader('isom'));
return outFile.path;
},
now: () => DateTime.fromMillisecondsSinceEpoch(1_700_000_000_000),
);
@@ -1091,7 +1086,7 @@ void main() {
final descriptor = await service.pickAndUploadVideo();
expect(descriptor, isNotNull);
expect(descriptor!.type, 'video/mp4');
expect(transcodeCalled, isFalse);
expect(transcodeCalled, isTrue);
} finally {
await tempFile.parent.delete(recursive: true);
}

View File

@@ -0,0 +1,145 @@
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:buzz/shared/relay/mp4_fast_start.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
late Directory tempDirectory;
setUp(() async {
tempDirectory = await Directory.systemTemp.createTemp(
'buzz-faststart-test-',
);
});
tearDown(() async {
await tempDirectory.delete(recursive: true);
});
for (final offsetBox in ['stco', 'co64']) {
test('moves moov before mdat and patches $offsetBox offsets', () async {
final ftyp = _box('ftyp', [
...ascii.encode('isom'),
0,
0,
0,
0,
...ascii.encode('isom'),
]);
final mdat = _box('mdat', [1, 2, 3, 4]);
final originalMediaOffset = ftyp.length + 8;
final offsetPayload = BytesBuilder()
..add([0, 0, 0, 0])
..add(_uint32(1))
..add(
offsetBox == 'stco'
? _uint32(originalMediaOffset)
: _uint64(originalMediaOffset),
);
final sampleTable = _box(offsetBox, offsetPayload.takeBytes());
final moov = _nestedMoov(sampleTable);
final sourceBytes = Uint8List.fromList([...ftyp, ...mdat, ...moov]);
final source = File('${tempDirectory.path}/source.mp4');
final destination = File('${tempDirectory.path}/output.mp4');
await source.writeAsBytes(sourceBytes);
await rewriteMp4ForFastStart(source, destination);
final output = await destination.readAsBytes();
expect(_topLevelTypes(output), ['ftyp', 'moov', 'mdat']);
expect(
output.sublist(ftyp.length + moov.length + 8),
equals([1, 2, 3, 4]),
);
final typeOffset = _findAscii(output, offsetBox);
expect(typeOffset, greaterThanOrEqualTo(4));
final entryOffset = typeOffset + 12;
final adjusted = offsetBox == 'stco'
? _readUint32(output, entryOffset)
: _readUint64(output, entryOffset);
expect(adjusted, originalMediaOffset + moov.length);
});
}
test(
'rejects excessive nested box depth and deletes partial output',
() async {
final ftyp = _box('ftyp', [
...ascii.encode('isom'),
0,
0,
0,
0,
...ascii.encode('isom'),
]);
final mdat = _box('mdat', [1]);
var nested = _box('stco', [0, 0, 0, 0, ..._uint32(0)]);
for (var index = 0; index < 34; index++) {
nested = _box('stbl', nested);
}
final source = File('${tempDirectory.path}/deep.mp4');
final destination = File('${tempDirectory.path}/output.mp4');
await source.writeAsBytes([...ftyp, ...mdat, ..._box('moov', nested)]);
await expectLater(
rewriteMp4ForFastStart(source, destination),
throwsA(isA<FormatException>()),
);
expect(await destination.exists(), isFalse);
},
);
}
Uint8List _nestedMoov(List<int> leaf) =>
_box('moov', _box('trak', _box('mdia', _box('minf', _box('stbl', leaf)))));
Uint8List _box(String type, List<int> payload) => Uint8List.fromList([
..._uint32(payload.length + 8),
...ascii.encode(type),
...payload,
]);
Uint8List _uint32(int value) {
final bytes = ByteData(4)..setUint32(0, value, Endian.big);
return bytes.buffer.asUint8List();
}
Uint8List _uint64(int value) {
final bytes = ByteData(8)..setUint64(0, value, Endian.big);
return bytes.buffer.asUint8List();
}
int _readUint32(Uint8List bytes, int offset) =>
ByteData.sublistView(bytes).getUint32(offset, Endian.big);
int _readUint64(Uint8List bytes, int offset) =>
ByteData.sublistView(bytes).getUint64(offset, Endian.big);
List<String> _topLevelTypes(Uint8List bytes) {
final types = <String>[];
var offset = 0;
while (offset < bytes.length) {
final size = _readUint32(bytes, offset);
types.add(ascii.decode(bytes.sublist(offset + 4, offset + 8)));
offset += size;
}
return types;
}
int _findAscii(Uint8List bytes, String value) {
final needle = ascii.encode(value);
for (var offset = 0; offset + needle.length <= bytes.length; offset++) {
var matches = true;
for (var index = 0; index < needle.length; index++) {
if (bytes[offset + index] != needle[index]) {
matches = false;
break;
}
}
if (matches) return offset;
}
return -1;
}

View File

@@ -40,7 +40,7 @@ if [[ -f ".env" ]]; then
set +o allexport
else
# Use defaults matching docker-compose.yml
export DATABASE_URL="postgres://buzz:buzz_dev@localhost:5432/buzz"
export DATABASE_URL="postgres://buzz:buzz_dev@localhost:5432/buzz" # sadscan:disable np.postgres.1
export PGHOST=localhost
export PGPORT=5432
export PGUSER=buzz
@@ -112,9 +112,12 @@ run_integration_tests() {
run_test_step "buzz-db tests" \
cargo test -p buzz-db -- --nocapture
run_test_step "buzz-auth integration tests" \
cargo test -p buzz-auth --test '*' -- --nocapture 2>/dev/null || \
if find crates/buzz-auth/tests -maxdepth 1 -name '*.rs' -print -quit 2>/dev/null | grep -q .; then
run_test_step "buzz-auth integration tests" \
cargo test -p buzz-auth --test '*' -- --nocapture
else
run_test_step "buzz-auth (no integration tests found)" true
fi
run_test_step "workspace integration tests" \
cargo test --test '*' -- --nocapture 2>/dev/null || \