mirror of
https://github.com/block/buzz.git
synced 2026-10-28 21:11:48 +00:00
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:
@@ -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();
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
345
mobile/lib/shared/relay/mp4_fast_start.dart
Normal file
345
mobile/lib/shared/relay/mp4_fast_start.dart
Normal 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);
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
145
mobile/test/shared/relay/mp4_fast_start_test.dart
Normal file
145
mobile/test/shared/relay/mp4_fast_start_test.dart
Normal 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;
|
||||
}
|
||||
@@ -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 || \
|
||||
|
||||
Reference in New Issue
Block a user