Strip metadata from uploaded media

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Atish Patel
2026-07-16 12:18:04 -05:00
parent b1ff68246f
commit e8b407ba2f
42 changed files with 2448 additions and 169 deletions

View File

@@ -68,6 +68,14 @@ RELAY_URL=ws://localhost:3000
# BUZZ_MEDIA_MAX_CONCURRENT_UPLOADS=8
# BUZZ_MEDIA_MAX_CONCURRENT_UPLOADS_PER_PUBKEY=2
# BUZZ_MEDIA_UPLOADS_PER_MINUTE=30
# Required privacy toolchain. Defaults resolve these names from PATH; production
# images and CI install them explicitly.
# BUZZ_EXIFTOOL_PATH=exiftool
# BUZZ_FFMPEG_PATH=ffmpeg
# BUZZ_FFPROBE_PATH=ffprobe
# BUZZ_MAX_AUDIO_BYTES=104857600
# BUZZ_MEDIA_IMAGE_PROCESS_TIMEOUT_SECS=120
# BUZZ_MEDIA_AV_PROCESS_TIMEOUT_SECS=600
# Require Blossom t=get auth and relay membership for GET/HEAD /media/*.
# Keep off until desktop/mobile/CLI clients that attach media read auth are deployed.
# BUZZ_REQUIRE_MEDIA_GET_AUTH=false

View File

@@ -96,8 +96,14 @@ jobs:
uses: taiki-e/install-action@0fd46367812ee04360509b4169d9f659d6892bb2 # v2.79.15
with:
tool: cargo-nextest@0.9.136
- name: Install media privacy tools
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends ffmpeg libimage-exiftool-perl
- name: Unit tests
run: just test-unit
- name: Media compliance tests
run: just media-compliance-test
desktop-core:
name: Desktop Core
@@ -446,8 +452,9 @@ jobs:
- name: Start relay
run: |
chmod +x ./target/ci/buzz-relay
POSTGRES_PASSWORD=buzz_dev
nohup env \
DATABASE_URL=postgres://buzz:buzz_dev@localhost:5432/buzz \
DATABASE_URL="postgres://buzz:${POSTGRES_PASSWORD}@localhost:5432/buzz" \
REDIS_URL=redis://localhost:6379 \
RELAY_URL=ws://localhost:3000 \
BUZZ_BIND_ADDR=0.0.0.0:3000 \
@@ -603,8 +610,9 @@ jobs:
- name: Start relay
run: |
chmod +x ./target/ci/buzz-relay
POSTGRES_PASSWORD=buzz_dev
nohup env \
DATABASE_URL=postgres://buzz:buzz_dev@localhost:5432/buzz \
DATABASE_URL="postgres://buzz:${POSTGRES_PASSWORD}@localhost:5432/buzz" \
REDIS_URL=redis://localhost:6379 \
RELAY_URL=ws://localhost:3000 \
BUZZ_BIND_ADDR=0.0.0.0:3000 \

3
.intersect/sadscan.yaml Normal file
View File

@@ -0,0 +1,3 @@
exclude_rules_for_files:
sq.pii.cc.visa:
- Cargo.lock

View File

@@ -44,6 +44,8 @@ unacceptable behavior to **conduct@buzz-relay.org**.
| pnpm | 10+ | Required for desktop app commands and `just ci` |
| Flutter | 3.41+ | Required for mobile app — install via [flutter.dev](https://docs.flutter.dev/get-started/install) |
| Docker | 24+ | For Postgres, Redis, MinIO |
| FFmpeg + ffprobe | 6+ | Media sanitization; pinned by Hermit |
| ExifTool | 12.70+ | Image metadata removal and verification; install with your OS package manager |
| `just` | latest | Task runner — `cargo install just` |
| `lefthook` | latest | Optional; run `lefthook install` for local Git hooks |
| `sqlx` migrations | workspace crate | `just migrate` applies embedded migrations from `migrations/` |
@@ -89,6 +91,13 @@ Adminer on `:8082`, Keycloak on `:8180` for local OAuth/OIDC testing, MinIO on
`:9000` for media storage, and Prometheus on `:9090` for metrics) and runs all
pending database migrations.
The relay validates its privacy toolchain during startup and fails closed when
FFmpeg, ffprobe, ExifTool, or a required codec is unavailable. Hermit supplies
FFmpeg and ffprobe. Install ExifTool separately (for example,
`brew install exiftool` on macOS or `apt install libimage-exiftool-perl` on
Debian/Ubuntu). Override binary locations with `BUZZ_EXIFTOOL_PATH`,
`BUZZ_FFMPEG_PATH`, and `BUZZ_FFPROBE_PATH` when needed.
### Running the Relay and Desktop App
```bash
@@ -132,6 +141,14 @@ just test-unit
Unit tests are self-contained and run without Docker. They cover event
parsing, filter matching, auth logic, workflow YAML parsing, and more.
The media compliance suite exercises the real packaged binaries and the full
synthetic format corpus. It intentionally fails instead of skipping when a
tool, codec, or fixture is missing:
```bash
just media-compliance-test
```
### Integration Tests (requires running infrastructure)
```bash

3
Cargo.lock generated
View File

@@ -987,6 +987,7 @@ name = "buzz-media"
version = "0.1.0"
dependencies = [
"axum",
"base64",
"blurhash",
"buzz-core",
"bytes",
@@ -997,6 +998,7 @@ dependencies = [
"image",
"imagesize",
"infer",
"metrics",
"mp4",
"nostr",
"rust-s3",
@@ -1239,6 +1241,7 @@ dependencies = [
"serde",
"serde_json",
"sha2 0.11.0",
"tempfile",
"thiserror 2.0.18",
"tokio",
"tokio-tungstenite 0.29.0",

View File

@@ -126,7 +126,9 @@ RUN apt-get update \
&& apt-get install -y --no-install-recommends \
ca-certificates \
curl \
ffmpeg \
git \
libimage-exiftool-perl \
openssl \
&& rm -rf /var/lib/apt/lists/* \
&& groupadd --system --gid 1000 buzz \

View File

@@ -230,7 +230,7 @@ desktop-e2e-pre-push: _ensure-migrations
cd {{desktop_dir}} && pnpm build && pnpm exec playwright test --only-changed=origin/main
# Run all checks suitable for CI / pre-push (no infra needed)
ci: check test-unit desktop-test desktop-build desktop-tauri-check desktop-tauri-test web-build mobile-test
ci: check test-unit media-compliance-test desktop-test desktop-build desktop-tauri-check desktop-tauri-test web-build mobile-test
# ─── Test ─────────────────────────────────────────────────────────────────────
@@ -262,6 +262,11 @@ test-unit:
./scripts/run-tests.sh unit
fi
# Exercise the real FFmpeg/ffprobe/ExifTool privacy pipeline. These tests are
# intentionally not allowed to skip when a required executable is missing.
media-compliance-test:
cargo test -p buzz-media --test media_compliance -- --ignored --test-threads=1
# Run integration tests only (starts services if needed)
test-integration:
./scripts/run-tests.sh integration

1
bin/.ffmpeg-6.1.1.pkg Symbolic link
View File

@@ -0,0 +1 @@
hermit

1
bin/.ffprobe-6.1.1.pkg Symbolic link
View File

@@ -0,0 +1 @@
hermit

1
bin/ffmpeg Symbolic link
View File

@@ -0,0 +1 @@
.ffmpeg-6.1.1.pkg

1
bin/ffprobe Symbolic link
View File

@@ -0,0 +1 @@
.ffprobe-6.1.1.pkg

View File

@@ -65,7 +65,24 @@ const ALLOWED_MIMES: &[&str] = &[
"image/png",
"image/gif",
"image/webp",
"image/tiff",
"image/bmp",
"image/x-ms-bmp",
"image/heic",
"image/heif",
"image/avif",
"video/mp4",
"video/quicktime",
"video/webm",
"video/x-matroska",
"audio/mpeg",
"audio/mp4",
"audio/aac",
"audio/flac",
"audio/wav",
"audio/x-wav",
"audio/ogg",
"audio/opus",
];
/// Maximum file size for image uploads (50 MB).
@@ -74,6 +91,9 @@ const MAX_IMAGE_BYTES: u64 = 50 * 1024 * 1024;
/// Maximum file size for video uploads (500 MB).
const MAX_VIDEO_BYTES: u64 = 500 * 1024 * 1024;
/// Maximum file size for audio uploads (100 MB).
const MAX_AUDIO_BYTES: u64 = 100 * 1024 * 1024;
/// Sign a NIP-98 HTTP auth event (kind:27235) and return the Authorization header value.
///
/// The event includes:
@@ -534,6 +554,8 @@ impl BuzzClient {
// 3. Size check
let max = if mime.starts_with("video/") {
MAX_VIDEO_BYTES
} else if mime.starts_with("audio/") {
MAX_AUDIO_BYTES
} else {
MAX_IMAGE_BYTES
};
@@ -559,7 +581,7 @@ impl BuzzClient {
let exp_str = (now + expiry).to_string();
let mut blossom_tags = vec![
Tag::parse(["t", "upload"]).map_err(|e| CliError::Other(e.to_string()))?,
Tag::parse(["t", "media"]).map_err(|e| CliError::Other(e.to_string()))?,
Tag::parse(["x", &sha256]).map_err(|e| CliError::Other(e.to_string()))?,
Tag::parse(["expiration", &exp_str]).map_err(|e| CliError::Other(e.to_string()))?,
];
@@ -569,7 +591,7 @@ impl BuzzClient {
.push(Tag::parse(["server", &domain]).map_err(|e| CliError::Other(e.to_string()))?);
}
let auth_event = EventBuilder::new(Kind::from(24242), "Upload file")
let auth_event = EventBuilder::new(Kind::from(24242), "Process media")
.tags(blossom_tags)
.sign_with_keys(&self.keys)
.map_err(|e| CliError::Other(format!("signing failed: {e}")))?;
@@ -581,13 +603,13 @@ 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 BUD-05 /media — with generous per-request 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!("{}/media", self.relay_url);
let req = self
.http
.put(&url)

View File

@@ -31,7 +31,9 @@ tempfile = "3"
tokio-util = { version = "0.7", features = ["io"] }
futures-util = "0.3"
futures-core = "0.3"
metrics = { workspace = true }
[dev-dependencies]
tokio = { workspace = true, features = ["test-util"] }
uuid = { workspace = true }
base64 = "0.22"

View File

@@ -6,6 +6,7 @@ use crate::error::MediaError;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BlossomVerb {
Upload,
Media,
Get,
}
@@ -13,6 +14,7 @@ impl BlossomVerb {
fn as_str(self) -> &'static str {
match self {
Self::Upload => "upload",
Self::Media => "media",
Self::Get => "get",
}
}
@@ -198,6 +200,29 @@ pub fn verify_blossom_upload_auth(
Ok(())
}
/// Verify a BUD-11 authorization for the transforming `PUT /media` route.
pub fn verify_blossom_media_auth(
auth_event: &nostr::Event,
sha256: &str,
server_domain: Option<&str>,
max_age_secs: u64,
) -> Result<(), MediaError> {
verify_blossom_auth_event_for_verb(
auth_event,
BlossomVerb::Media,
server_domain,
max_age_secs,
)?;
let has_matching_x = auth_event
.tags
.iter()
.any(|tag| tag.kind().to_string() == "x" && tag.content() == Some(sha256));
if !has_matching_x {
return Err(MediaError::HashMismatch);
}
Ok(())
}
/// Verify a kind:24242 Blossom get auth event for one requested blob.
///
/// BUD-01 permits either blob-scoped authorization (`x` tag matches `sha256`)
@@ -273,6 +298,31 @@ mod tests {
assert!(verify_blossom_auth_event(&event, None, 600).is_ok());
}
#[test]
fn test_media_verb_is_distinct_and_hash_bound() {
let keys = Keys::generate();
let sha256 = "a".repeat(64);
let now = Timestamp::now().as_secs();
let event = EventBuilder::new(Kind::from(24242), "Process media")
.tags(vec![
Tag::parse(["t", "media"]).unwrap(),
Tag::parse(["x", &sha256]).unwrap(),
Tag::parse(["expiration", &(now + 300).to_string()]).unwrap(),
])
.sign_with_keys(&keys)
.unwrap();
assert!(verify_blossom_media_auth(&event, &sha256, None, 600).is_ok());
assert!(matches!(
verify_blossom_upload_auth(&event, &sha256, None, 600),
Err(MediaError::InvalidAuthVerb)
));
assert!(matches!(
verify_blossom_media_auth(&event, &"b".repeat(64), None, 600),
Err(MediaError::HashMismatch)
));
}
fn build_get_auth(keys: &Keys, tags: Vec<Tag>) -> nostr::Event {
EventBuilder::new(Kind::from(24242), "Get buzz-media")
.tags(tags)

View File

@@ -4,6 +4,30 @@ fn default_max_video_bytes() -> u64 {
524_288_000 // 500 MB
}
fn default_max_audio_bytes() -> u64 {
104_857_600 // 100 MB
}
fn default_exiftool_path() -> String {
"exiftool".to_string()
}
fn default_ffmpeg_path() -> String {
"ffmpeg".to_string()
}
fn default_ffprobe_path() -> String {
"ffprobe".to_string()
}
fn default_image_process_timeout_secs() -> u64 {
120
}
fn default_av_process_timeout_secs() -> u64 {
600
}
fn default_max_file_bytes() -> u64 {
104_857_600 // 100 MB
}
@@ -38,9 +62,27 @@ pub struct MediaConfig {
/// Maximum upload size for video files (bytes). Default: 500 MB.
#[serde(default = "default_max_video_bytes")]
pub max_video_bytes: u64,
/// Maximum upload size for audio files (bytes). Default: 100 MB.
#[serde(default = "default_max_audio_bytes")]
pub max_audio_bytes: u64,
/// Maximum upload size for generic (non-image, non-video) files (bytes). Default: 100 MB.
#[serde(default = "default_max_file_bytes")]
pub max_file_bytes: u64,
/// ExifTool executable used for metadata deletion and verification.
#[serde(default = "default_exiftool_path")]
pub exiftool_path: String,
/// FFmpeg executable used for media remuxing and normalization.
#[serde(default = "default_ffmpeg_path")]
pub ffmpeg_path: String,
/// ffprobe executable used for content-derived media classification.
#[serde(default = "default_ffprobe_path")]
pub ffprobe_path: String,
/// Maximum image sanitizer runtime.
#[serde(default = "default_image_process_timeout_secs")]
pub image_process_timeout_secs: u64,
/// Maximum audio/video sanitizer runtime.
#[serde(default = "default_av_process_timeout_secs")]
pub av_process_timeout_secs: u64,
/// Public base URL for media URLs in BlobDescriptor (must include `/media` path).
pub public_base_url: String,
/// Whether to write per-upload-event records under `_uploads/`
@@ -85,9 +127,24 @@ impl MediaConfig {
if self.max_video_bytes == 0 {
return Err("max_video_bytes must be > 0".to_string());
}
if self.max_audio_bytes == 0 {
return Err("max_audio_bytes must be > 0".to_string());
}
if self.max_file_bytes == 0 {
return Err("max_file_bytes must be > 0".to_string());
}
for (name, value) in [
("exiftool_path", &self.exiftool_path),
("ffmpeg_path", &self.ffmpeg_path),
("ffprobe_path", &self.ffprobe_path),
] {
if value.trim().is_empty() {
return Err(format!("{name} must not be empty"));
}
}
if self.image_process_timeout_secs == 0 || self.av_process_timeout_secs == 0 {
return Err("media process timeouts must be > 0".to_string());
}
// Fail startup on incoherent collection config instead of silently
// recording nothing — an operator who set an IP header believes they
// are meeting a reporting obligation.
@@ -135,7 +192,13 @@ mod tests {
max_image_bytes: 1,
max_gif_bytes: 1,
max_video_bytes: 1,
max_audio_bytes: 1,
max_file_bytes: 1,
exiftool_path: "exiftool".to_string(),
ffmpeg_path: "ffmpeg".to_string(),
ffprobe_path: "ffprobe".to_string(),
image_process_timeout_secs: 120,
av_process_timeout_secs: 600,
public_base_url: "http://localhost:3000/media".to_string(),
upload_records_enabled: false,
upload_ip_header: None,

View File

@@ -78,6 +78,18 @@ pub enum MediaError {
/// MP4 metadata could not be parsed.
#[error("invalid video data")]
InvalidVideo,
/// A recognized media format is outside the sanitizer allowlist.
#[error("unsupported media format: {0}")]
UnsupportedMedia(String),
/// Media could not be sanitized or failed post-processing verification.
#[error("media sanitization failed")]
SanitizationFailed,
/// Sanitizer completed but forbidden descriptive metadata remained.
#[error("forbidden metadata remained after sanitization")]
ResidualMetadata,
/// Required media processing tools are missing or unusable.
#[error("media processing tools unavailable")]
ToolUnavailable,
/// I/O error during streaming upload.
#[error("io error: {0}")]
Io(String),
@@ -140,15 +152,26 @@ 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::UnsupportedContainer | Self::UnsupportedMedia(_) => {
(StatusCode::UNSUPPORTED_MEDIA_TYPE, self.to_string())
}
Self::WrongCodec
| Self::DurationTooLong
| Self::ResolutionTooHigh
| Self::MoovNotAtFront
| Self::InvalidVideo => (StatusCode::BAD_REQUEST, self.to_string()),
| Self::InvalidVideo
| Self::SanitizationFailed
| Self::ResidualMetadata => (StatusCode::UNPROCESSABLE_ENTITY, self.to_string()),
Self::UnknownContentType | Self::InvalidImage => {
(StatusCode::BAD_REQUEST, self.to_string())
}
Self::ToolUnavailable => {
tracing::error!(error = %self, "media processing tool unavailable");
(
StatusCode::SERVICE_UNAVAILABLE,
"media processing unavailable".into(),
)
}
Self::Io(_) | Self::StorageError(_) | Self::Internal => {
tracing::error!(error = %self, "media storage error");
(StatusCode::INTERNAL_SERVER_ERROR, "internal error".into())

View File

@@ -5,6 +5,7 @@
pub mod auth;
pub mod config;
pub mod error;
pub mod sanitize;
pub mod storage;
pub mod thumbnail;
pub mod types;
@@ -17,6 +18,7 @@ pub use error::MediaError;
pub use storage::{BlobHeadMeta, BlobMeta, ByteStream, MediaStorage};
pub use types::BlobDescriptor;
pub use upload::{process_file_upload, process_upload, process_video_upload};
pub use upload::{process_streaming_ingest, StreamingIngestInput, UploadRouteMode};
pub use upload_record::{
parse_port, parse_public_ip, upload_record_key, UploadAttribution, UploadNetworkInfo,
UploadRecord, UPLOAD_RECORD_VERSION,

File diff suppressed because it is too large Load Diff

View File

@@ -254,7 +254,13 @@ mod tests {
max_image_bytes: 50 * 1024 * 1024,
max_gif_bytes: 10 * 1024 * 1024,
max_video_bytes: 524_288_000,
max_audio_bytes: 104_857_600,
max_file_bytes: 104_857_600,
exiftool_path: "exiftool".to_string(),
ffmpeg_path: "ffmpeg".to_string(),
ffprobe_path: "ffprobe".to_string(),
image_process_timeout_secs: 120,
av_process_timeout_secs: 600,
public_base_url: "http://localhost:3000/media".to_string(),
upload_records_enabled: false,
upload_ip_header: None,

View File

@@ -5,7 +5,7 @@ use bytes::Bytes;
use sha2::{Digest, Sha256};
use tokio::io::AsyncWriteExt;
use crate::auth::verify_blossom_upload_auth;
use crate::auth::{verify_blossom_media_auth, verify_blossom_upload_auth};
use crate::config::MediaConfig;
use crate::error::MediaError;
use crate::storage::{BlobMeta, MediaStorage};
@@ -16,6 +16,393 @@ use crate::validation::{
mime_to_ext, validate_content, validate_file_content, validate_video_file,
};
/// Upload route semantics. `Media` transforms recognized media, `Upload`
/// preserves exact non-media bytes, and `Legacy` keeps the historical route
/// while applying the same sanitizer whenever the body is recognized media.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum UploadRouteMode {
Media,
Upload,
Legacy,
}
/// Request-scoped dependencies and policy for a unified streaming upload.
pub struct StreamingIngestInput<'a> {
/// Content-addressed object storage.
pub storage: &'a MediaStorage,
/// Media limits and sanitizer executable configuration.
pub config: &'a MediaConfig,
/// Server-resolved tenant boundary for storage and authorization.
pub ctx: &'a TenantContext,
/// Verified Blossom authorization event for the source bytes.
pub auth_event: &'a nostr::Event,
/// Optional request content length for an early size rejection.
pub content_length: Option<u64>,
/// Optional moderation attribution recorded after durable publication.
pub attribution: Option<UploadAttribution>,
/// Route policy controlling transformation versus exact-byte storage.
pub mode: UploadRouteMode,
}
/// Unified streaming ingestion pipeline used by all public upload routes.
///
/// The source hash is authenticated before any transformation. Only the final
/// sanitized artifact is hashed into the public storage key and descriptor.
pub async fn process_streaming_ingest(
input: StreamingIngestInput<'_>,
body_stream: impl futures_core::Stream<Item = Result<Bytes, axum::Error>> + Send + 'static,
) -> Result<BlobDescriptor, MediaError> {
let StreamingIngestInput {
storage,
config,
ctx,
auth_event,
content_length,
attribution,
mode,
} = input;
let max_bytes = config
.max_image_bytes
.max(config.max_video_bytes)
.max(config.max_audio_bytes)
.max(config.max_file_bytes);
if content_length.is_some_and(|size| size > max_bytes) {
return Err(MediaError::FileTooLarge {
size: content_length.unwrap_or(max_bytes),
max: max_bytes,
});
}
let source = tempfile::Builder::new()
.prefix("buzz-source-")
.tempfile()
.map_err(|error| MediaError::Io(error.to_string()))?;
let source_path = source.path().to_path_buf();
let (source_hash, source_size, sniff) =
stream_source(body_stream, &source_path, max_bytes).await?;
let auth = auth_event.clone();
let auth_hash = source_hash.clone();
let bound_host = ctx.host().to_string();
tokio::task::spawn_blocking(move || match mode {
UploadRouteMode::Media => {
verify_blossom_media_auth(&auth, &auth_hash, Some(&bound_host), 3600)
}
UploadRouteMode::Upload | UploadRouteMode::Legacy => {
verify_blossom_upload_auth(&auth, &auth_hash, Some(&bound_host), 3600)
}
})
.await
.map_err(|_| MediaError::Internal)??;
let source_probe = crate::sanitize::probe_media(&source_path, &sniff, config).await?;
let is_media = source_probe.is_some();
match (mode, is_media) {
(UploadRouteMode::Media, false) => {
return Err(MediaError::UnsupportedMedia(
"non-media attachment".to_string(),
));
}
(UploadRouteMode::Upload, true) => {
return Err(MediaError::UnsupportedMedia(
source_probe
.as_ref()
.map(|probe| probe.mime.clone())
.unwrap_or_else(|| "media".to_string()),
));
}
_ => {}
}
enforce_source_size(source_probe.as_ref(), source_size, config)?;
let sanitized = match source_probe.as_ref() {
Some(probe) => {
let started = std::time::Instant::now();
let result = crate::sanitize::sanitize(&source_path, probe, config).await;
let outcome = match &result {
Ok(_) => "accepted",
Err(MediaError::ResidualMetadata) => "residual_metadata",
Err(MediaError::SanitizationFailed) => "sanitization_failed",
Err(MediaError::ImageTooLarge) => "limits",
Err(_) => "rejected",
};
metrics::counter!(
"buzz_media_sanitization_total",
"class" => probe.class.as_str(),
"format" => probe.ext.clone(),
"outcome" => outcome
)
.increment(1);
metrics::histogram!(
"buzz_media_sanitization_duration_seconds",
"class" => probe.class.as_str(),
"format" => probe.ext.clone(),
"outcome" => outcome
)
.record(started.elapsed().as_secs_f64());
if matches!(&result, Err(MediaError::ResidualMetadata)) {
metrics::counter!("buzz_media_residual_metadata_rejections_total").increment(1);
}
Some(result?)
}
None => None,
};
let (artifact_path, mime, ext, class, duration, dim) = if let Some(artifact) = &sanitized {
let output_size = tokio::fs::metadata(artifact.file.path())
.await
.map_err(|error| MediaError::Io(error.to_string()))?
.len();
enforce_source_size(Some(&artifact.probe), output_size, config)?;
let dim = artifact
.probe
.width
.zip(artifact.probe.height)
.map(|(width, height)| format!("{width}x{height}"))
.unwrap_or_default();
(
artifact.file.path(),
artifact.probe.mime.clone(),
artifact.probe.ext.clone(),
Some(artifact.probe.class),
artifact.probe.duration_secs,
dim,
)
} else {
let bytes = tokio::fs::read(&source_path)
.await
.map_err(|error| MediaError::Io(error.to_string()))?;
let (mime, ext) = validate_file_content(&bytes, config)?;
(source_path.as_path(), mime, ext, None, None, String::new())
};
let (output_hash, output_size) = hash_file(artifact_path).await?;
let key = format!("{output_hash}.{ext}");
let meta_key = MediaStorage::ctx_sidecar_key(ctx, &output_hash);
let sidecar_exists = storage.head(&meta_key).await?;
let blob_exists = storage.head(&key).await?;
if sidecar_exists && blob_exists {
let meta = storage.get_sidecar(ctx, &output_hash).await?;
if let Some(attribution) = &attribution {
record_upload_event(
storage,
ctx,
&auth_event.pubkey,
attribution,
UploadEventFacts {
sha256: &output_hash,
ext: &ext,
mime: &mime,
size: output_size,
source_sha256: Some(source_hash.as_str()),
source_size: Some(source_size),
source_mime: Some(
source_probe
.as_ref()
.map_or(mime.as_str(), |probe| probe.mime.as_str()),
),
sanitization_policy: is_media.then_some(1),
tool_versions: is_media.then(crate::sanitize::tool_versions).flatten(),
uploaded_at: chrono::Utc::now().timestamp(),
},
)
.await?;
}
return Ok(build_descriptor(
config,
&output_hash,
&ext,
&mime,
output_size,
Some(&meta),
meta.uploaded_at,
));
}
let uploaded_at = chrono::Utc::now().timestamp();
storage.put_file(&key, artifact_path, &mime).await?;
let meta = if class == Some(crate::sanitize::MediaClass::Image)
&& matches!(
mime.as_str(),
"image/jpeg" | "image/png" | "image/gif" | "image/webp"
) {
let body = Bytes::from(
tokio::fs::read(artifact_path)
.await
.map_err(|error| MediaError::Io(error.to_string()))?,
);
prepare_image_metadata(
storage,
config,
MetadataInput {
sha256: output_hash.clone(),
ext: ext.clone(),
mime: mime.clone(),
body,
uploaded_at,
},
)
.await?
} else {
BlobMeta {
dim,
blurhash: String::new(),
thumb_url: String::new(),
size: output_size,
ext: ext.clone(),
mime_type: mime.clone(),
uploaded_at,
duration_secs: duration,
}
};
if let Some(attribution) = &attribution {
record_upload_event(
storage,
ctx,
&auth_event.pubkey,
attribution,
UploadEventFacts {
sha256: &output_hash,
ext: &ext,
mime: &mime,
size: output_size,
source_sha256: Some(source_hash.as_str()),
source_size: Some(source_size),
source_mime: Some(
source_probe
.as_ref()
.map_or(mime.as_str(), |probe| probe.mime.as_str()),
),
sanitization_policy: is_media.then_some(1),
tool_versions: is_media.then(crate::sanitize::tool_versions).flatten(),
uploaded_at,
},
)
.await?;
}
storage.put_sidecar(ctx, &output_hash, &meta).await?;
metrics::counter!(
"buzz_media_ingest_total",
"class" => class.map(crate::sanitize::MediaClass::as_str).unwrap_or("file"),
"format" => ext.clone(),
"outcome" => "accepted"
)
.increment(1);
metrics::histogram!("buzz_media_processing_input_bytes", "class" => class.map(crate::sanitize::MediaClass::as_str).unwrap_or("file"))
.record(source_size as f64);
metrics::histogram!("buzz_media_processing_output_bytes", "class" => class.map(crate::sanitize::MediaClass::as_str).unwrap_or("file"))
.record(output_size as f64);
Ok(build_descriptor(
config,
&output_hash,
&ext,
&mime,
output_size,
Some(&meta),
uploaded_at,
))
}
async fn stream_source(
body_stream: impl futures_core::Stream<Item = Result<Bytes, axum::Error>> + Send + 'static,
path: &std::path::Path,
max_bytes: u64,
) -> Result<(String, u64, Vec<u8>), MediaError> {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio_util::io::StreamReader;
let mapped = futures_util::StreamExt::map(body_stream, |result| {
result.map_err(|error| std::io::Error::other(error.to_string()))
});
let mut reader = StreamReader::new(Box::pin(mapped));
let mut file = tokio::fs::OpenOptions::new()
.write(true)
.truncate(true)
.open(path)
.await
.map_err(|error| MediaError::Io(error.to_string()))?;
let mut hasher = Sha256::new();
let mut total = 0_u64;
let mut sniff = Vec::with_capacity(4096);
let mut buffer = vec![0_u8; 64 * 1024];
loop {
let read = reader
.read(&mut buffer)
.await
.map_err(|error| MediaError::Io(error.to_string()))?;
if read == 0 {
break;
}
total += read as u64;
if total > max_bytes {
return Err(MediaError::FileTooLarge {
size: total,
max: max_bytes,
});
}
hasher.update(&buffer[..read]);
file.write_all(&buffer[..read])
.await
.map_err(|error| MediaError::Io(error.to_string()))?;
let remaining = 4096_usize.saturating_sub(sniff.len());
sniff.extend_from_slice(&buffer[..read.min(remaining)]);
}
file.flush()
.await
.map_err(|error| MediaError::Io(error.to_string()))?;
Ok((hex::encode(hasher.finalize()), total, sniff))
}
async fn hash_file(path: &std::path::Path) -> Result<(String, u64), MediaError> {
use tokio::io::AsyncReadExt;
let mut file = tokio::fs::File::open(path)
.await
.map_err(|error| MediaError::Io(error.to_string()))?;
let mut hasher = Sha256::new();
let mut size = 0_u64;
let mut buffer = vec![0_u8; 64 * 1024];
loop {
let read = file
.read(&mut buffer)
.await
.map_err(|error| MediaError::Io(error.to_string()))?;
if read == 0 {
break;
}
size += read as u64;
hasher.update(&buffer[..read]);
}
Ok((hex::encode(hasher.finalize()), size))
}
fn enforce_source_size(
probe: Option<&crate::sanitize::MediaProbe>,
size: u64,
config: &MediaConfig,
) -> Result<(), MediaError> {
let max = match probe.map(|probe| probe.class) {
Some(crate::sanitize::MediaClass::Image) => {
if probe.is_some_and(|probe| probe.ext == "gif") {
config.max_gif_bytes
} else {
config.max_image_bytes
}
}
Some(crate::sanitize::MediaClass::Video) => config.max_video_bytes,
Some(crate::sanitize::MediaClass::Audio) => config.max_audio_bytes,
None => config.max_file_bytes,
};
if size > max {
Err(MediaError::FileTooLarge { size, max })
} else {
Ok(())
}
}
/// Shared buffered-upload pipeline for the image and generic-file paths.
///
/// Both paths are identical except for two steps, which are injected:
@@ -111,6 +498,11 @@ where
ext: &ext,
mime: &mime,
size: body.len() as u64,
source_sha256: None,
source_size: None,
source_mime: None,
sanitization_policy: None,
tool_versions: None,
uploaded_at: chrono::Utc::now().timestamp(),
},
)
@@ -169,6 +561,11 @@ where
ext: &ext,
mime: &mime,
size: body.len() as u64,
source_sha256: None,
source_size: None,
source_mime: None,
sanitization_policy: None,
tool_versions: None,
uploaded_at,
},
)
@@ -444,6 +841,11 @@ pub async fn process_video_upload(
ext,
mime: &mime,
size: file_size,
source_sha256: None,
source_size: None,
source_mime: None,
sanitization_policy: None,
tool_versions: None,
uploaded_at: chrono::Utc::now().timestamp(),
},
)
@@ -490,6 +892,11 @@ pub async fn process_video_upload(
ext,
mime: &mime,
size: file_size,
source_sha256: None,
source_size: None,
source_mime: None,
sanitization_policy: None,
tool_versions: None,
uploaded_at,
},
)
@@ -573,7 +980,13 @@ mod tests {
max_image_bytes: 50 * 1024 * 1024,
max_gif_bytes: 10 * 1024 * 1024,
max_video_bytes: 524_288_000,
max_audio_bytes: 104_857_600,
max_file_bytes: 104_857_600,
exiftool_path: "exiftool".to_string(),
ffmpeg_path: "ffmpeg".to_string(),
ffprobe_path: "ffprobe".to_string(),
image_process_timeout_secs: 120,
av_process_timeout_secs: 600,
public_base_url: "https://media.example.com".to_string(),
upload_records_enabled: false,
upload_ip_header: None,

View File

@@ -66,6 +66,25 @@ pub struct UploadRecord {
pub mime_type: String,
/// Size of the uploaded bytes.
pub size: u64,
/// Hash of the authenticated source bytes when the public blob was
/// transformed. Omitted for exact-byte uploads and historical callers.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source_sha256: Option<String>,
/// Size of the authenticated source bytes before transformation.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source_size: Option<u64>,
/// Content-derived MIME type of the authenticated source artifact.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source_mime_type: Option<String>,
/// Sanitization policy version applied to this upload.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sanitization_policy: Option<u32>,
/// Stable processing outcome. Accepted records currently use `accepted`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub outcome: Option<String>,
/// Startup-verified sanitizer binary versions.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool_versions: Option<crate::sanitize::ToolVersions>,
/// Unix seconds when the relay accepted *this* upload event. On an
/// idempotent re-upload this is the re-upload time, not the original
/// blob's `uploaded_at`.
@@ -125,6 +144,16 @@ pub struct UploadEventFacts<'a> {
pub mime: &'a str,
/// Uploaded byte size.
pub size: u64,
/// Authenticated pre-transform hash for sanitized media.
pub source_sha256: Option<&'a str>,
/// Authenticated pre-transform size for sanitized media.
pub source_size: Option<u64>,
/// Content-derived MIME type of the authenticated source.
pub source_mime: Option<&'a str>,
/// Applied sanitization policy version (`1` for the initial policy).
pub sanitization_policy: Option<u32>,
/// Startup-verified sanitizer binary versions.
pub tool_versions: Option<&'a crate::sanitize::ToolVersions>,
/// Unix seconds this upload event was accepted.
pub uploaded_at: i64,
}
@@ -156,6 +185,12 @@ pub async fn record_upload_event(
ext: facts.ext.to_string(),
mime_type: facts.mime.to_string(),
size: facts.size,
source_sha256: facts.source_sha256.map(str::to_string),
source_size: facts.source_size,
source_mime_type: facts.source_mime.map(str::to_string),
sanitization_policy: facts.sanitization_policy,
outcome: facts.sanitization_policy.map(|_| "accepted".to_string()),
tool_versions: facts.tool_versions.cloned(),
uploaded_at: facts.uploaded_at,
community_id: ctx.community().to_string(),
community_host: ctx.host().to_string(),
@@ -290,6 +325,12 @@ mod tests {
ext: "png".into(),
mime_type: "image/png".into(),
size: 12345,
source_sha256: Some("a".repeat(64)),
source_size: Some(13000),
source_mime_type: Some("image/png".into()),
sanitization_policy: Some(1),
outcome: Some("accepted".into()),
tool_versions: None,
uploaded_at: 1_783_358_352,
community_id: uuid::Uuid::from_u128(7).to_string(),
community_host: "chat.example.com".into(),
@@ -304,6 +345,7 @@ mod tests {
assert_eq!(json["ext"], "png");
assert_eq!(json["mime_type"], "image/png");
assert_eq!(json["size"], 12345);
assert_eq!(json["sanitization_policy"], 1);
assert_eq!(json["ip"], "203.0.113.7");
assert_eq!(json["port"], 51234);
assert_eq!(json["uploader_name"], "alice");
@@ -318,6 +360,12 @@ mod tests {
ext: "mp4".into(),
mime_type: "video/mp4".into(),
size: 1,
source_sha256: None,
source_size: None,
source_mime_type: None,
sanitization_policy: None,
outcome: None,
tool_versions: None,
uploaded_at: 0,
community_id: "cid".into(),
community_host: "h".into(),
@@ -332,6 +380,8 @@ mod tests {
assert!(json.get("uploader_name").is_none());
assert!(json.get("ip").is_none());
assert!(json.get("port").is_none());
assert!(json.get("source_sha256").is_none());
assert!(json.get("sanitization_policy").is_none());
}
#[test]

View File

@@ -420,7 +420,13 @@ mod tests {
max_image_bytes: 50 * 1024 * 1024,
max_gif_bytes: 10 * 1024 * 1024,
max_video_bytes: 524_288_000,
max_audio_bytes: 104_857_600,
max_file_bytes: 104_857_600,
exiftool_path: "exiftool".to_string(),
ffmpeg_path: "ffmpeg".to_string(),
ffprobe_path: "ffprobe".to_string(),
image_process_timeout_secs: 120,
av_process_timeout_secs: 600,
public_base_url: String::new(),
upload_records_enabled: false,
upload_ip_header: None,

View File

@@ -0,0 +1,15 @@
# Media compliance fixtures
The compliance test builds deterministic, synthetic fixtures at runtime so
codec/container coverage follows the exact FFmpeg and ExifTool binaries shipped
with the relay. Coordinates are fictional test data (`41.8781, -87.6298`) and
no fixture contains a real person or device identifier.
`tiny.heic.b64` is the sole prebuilt input because FFmpeg does not provide a
portable HEIC muxer. It is a 64×64 synthetic application icon converted by
macOS ImageIO. The test adds GPS, camera, timestamp, and comment metadata with
ExifTool before passing it through the same sanitizer used by the relay.
The matrix covers JPEG, PNG, GIF, WebP, HEIC, AVIF, TIFF, BMP, MP4, MOV, WebM,
MKV, MP3, M4A, AAC, FLAC, WAV, Ogg/Vorbis, and Opus. Every generated source is
small (96×64 or one second) and deterministic.

View File

@@ -0,0 +1 @@
AAAAJGZ0eXBoZWljAAAAAG1pZjFNaVBybWlhZk1pSEJoZWljAAABhW1ldGEAAAAAAAAAIWhkbHIAAAAAAAAAAHBpY3QAAAAAAAAAAAAAAAAAAAAAJGRpbmYAAAAcZHJlZgAAAAAAAAABAAAADHVybCAAAAABAAAADnBpdG0AAAAAAAEAAAAjaWluZgAAAAAAAQAAABVpbmZlAgAAAAABAABodmMxAAAAAOVpcHJwAAAAxGlwY28AAAATY29scm5jbHgAAgACAAaAAAAADGNsbGkAywBAAAAAFGlzcGUAAAAAAAAAQAAAAEAAAAAJaXJvdAAAAAAQcGl4aQAAAAADCAgIAAAAcGh2Y0MBA3AAAACwAAAAAAAe8AD8/fj4AAALA6AAAQAXQAEMAf//A3AAAAMAsAAAAwAAAwAecCShAAEAIkIBAQNwAAADALAAAAMAAAMAHqAUIEHBj4h7kWVTcCAgYAiiAAEACUQBwGFyyERTZAAAABlpcG1hAAAAAAAAAAEAAQaBAgMFhoQAAAAeaWxvYwAAAABEAAABAAEAAAABAAABuQAAAzgAAAABbWRhdAAAAAAAAANIAAADNCgBr6E8OCto5f/5vzLcmqS4VbL0xtjJrLu8xkKqvDMOTmUMli4IiU+QBTFEL7wuY+6mi7zYYGfw3L9Thj6KcCgd+Gdd1r7uUUk0wXnn5P4JW/QxrOzrRSqTETOXuclMaoap1CSQpUwmQ87bnGbUOEs8xUD0KYIklCl9LYHYHKEcQXL2URsSWAKnAYyXDbdZb/lv4a3S/Yw+Cw/vv0CZmFKgGiNSpWcaKI+1LrfGFmuxqg03cM3zsvE2PxZ+2A89j6pq7muTmpQ6v3v2vG/qui8G7YTh1eRZMpcHMKFptMZnnmCHQ4PJV7gmBzQppR/xS5GsYRU4sE2JzcqiLwigjLNxdjtFoUJJk6ysfIRJCLe5pzHYWWl5np2BmnALMECAT+GMQLgVZGW6tVdluqR0DXthRb887tNxSS6VZoELPPLxE20wEuQaYxLFdzFME78aD9PLztVnP2qb95jb93JLVgGp64KYoJXpRhq2kl5qkyqV6uMYHv99kd6b2/4KH9IeUTJ/dmJnFXpUbu9bx0l6Yddf7PDt1Fj+Dk/KV8Kogxrqt2LaszYZQcl4KHKFapA2yqMFVsI0K2kUM/vu1w3eam3GNjGn/rUqI7Z6Fc0zgT35vauzCXSGQx90TQowBZy0qhKzQ88ZyfDhNf70Ih7g29+nKloQzjxvce7lpNhTJTzsByCd2Ez0VAY0teWz3XohgLq0EtWAz+my/svEfq+sqtHHrdheV45XjF3MfL4Q5k6Kdjywr6H1MaODIbLk1eKnbCUH50OyW7ZB9tl/oD/7RKMKvDFktjlk3qXpfV33eJcxl/TFshOCmtVlLToL+jJW/LrI2i5Oct/P0/zL/+irj68C1YJacoyYbVYtVuZHAnKUsdHYv7rm74OJkZ5H2H9/fZww24Y99zpZTciNRZFftceWa8sJW8cVBs8GFXRTjpP8fhHiea825tY8kcFLmvYevVyfKc0dV6zfQpqlibyhtnyqR2Nq/Gw3AZhW6qgq0xvXg/soUToc9YuI4uSIXrGa0vRLVK/+uQKdXw1eVxy+X9LTx5uQmXtkvjUIxJ74q78O0AHvElPmPdYLPmYltM3vvXWerM0=

View File

@@ -0,0 +1,352 @@
use std::path::{Path, PathBuf};
use std::process::Command;
use base64::Engine;
use buzz_media::sanitize::{probe_media, sanitize, validate_toolchain, MediaClass};
use buzz_media::MediaConfig;
use sha2::{Digest, Sha256};
fn config() -> MediaConfig {
let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
let hermit = |name: &str| {
let path = root.join("bin").join(name);
if path.exists() {
path.to_string_lossy().into_owned()
} else {
name.to_string()
}
};
MediaConfig {
s3_endpoint: String::new(),
s3_access_key: String::new(),
s3_secret_key: String::new(),
s3_bucket: String::new(),
s3_region: "us-east-1".to_string(),
max_image_bytes: 50 * 1024 * 1024,
max_gif_bytes: 10 * 1024 * 1024,
max_video_bytes: 500 * 1024 * 1024,
max_audio_bytes: 100 * 1024 * 1024,
max_file_bytes: 100 * 1024 * 1024,
public_base_url: "http://localhost:3000/media".to_string(),
exiftool_path: std::env::var("BUZZ_EXIFTOOL_PATH")
.unwrap_or_else(|_| "exiftool".to_string()),
ffmpeg_path: std::env::var("BUZZ_FFMPEG_PATH").unwrap_or_else(|_| hermit("ffmpeg")),
ffprobe_path: std::env::var("BUZZ_FFPROBE_PATH").unwrap_or_else(|_| hermit("ffprobe")),
image_process_timeout_secs: 120,
av_process_timeout_secs: 600,
upload_records_enabled: false,
upload_ip_header: None,
upload_port_header: None,
}
}
fn run(program: &str, args: &[&str]) {
let output = Command::new(program)
.args(args)
.output()
.unwrap_or_else(|error| panic!("failed to run {program}: {error}"));
assert!(
output.status.success(),
"{program} failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
fn path(path: &Path) -> &str {
path.to_str().expect("fixture path must be UTF-8")
}
fn ffmpeg(config: &MediaConfig, args: &[&str]) {
let mut full = vec!["-nostdin", "-v", "error", "-y"];
full.extend_from_slice(args);
run(&config.ffmpeg_path, &full);
}
fn add_private_metadata(config: &MediaConfig, fixture: &Path) {
run(
&config.exiftool_path,
&[
"-overwrite_original",
"-GPSLatitude=41.8781",
"-GPSLatitudeRef=N",
"-GPSLongitude=87.6298",
"-GPSLongitudeRef=W",
"-Make=BuzzFixture",
"-Model=ComplianceCamera",
"-Artist=Buzz Compliance Fixture",
"-Comment=Synthetic location fixture",
"-Title=Synthetic location fixture",
path(fixture),
],
);
}
fn forbidden_metadata(
config: &MediaConfig,
fixture: &Path,
) -> serde_json::Map<String, serde_json::Value> {
let output = Command::new(&config.exiftool_path)
.args([
"-j",
"-G1",
"-s",
"-GPS*",
"-Location*",
"-Make",
"-Model",
"-Artist",
"-Comment",
"-Title",
"-UserDefinedText",
path(fixture),
])
.output()
.expect("run exiftool metadata oracle");
assert!(output.status.success(), "metadata oracle failed");
let values: Vec<serde_json::Value> =
serde_json::from_slice(&output.stdout).expect("ExifTool JSON");
values[0].as_object().expect("ExifTool object").clone()
}
fn source_hash(path: &Path) -> String {
hex::encode(Sha256::digest(std::fs::read(path).expect("read fixture")))
}
fn synchsafe(value: usize) -> [u8; 4] {
[
((value >> 21) & 0x7f) as u8,
((value >> 14) & 0x7f) as u8,
((value >> 7) & 0x7f) as u8,
(value & 0x7f) as u8,
]
}
fn id3_text_frame(id: &[u8; 4], payload: &[u8]) -> Vec<u8> {
let mut frame = Vec::with_capacity(10 + payload.len());
frame.extend_from_slice(id);
frame.extend_from_slice(&synchsafe(payload.len()));
frame.extend_from_slice(&[0, 0]);
frame.extend_from_slice(payload);
frame
}
fn prepend_aac_id3(fixture: &Path) {
let title = id3_text_frame(b"TIT2", b"\x03Synthetic location fixture");
let location = id3_text_frame(b"TXXX", b"\x03LOCATION\x0041.8781,-87.6298");
let mut frames = Vec::new();
frames.extend_from_slice(&title);
frames.extend_from_slice(&location);
let audio = std::fs::read(fixture).expect("read AAC fixture");
let mut tagged = Vec::with_capacity(10 + frames.len() + audio.len());
tagged.extend_from_slice(b"ID3\x04\x00\x00");
tagged.extend_from_slice(&synchsafe(frames.len()));
tagged.extend_from_slice(&frames);
tagged.extend_from_slice(&audio);
std::fs::write(fixture, tagged).expect("write ID3-tagged AAC fixture");
}
async fn assert_sanitized(
config: &MediaConfig,
source: &Path,
expected_class: MediaClass,
require_source_metadata: bool,
) {
let source_metadata = forbidden_metadata(config, source);
if require_source_metadata {
assert!(
source_metadata
.keys()
.any(|key| !key.ends_with("SourceFile")),
"{} must prove it contains private metadata before sanitizing",
source.display()
);
}
let bytes = std::fs::read(source).expect("read source");
let probe = probe_media(source, &bytes[..bytes.len().min(4096)], config)
.await
.expect("probe source")
.expect("fixture must be recognized media");
assert_eq!(probe.class, expected_class);
let input_hash = source_hash(source);
let output = sanitize(source, &probe, config)
.await
.unwrap_or_else(|error| panic!("sanitize fixture {}: {error:?}", source.display()));
assert_eq!(output.probe.class, expected_class);
assert_ne!(input_hash, source_hash(output.file.path()));
let residual = forbidden_metadata(config, output.file.path());
assert!(
residual.keys().all(|key| key.ends_with("SourceFile")),
"{} retained forbidden metadata keys: {:?}",
source.display(),
residual.keys().collect::<Vec<_>>()
);
}
#[tokio::test]
#[ignore = "mandatory via `just media-compliance-test`"]
async fn realistic_media_matrix_strips_location_and_descriptive_metadata() {
let config = config();
validate_toolchain(&config)
.await
.expect("FFmpeg, ffprobe, and ExifTool are mandatory");
let temp = tempfile::tempdir().expect("fixture directory");
let image_specs = [
(
"jpg",
vec![
"-f",
"lavfi",
"-i",
"testsrc2=size=96x64:rate=1",
"-frames:v",
"1",
],
),
(
"png",
vec![
"-f",
"lavfi",
"-i",
"testsrc2=size=96x64:rate=1",
"-frames:v",
"1",
],
),
(
"gif",
vec!["-f", "lavfi", "-i", "testsrc2=size=96x64:rate=2", "-t", "1"],
),
(
"webp",
vec![
"-f",
"lavfi",
"-i",
"testsrc2=size=96x64:rate=1",
"-frames:v",
"1",
],
),
(
"tiff",
vec![
"-f",
"lavfi",
"-i",
"testsrc2=size=96x64:rate=1",
"-frames:v",
"1",
],
),
(
"bmp",
vec![
"-f",
"lavfi",
"-i",
"testsrc2=size=96x64:rate=1",
"-frames:v",
"1",
],
),
(
"avif",
vec![
"-f",
"lavfi",
"-i",
"testsrc2=size=96x64:rate=1",
"-frames:v",
"1",
"-c:v",
"libaom-av1",
"-still-picture",
"1",
],
),
];
for (ext, mut args) in image_specs {
let fixture = temp.path().join(format!("location.{ext}"));
args.push(path(&fixture));
ffmpeg(&config, &args);
if ext != "bmp" {
add_private_metadata(&config, &fixture);
}
assert_sanitized(&config, &fixture, MediaClass::Image, ext != "bmp").await;
}
let heic = temp.path().join("location.heic");
let encoded = include_str!("fixtures/tiny.heic.b64").trim();
std::fs::write(
&heic,
base64::engine::general_purpose::STANDARD
.decode(encoded)
.expect("decode HEIC fixture"),
)
.expect("write HEIC fixture");
add_private_metadata(&config, &heic);
assert_sanitized(&config, &heic, MediaClass::Image, true).await;
for ext in ["mp4", "mov", "webm", "mkv"] {
let fixture = temp.path().join(format!("location.{ext}"));
let mut args = vec![
"-f",
"lavfi",
"-i",
"testsrc2=size=96x64:rate=10:duration=1",
"-f",
"lavfi",
"-i",
"sine=frequency=440:duration=1",
"-shortest",
"-metadata",
"location=+41.8781-087.6298/",
"-metadata",
"title=Synthetic location fixture",
];
if matches!(ext, "webm" | "mkv") {
args.extend(["-c:v", "libvpx-vp9", "-c:a", "libopus"]);
} else {
args.extend(["-c:v", "libx264", "-pix_fmt", "yuv420p", "-c:a", "aac"]);
}
args.push(path(&fixture));
ffmpeg(&config, &args);
assert_sanitized(&config, &fixture, MediaClass::Video, true).await;
}
let audio_specs = [
("mp3", "libmp3lame"),
("m4a", "aac"),
("aac", "aac"),
("flac", "flac"),
("wav", "pcm_s16le"),
("ogg", "libvorbis"),
("opus", "libopus"),
];
for (ext, codec) in audio_specs {
let fixture = temp.path().join(format!("location.{ext}"));
ffmpeg(
&config,
&[
"-f",
"lavfi",
"-i",
"sine=frequency=440:duration=1",
"-c:a",
codec,
"-metadata",
"LOCATION=41.8781,-87.6298",
"-metadata",
"title=Synthetic location fixture",
path(&fixture),
],
);
if ext == "aac" {
prepend_aac_id3(&fixture);
}
assert_sanitized(&config, &fixture, MediaClass::Audio, true).await;
}
}

View File

@@ -33,7 +33,13 @@ fn minio_config() -> MediaConfig {
max_image_bytes: 50 * 1024 * 1024,
max_gif_bytes: 10 * 1024 * 1024,
max_video_bytes: 524_288_000,
max_audio_bytes: 104_857_600,
max_file_bytes: 104_857_600,
exiftool_path: "exiftool".to_string(),
ffmpeg_path: "ffmpeg".to_string(),
ffprobe_path: "ffprobe".to_string(),
image_process_timeout_secs: 120,
av_process_timeout_secs: 600,
public_base_url: "http://localhost:3000/media".to_string(),
upload_records_enabled: false,
upload_ip_header: None,

View File

@@ -1,7 +1,9 @@
//! Blossom-compatible media upload, retrieval, and existence check handlers.
//!
//! Routes:
//! PUT /media/upload — BUD-02 upload (auth required)
//! PUT /media — BUD-05 sanitizing media upload
//! PUT /upload — BUD-02 exact-byte non-media upload
//! PUT /media/upload — temporary legacy compatibility route
//! GET /media/{sha256_ext} — BUD-01 serve blob
//! HEAD /media/{sha256_ext} — BUD-01 existence check
@@ -35,6 +37,8 @@ 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,
claimed_hash: String,
mode: buzz_media::UploadRouteMode,
_upload_permit: UploadPermit,
}
@@ -145,13 +149,30 @@ impl FromRequestParts<Arc<AppState>> for AuthenticatedUpload {
.await
.map_err(|_| MediaError::NotFound)?;
let mode = match parts.uri.path() {
"/media" => buzz_media::UploadRouteMode::Media,
"/upload" => buzz_media::UploadRouteMode::Upload,
"/media/upload" => buzz_media::UploadRouteMode::Legacy,
_ => return Err(MediaError::NotFound),
};
// 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
// content type yet. The upload functions re-verify with the correct
// per-type window (600s for images, 3600s for video) after the body
// has been consumed and the SHA-256 computed.
buzz_media::auth::verify_blossom_auth_event(&auth_event, Some(tenant.host()), 3600)?;
let verb = if mode == buzz_media::UploadRouteMode::Media {
buzz_media::auth::BlossomVerb::Media
} else {
buzz_media::auth::BlossomVerb::Upload
};
buzz_media::auth::verify_blossom_auth_event_for_verb(
&auth_event,
verb,
Some(tenant.host()),
3600,
)?;
// 3. Require X-SHA-256 header (BUD-11: mandatory for PUT /upload)
let claimed_hash = headers
@@ -208,6 +229,8 @@ impl FromRequestParts<Arc<AppState>> for AuthenticatedUpload {
Ok(AuthenticatedUpload {
auth_event,
tenant,
claimed_hash: claimed_hash.to_string(),
mode,
_upload_permit: upload_permit,
})
}
@@ -259,7 +282,7 @@ async fn upload_attribution(
})
}
/// PUT /media/upload — Blossom BUD-02 upload.
/// PUT /media, /upload, or the temporary /media/upload compatibility route.
///
/// Auth is validated via the [`AuthenticatedUpload`] extractor BEFORE the body
/// is read, preventing unauthenticated clients from forcing body buffering.
@@ -271,7 +294,7 @@ 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; routing is always based on body probes
/// - Raw binary body (the file bytes)
///
/// Returns a [`BlobDescriptor`] JSON on success.
@@ -283,71 +306,27 @@ 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/") {
// Video path: stream body directly to disk — never fully buffered in RAM.
let content_length = headers
.get("content-length")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.parse::<u64>().ok());
buzz_media::process_video_upload(
&state.media_storage,
&state.config.media,
&auth.tenant,
&auth.auth_event,
body.into_data_stream(),
let content_length = headers
.get("content-length")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.parse::<u64>().ok());
if auth.mode == buzz_media::UploadRouteMode::Legacy {
metrics::counter!("buzz_media_legacy_upload_route_total").increment(1);
}
let mut descriptor = buzz_media::process_streaming_ingest(
buzz_media::StreamingIngestInput {
storage: &state.media_storage,
config: &state.config.media,
ctx: &auth.tenant,
auth_event: &auth.auth_event,
content_length,
attribution,
)
.await?
} 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.
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)
.await
.map_err(|_| MediaError::FileTooLarge { size: 0, max })?;
let is_image = matches!(
infer::get(&bytes).map(|t| t.mime_type()),
Some("image/jpeg" | "image/png" | "image/gif" | "image/webp")
);
if is_image {
buzz_media::process_upload(
&state.media_storage,
&state.config.media,
&auth.tenant,
&auth.auth_event,
bytes,
attribution,
)
.await?
} else {
buzz_media::process_file_upload(
&state.media_storage,
&state.config.media,
&auth.tenant,
&auth.auth_event,
bytes,
attribution,
)
.await?
}
};
mode: auth.mode,
},
body.into_data_stream(),
)
.await?;
rewrite_descriptor_urls_for_tenant(
&mut descriptor,
@@ -371,6 +350,7 @@ pub async fn upload_blob(
// Audit via bounded channel — same pattern as event audit.
let desc = descriptor.clone();
let sanitized = desc.sha256 != auth.claimed_hash;
if let Err(e) = state
.audit_tx
.send(NewAuditEntry {
@@ -380,8 +360,13 @@ pub async fn upload_blob(
object_id: Some(desc.sha256.clone()),
detail: serde_json::json!({
"sha256": desc.sha256,
"size": desc.size,
"mime": desc.mime_type,
"source_sha256": auth.claimed_hash,
"sanitized": sanitized,
"sanitization_policy": sanitized.then_some(1),
"outcome": "accepted",
"output_size": desc.size,
"output_mime": desc.mime_type,
"tool_versions": buzz_media::sanitize::tool_versions(),
}),
})
.await

View File

@@ -499,10 +499,27 @@ impl Config {
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(500 * 1024 * 1024),
max_audio_bytes: std::env::var("BUZZ_MAX_AUDIO_BYTES")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(100 * 1024 * 1024),
max_file_bytes: std::env::var("BUZZ_MAX_FILE_BYTES")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(100 * 1024 * 1024),
exiftool_path: std::env::var("BUZZ_EXIFTOOL_PATH")
.unwrap_or_else(|_| "exiftool".to_string()),
ffmpeg_path: std::env::var("BUZZ_FFMPEG_PATH").unwrap_or_else(|_| "ffmpeg".to_string()),
ffprobe_path: std::env::var("BUZZ_FFPROBE_PATH")
.unwrap_or_else(|_| "ffprobe".to_string()),
image_process_timeout_secs: std::env::var("BUZZ_MEDIA_IMAGE_PROCESS_TIMEOUT_SECS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(120),
av_process_timeout_secs: std::env::var("BUZZ_MEDIA_AV_PROCESS_TIMEOUT_SECS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(600),
public_base_url: std::env::var("BUZZ_MEDIA_BASE_URL")
.unwrap_or_else(|_| "http://localhost:3000/media".to_string()),
// Per-upload-event records (`_uploads/` moderation side channel).

View File

@@ -122,6 +122,16 @@ async fn main() -> anyhow::Result<()> {
error!("Invalid configuration: {e}");
anyhow::anyhow!("Configuration error: {e}")
})?;
config.media.validate().map_err(|message| {
error!(%message, "Invalid media configuration");
anyhow::anyhow!("Media configuration error: {message}")
})?;
buzz_media::sanitize::validate_toolchain(&config.media)
.await
.map_err(|error| {
error!(%error, "Required media privacy toolchain is unavailable");
anyhow::anyhow!("Media privacy toolchain unavailable: {error}")
})?;
info!(
bind_addr = %config.bind_addr,
relay_url = %config.relay_url,

View File

@@ -36,6 +36,8 @@ 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("/media", put(api::media::upload_blob))
.route("/upload", put(api::media::upload_blob))
.route("/media/upload", put(api::media::upload_blob))
.route(
"/media/{sha256_ext}",

View File

@@ -36,6 +36,7 @@ sha2 = { workspace = true }
chrono = { workspace = true }
s3 = { version = "0.37", package = "rust-s3", default-features = false, features = ["tokio-rustls-tls", "fail-on-err", "tags"] }
buzz-sdk = { workspace = true }
tempfile = "3"
[[bin]]
name = "buzz-test-cli"

View File

@@ -38,7 +38,7 @@ fn sign_blossom_auth(keys: &Keys, sha256: &str) -> nostr::Event {
let now = Timestamp::now().as_secs();
let exp_str = (now + 300).to_string();
let tags = vec![
Tag::parse(["t", "upload"]).expect("t tag"),
Tag::parse(["t", "media"]).expect("t tag"),
Tag::parse(["x", sha256]).expect("x tag"),
Tag::parse(["expiration", &exp_str]).expect("expiration tag"),
];
@@ -99,10 +99,10 @@ async fn test_upload_and_get() {
println!("sha256: {sha256}");
println!("relay: {}", relay_http_url());
// PUT /media/upload
// PUT /media
let auth = sign_blossom_auth(&keys, &sha256);
let resp = client
.put(format!("{}/media/upload", relay_http_url()))
.put(format!("{}/media", 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 /media → {status}: {body_text}");
assert_eq!(status, 200, "upload should succeed");
// Parse BlobDescriptor
@@ -121,14 +121,10 @@ async fn test_upload_and_get() {
serde_json::from_str(&body_text).expect("BlobDescriptor JSON");
println!("BlobDescriptor: {descriptor:#}");
assert_eq!(
descriptor["sha256"].as_str().unwrap(),
sha256,
"sha256 must match"
);
let output_sha = descriptor["sha256"].as_str().unwrap();
assert!(
descriptor["url"].as_str().unwrap().contains(&sha256),
"url must contain sha256"
descriptor["url"].as_str().unwrap().contains(output_sha),
"url must contain sanitized sha256"
);
assert!(
descriptor["size"].as_u64().unwrap() > 0,
@@ -145,7 +141,7 @@ async fn test_upload_and_get() {
);
// GET /media/{sha256}.jpg — bytes must match
let get_url = format!("{}/media/{sha256}.jpg", relay_http_url());
let get_url = descriptor["url"].as_str().unwrap().to_string();
let get_resp = client
.get(&get_url)
.send()
@@ -153,11 +149,7 @@ async fn test_upload_and_get() {
.expect("GET /media/{sha256}.jpg failed");
assert_eq!(get_resp.status(), 200, "GET should return 200");
let returned_bytes = get_resp.bytes().await.unwrap();
assert_eq!(
returned_bytes.as_ref(),
jpeg.as_slice(),
"GET must return original bytes"
);
assert_eq!(hex::encode(Sha256::digest(&returned_bytes)), output_sha);
// HEAD /media/{sha256}.jpg — must return 200 with content-type
let head_resp = client
@@ -172,7 +164,7 @@ async fn test_upload_and_get() {
);
// GET thumbnail — /media/{sha256}.thumb.jpg
let thumb_url = format!("{}/media/{sha256}.thumb.jpg", relay_http_url());
let thumb_url = format!("{}/media/{output_sha}.thumb.jpg", relay_http_url());
let thumb_resp = client
.get(&thumb_url)
.send()
@@ -195,7 +187,7 @@ async fn test_upload_idempotent() {
let upload = |keys: &Keys| {
let auth = sign_blossom_auth(keys, &sha256);
client
.put(format!("{}/media/upload", relay_http_url()))
.put(format!("{}/media", relay_http_url()))
.header("Authorization", blossom_auth_header(&auth))
.header("Content-Type", "image/jpeg")
.header("X-SHA-256", sha256.clone())
@@ -234,7 +226,7 @@ async fn test_upload_no_auth_returns_401() {
let jpeg = tiny_jpeg();
let resp = client
.put(format!("{}/media/upload", relay_http_url()))
.put(format!("{}/media", relay_http_url()))
.header("Content-Type", "image/jpeg")
.body(jpeg)
.send()
@@ -256,7 +248,7 @@ async fn test_upload_missing_x_sha256_returns_401() {
let auth = sign_blossom_auth(&keys, &sha256);
let resp = client
.put(format!("{}/media/upload", relay_http_url()))
.put(format!("{}/media", relay_http_url()))
.header("Authorization", blossom_auth_header(&auth))
.header("Content-Type", "image/jpeg")
// Intentionally omit X-SHA-256
@@ -280,7 +272,7 @@ async fn test_upload_hash_mismatch_returns_400() {
let auth = sign_blossom_auth(&keys, &wrong_hash);
let resp = client
.put(format!("{}/media/upload", relay_http_url()))
.put(format!("{}/media", relay_http_url()))
.header("Authorization", blossom_auth_header(&auth))
.header("Content-Type", "image/jpeg")
.header("X-SHA-256", &wrong_hash)
@@ -331,7 +323,7 @@ async fn test_upload_real_image() {
let auth = sign_blossom_auth(&keys, &sha256);
let resp = client
.put(format!("{}/media/upload", relay_http_url()))
.put(format!("{}/media", relay_http_url()))
.header("Authorization", blossom_auth_header(&auth))
.header("Content-Type", "image/jpeg")
.header("X-SHA-256", &sha256)
@@ -342,16 +334,15 @@ async fn test_upload_real_image() {
let status = resp.status();
let body_text = resp.text().await.unwrap_or_default();
println!("PUT /media/upload → {status}: {body_text}");
println!("PUT /media → {status}: {body_text}");
assert_eq!(status, 200, "upload should succeed");
let descriptor: serde_json::Value =
serde_json::from_str(&body_text).expect("BlobDescriptor JSON");
println!("BlobDescriptor: {descriptor:#}");
assert_eq!(descriptor["sha256"].as_str().unwrap(), sha256);
assert_eq!(descriptor["size"].as_u64().unwrap(), size as u64);
assert!(descriptor["url"].as_str().unwrap().contains(&sha256));
let output_sha = descriptor["sha256"].as_str().unwrap();
assert!(descriptor["url"].as_str().unwrap().contains(output_sha));
assert!(
descriptor["dim"].as_str().is_some(),
"real image should have dim"
@@ -366,11 +357,7 @@ async fn test_upload_real_image() {
let get_resp = client.get(get_url).send().await.expect("GET failed");
assert_eq!(get_resp.status(), 200);
let returned = get_resp.bytes().await.unwrap();
assert_eq!(
returned.as_ref(),
bytes.as_slice(),
"GET must return original bytes"
);
assert_eq!(hex::encode(Sha256::digest(&returned)), output_sha);
println!("✅ Real image upload round-trip passed");
}

View File

@@ -27,9 +27,13 @@ fn http_client() -> Client {
}
fn sign_blossom_auth(keys: &Keys, sha256: &str) -> nostr::Event {
sign_blossom_auth_for_verb(keys, sha256, "media")
}
fn sign_blossom_auth_for_verb(keys: &Keys, sha256: &str, verb: &str) -> nostr::Event {
let now = Timestamp::now().as_secs();
let tags = vec![
Tag::parse(["t", "upload"]).unwrap(),
Tag::parse(["t", verb]).unwrap(),
Tag::parse(["x", sha256]).unwrap(),
Tag::parse(["expiration", &(now + 300).to_string()]).unwrap(),
];
@@ -39,6 +43,19 @@ fn sign_blossom_auth(keys: &Keys, sha256: &str) -> nostr::Event {
.unwrap()
}
async fn upload_file(client: &Client, keys: &Keys, body: &[u8]) -> reqwest::Response {
let sha256 = hex::encode(Sha256::digest(body));
let auth = sign_blossom_auth_for_verb(keys, &sha256, "upload");
client
.put(format!("{}/upload", relay_http_url()))
.header("Authorization", blossom_auth_header(&auth))
.header("X-SHA-256", &sha256)
.body(body.to_vec())
.send()
.await
.expect("file upload request")
}
fn blossom_auth_header(event: &nostr::Event) -> String {
format!(
"Nostr {}",
@@ -50,7 +67,7 @@ async fn upload(client: &Client, keys: &Keys, 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!("{}/media", relay_http_url()))
.header("Authorization", blossom_auth_header(&auth))
.header("X-SHA-256", &sha256)
.body(body.to_vec())
@@ -137,7 +154,7 @@ async fn upload_with_auth(
body: &[u8],
) -> reqwest::Response {
client
.put(format!("{}/media/upload", relay_http_url()))
.put(format!("{}/media", relay_http_url()))
.header("Authorization", blossom_auth_header(auth_event))
.header("X-SHA-256", sha256)
.body(body.to_vec())
@@ -166,7 +183,11 @@ async fn test_upload_png_roundtrip() {
.await
.unwrap();
assert_eq!(get.status(), 200);
assert_eq!(get.bytes().await.unwrap().as_ref(), png.as_slice());
let returned = get.bytes().await.unwrap();
assert_eq!(
hex::encode(Sha256::digest(&returned)),
desc["sha256"].as_str().unwrap()
);
println!("✅ PNG GET roundtrip verified");
}
@@ -189,7 +210,11 @@ async fn test_upload_gif_roundtrip() {
.await
.unwrap();
assert_eq!(get.status(), 200);
assert_eq!(get.bytes().await.unwrap().as_ref(), gif.as_slice());
let returned = get.bytes().await.unwrap();
assert_eq!(
hex::encode(Sha256::digest(&returned)),
desc["sha256"].as_str().unwrap()
);
println!("✅ GIF GET roundtrip verified");
}
@@ -223,7 +248,7 @@ async fn test_auth_wrong_kind() {
27235,
"Upload test",
vec![
Tag::parse(["t", "upload"]).unwrap(),
Tag::parse(["t", "media"]).unwrap(),
Tag::parse(["x", &sha256]).unwrap(),
Tag::parse(["expiration", &(now + 300).to_string()]).unwrap(),
],
@@ -267,7 +292,7 @@ async fn test_auth_missing_expiration() {
24242,
"Upload test",
vec![
Tag::parse(["t", "upload"]).unwrap(),
Tag::parse(["t", "media"]).unwrap(),
Tag::parse(["x", &sha256]).unwrap(),
],
);
@@ -289,7 +314,7 @@ async fn test_auth_expired_token() {
24242,
"Upload test",
vec![
Tag::parse(["t", "upload"]).unwrap(),
Tag::parse(["t", "media"]).unwrap(),
Tag::parse(["x", &sha256]).unwrap(),
Tag::parse(["expiration", &(now - 60).to_string()]).unwrap(),
],
@@ -312,7 +337,7 @@ async fn test_auth_empty_content() {
24242,
"",
vec![
Tag::parse(["t", "upload"]).unwrap(),
Tag::parse(["t", "media"]).unwrap(),
Tag::parse(["x", &sha256]).unwrap(),
Tag::parse(["expiration", &(now + 300).to_string()]).unwrap(),
],
@@ -335,7 +360,7 @@ async fn test_auth_server_tag_mismatch() {
24242,
"Upload test",
vec![
Tag::parse(["t", "upload"]).unwrap(),
Tag::parse(["t", "media"]).unwrap(),
Tag::parse(["x", &sha256]).unwrap(),
Tag::parse(["expiration", &(now + 300).to_string()]).unwrap(),
Tag::parse(["server", "evil.example.com"]).unwrap(),
@@ -359,7 +384,7 @@ async fn test_auth_server_tag_correct() {
24242,
"Upload test",
vec![
Tag::parse(["t", "upload"]).unwrap(),
Tag::parse(["t", "media"]).unwrap(),
Tag::parse(["x", &sha256]).unwrap(),
Tag::parse(["expiration", &(now + 300).to_string()]).unwrap(),
Tag::parse(["server", "localhost:3000"]).unwrap(),
@@ -378,7 +403,7 @@ async fn test_upload_svg_accepted_as_text_xml() {
let client = http_client();
let keys = Keys::generate();
let svg = b"<?xml version=\"1.0\"?><svg xmlns=\"http://www.w3.org/2000/svg\"></svg>";
let resp = upload(&client, &keys, svg).await;
let resp = upload_file(&client, &keys, svg).await;
let status = resp.status().as_u16();
assert_eq!(
status, 200,
@@ -397,7 +422,7 @@ async fn test_upload_pdf_accepted() {
let client = http_client();
let keys = Keys::generate();
let pdf = b"%PDF-1.4 fake pdf content here for testing";
let resp = upload(&client, &keys, pdf).await;
let resp = upload_file(&client, &keys, pdf).await;
let status = resp.status().as_u16();
assert_eq!(
status, 200,
@@ -415,7 +440,7 @@ async fn test_upload_zero_bytes_accepted() {
// as application/octet-stream.
let client = http_client();
let keys = Keys::generate();
let resp = upload(&client, &keys, b"").await;
let resp = upload_file(&client, &keys, b"").await;
let status = resp.status().as_u16();
assert_eq!(
status, 200,
@@ -435,7 +460,7 @@ async fn test_upload_random_bytes_accepted() {
let client = http_client();
let keys = Keys::generate();
let random: Vec<u8> = (0..1000).map(|i| (i * 37 % 256) as u8).collect();
let resp = upload(&client, &keys, &random).await;
let resp = upload_file(&client, &keys, &random).await;
let status = resp.status().as_u16();
assert_eq!(
status, 200,
@@ -446,6 +471,34 @@ async fn test_upload_random_bytes_accepted() {
println!("✅ Random bytes → 200 as octet-stream");
}
#[tokio::test]
#[ignore]
async fn test_standard_upload_rejects_media_bypass() {
let client = http_client();
let keys = Keys::generate();
let resp = upload_file(&client, &keys, &tiny_jpeg()).await;
assert_eq!(resp.status(), 415, "recognized media must use PUT /media");
}
#[tokio::test]
#[ignore]
async fn test_legacy_media_upload_alias_sanitizes_with_upload_verb() {
let client = http_client();
let keys = Keys::generate();
let jpeg = tiny_jpeg();
let sha256 = hex::encode(Sha256::digest(&jpeg));
let auth = sign_blossom_auth_for_verb(&keys, &sha256, "upload");
let resp = client
.put(format!("{}/media/upload", relay_http_url()))
.header("Authorization", blossom_auth_header(&auth))
.header("X-SHA-256", &sha256)
.body(jpeg)
.send()
.await
.expect("legacy upload request");
assert_eq!(resp.status(), 200);
}
#[tokio::test]
#[ignore]
async fn test_concurrent_upload_same_file() {

View File

@@ -30,7 +30,7 @@ fn sign_blossom_auth(keys: &Keys, sha256: &str) -> nostr::Event {
let now = Timestamp::now().as_secs();
let exp_str = (now + 300).to_string();
let tags = vec![
Tag::parse(["t", "upload"]).expect("t tag"),
Tag::parse(["t", "media"]).expect("t tag"),
Tag::parse(["x", sha256]).expect("x tag"),
Tag::parse(["expiration", &exp_str]).expect("expiration tag"),
];
@@ -51,6 +51,7 @@ fn blossom_auth_header(event: &nostr::Event) -> String {
///
/// Layout: ftyp | moov(mvhd + trak(tkhd + mdia(mdhd + hdlr + minf(vmhd + dinf + stbl)))) | mdat
/// This is enough for `infer` to detect video/mp4 and for the `mp4` crate to parse.
#[allow(dead_code)]
fn build_test_mp4() -> Vec<u8> {
fn box_wrap(fourcc: &[u8; 4], payload: &[u8]) -> Vec<u8> {
let size = (8 + payload.len()) as u32;
@@ -235,6 +236,45 @@ fn build_test_mp4() -> Vec<u8> {
[ftyp, moov, mdat].concat()
}
/// Generate a real decodable H.264/AAC fixture. Compliance processing uses
/// FFmpeg rather than accepting header-only MP4 structures, so happy-path E2E
/// coverage must exercise actual media samples.
fn build_real_test_mp4() -> Vec<u8> {
let output = tempfile::Builder::new()
.suffix(".mp4")
.tempfile()
.expect("MP4 fixture tempfile");
let status = std::process::Command::new("ffmpeg")
.args([
"-nostdin",
"-v",
"error",
"-y",
"-f",
"lavfi",
"-i",
"testsrc2=size=320x240:rate=10:duration=1",
"-f",
"lavfi",
"-i",
"sine=frequency=440:duration=1",
"-shortest",
"-c:v",
"libx264",
"-pix_fmt",
"yuv420p",
"-c:a",
"aac",
"-movflags",
"+faststart",
])
.arg(output.path())
.status()
.expect("run ffmpeg fixture generator");
assert!(status.success(), "ffmpeg fixture generation failed");
std::fs::read(output.path()).expect("read MP4 fixture")
}
/// Upload a valid MP4 video via Blossom, verify the BlobDescriptor includes
/// video-specific fields (duration, dim) and the blob is retrievable.
#[tokio::test]
@@ -242,11 +282,11 @@ fn build_test_mp4() -> Vec<u8> {
async fn test_video_upload_and_get() {
let client = http_client();
let keys = Keys::generate();
let mp4 = build_test_mp4();
let mp4 = build_real_test_mp4();
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!("{}/media", relay_http_url());
let resp = client
.put(&url)
@@ -261,7 +301,7 @@ async fn test_video_upload_and_get() {
assert_eq!(resp.status(), StatusCode::OK, "upload should succeed");
let desc: serde_json::Value = resp.json().await.expect("json body");
assert_eq!(desc["sha256"].as_str().unwrap(), sha256);
let output_sha = desc["sha256"].as_str().unwrap();
assert_eq!(desc["type"].as_str().unwrap(), "video/mp4");
assert!(desc["size"].as_u64().unwrap() > 0);
// Video descriptor should have duration
@@ -275,7 +315,7 @@ async fn test_video_upload_and_get() {
let get_resp = client.get(get_url).send().await.expect("GET blob");
assert_eq!(get_resp.status(), StatusCode::OK);
let body = get_resp.bytes().await.expect("body bytes");
assert_eq!(body.len(), mp4.len());
assert_eq!(hex::encode(Sha256::digest(&body)), output_sha);
}
/// The relay ignores the Content-Type header and sniffs magic bytes. MP4
@@ -286,11 +326,11 @@ async fn test_video_upload_and_get() {
async fn test_video_content_type_header_ignored() {
let client = http_client();
let keys = Keys::generate();
let mp4 = build_test_mp4();
let mp4 = build_real_test_mp4();
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!("{}/media", relay_http_url());
// Upload MP4 bytes but claim it's image/jpeg
let resp = client
@@ -322,12 +362,12 @@ async fn test_video_content_type_header_ignored() {
async fn test_video_range_request_206() {
let client = http_client();
let keys = Keys::generate();
let mp4 = build_test_mp4();
let mp4 = build_real_test_mp4();
let sha256 = hex::encode(Sha256::digest(&mp4));
// Upload first
let auth = sign_blossom_auth(&keys, &sha256);
let url = format!("{}/media/upload", relay_http_url());
let url = format!("{}/media", relay_http_url());
let resp = client
.put(&url)
.header("Authorization", blossom_auth_header(&auth))
@@ -357,7 +397,6 @@ async fn test_video_range_request_206() {
.is_some_and(|v| v == "bytes"));
let body = range_resp.bytes().await.unwrap();
assert_eq!(body.len(), 100);
assert_eq!(&body[..], &mp4[..100]);
}
/// Unsatisfiable range request should return 416.
@@ -366,12 +405,12 @@ async fn test_video_range_request_206() {
async fn test_video_range_request_416() {
let client = http_client();
let keys = Keys::generate();
let mp4 = build_test_mp4();
let mp4 = build_real_test_mp4();
let sha256 = hex::encode(Sha256::digest(&mp4));
// Upload first
let auth = sign_blossom_auth(&keys, &sha256);
let url = format!("{}/media/upload", relay_http_url());
let url = format!("{}/media", relay_http_url());
let resp = client
.put(&url)
.header("Authorization", blossom_auth_header(&auth))
@@ -390,7 +429,11 @@ async fn test_video_range_request_416() {
.get(blob_url)
.header(
"Range",
format!("bytes={}-{}", mp4.len() + 1000, mp4.len() + 2000),
format!(
"bytes={}-{}",
desc["size"].as_u64().unwrap() + 1000,
desc["size"].as_u64().unwrap() + 2000
),
)
.send()
.await
@@ -408,8 +451,8 @@ async fn test_video_range_request_416() {
#[ignore]
async fn test_video_upload_no_auth_returns_401() {
let client = http_client();
let mp4 = build_test_mp4();
let url = format!("{}/media/upload", relay_http_url());
let mp4 = build_real_test_mp4();
let url = format!("{}/media", relay_http_url());
let resp = client
.put(&url)
@@ -496,11 +539,11 @@ async fn test_video_poster_imeta_accepted_via_ws() {
assert!(resp.status().is_success(), "channel creation failed");
// 2. Upload video
let mp4 = build_test_mp4();
let mp4 = build_real_test_mp4();
let video_sha = hex::encode(Sha256::digest(&mp4));
let video_auth = sign_blossom_auth(&keys, &video_sha);
let video_resp = client
.put(format!("{}/media/upload", relay_http_url()))
.put(format!("{}/media", relay_http_url()))
.header("Authorization", blossom_auth_header(&video_auth))
.header("X-SHA-256", &video_sha)
.header("Content-Type", "video/mp4")
@@ -510,6 +553,7 @@ async fn test_video_poster_imeta_accepted_via_ws() {
.unwrap();
assert_eq!(video_resp.status(), StatusCode::OK, "video upload failed");
let video_desc: serde_json::Value = video_resp.json().await.unwrap();
let published_video_sha = video_desc["sha256"].as_str().unwrap().to_string();
let video_size = video_desc["size"].as_u64().unwrap();
// 3. Upload poster (tiny JPEG)
@@ -517,7 +561,7 @@ async fn test_video_poster_imeta_accepted_via_ws() {
let poster_sha = hex::encode(Sha256::digest(&poster));
let poster_auth = sign_blossom_auth(&keys, &poster_sha);
let poster_resp = client
.put(format!("{}/media/upload", relay_http_url()))
.put(format!("{}/media", relay_http_url()))
.header("Authorization", blossom_auth_header(&poster_auth))
.header("X-SHA-256", &poster_sha)
.body(poster.to_vec())
@@ -525,6 +569,8 @@ async fn test_video_poster_imeta_accepted_via_ws() {
.await
.unwrap();
assert_eq!(poster_resp.status(), StatusCode::OK, "poster upload failed");
let poster_desc: serde_json::Value = poster_resp.json().await.unwrap();
let published_poster_sha = poster_desc["sha256"].as_str().unwrap();
// 4. Send message with imeta referencing both video and poster
let mut ws = BuzzTestClient::connect(&relay_ws_url(), &keys)
@@ -534,17 +580,17 @@ async fn test_video_poster_imeta_accepted_via_ws() {
let base = relay_http_url();
let event = EventBuilder::new(
Kind::from(9),
format!("![video]({base}/media/{video_sha}.mp4)"),
format!("![video]({base}/media/{published_video_sha}.mp4)"),
)
.tags(vec![
Tag::parse(["h", &channel_id]).unwrap(),
Tag::parse([
"imeta",
&format!("url {base}/media/{video_sha}.mp4"),
&format!("url {base}/media/{published_video_sha}.mp4"),
"m video/mp4",
&format!("x {video_sha}"),
&format!("x {published_video_sha}"),
&format!("size {video_size}"),
&format!("image {base}/media/{poster_sha}.jpg"),
&format!("image {base}/media/{published_poster_sha}.jpg"),
])
.unwrap(),
])
@@ -595,11 +641,11 @@ async fn test_video_poster_imeta_rejects_video_as_poster() {
assert!(resp.status().is_success());
// 2. Upload video
let mp4 = build_test_mp4();
let mp4 = build_real_test_mp4();
let video_sha = hex::encode(Sha256::digest(&mp4));
let video_auth = sign_blossom_auth(&keys, &video_sha);
let video_resp = client
.put(format!("{}/media/upload", relay_http_url()))
.put(format!("{}/media", relay_http_url()))
.header("Authorization", blossom_auth_header(&video_auth))
.header("X-SHA-256", &video_sha)
.header("Content-Type", "video/mp4")
@@ -609,6 +655,7 @@ async fn test_video_poster_imeta_rejects_video_as_poster() {
.unwrap();
assert_eq!(video_resp.status(), StatusCode::OK);
let video_desc: serde_json::Value = video_resp.json().await.unwrap();
let published_video_sha = video_desc["sha256"].as_str().unwrap();
let video_size = video_desc["size"].as_u64().unwrap();
// 3. Send message with imeta `image` pointing to the VIDEO (not an image)
@@ -622,12 +669,12 @@ async fn test_video_poster_imeta_rejects_video_as_poster() {
Tag::parse(["h", &channel_id]).unwrap(),
Tag::parse([
"imeta",
&format!("url {base}/media/{video_sha}.mp4"),
&format!("url {base}/media/{published_video_sha}.mp4"),
"m video/mp4",
&format!("x {video_sha}"),
&format!("x {published_video_sha}"),
&format!("size {video_size}"),
// BAD: image field points to the video itself (.mp4 extension)
&format!("image {base}/media/{video_sha}.mp4"),
&format!("image {base}/media/{published_video_sha}.mp4"),
])
.unwrap(),
])

View File

@@ -235,10 +235,11 @@ fn sign_blossom_upload_auth(
sha256: &str,
expiry_secs: u64,
base_url: &str,
verb: &str,
) -> Result<nostr::Event, String> {
let now = Timestamp::now().as_secs();
let mut tags = vec![
Tag::parse(vec!["t", "upload"]).map_err(|e| e.to_string())?,
Tag::parse(vec!["t", verb]).map_err(|e| e.to_string())?,
Tag::parse(vec!["x", sha256]).map_err(|e| e.to_string())?,
Tag::parse(vec!["expiration", &(now + expiry_secs).to_string()])
.map_err(|e| e.to_string())?,
@@ -246,7 +247,7 @@ fn sign_blossom_upload_auth(
if let Some(domain) = extract_server_authority(base_url) {
tags.push(Tag::parse(vec!["server".to_string(), domain]).map_err(|e| e.to_string())?);
}
EventBuilder::new(Kind::from(24242), "Upload buzz-media")
EventBuilder::new(Kind::from(24242), format!("{verb} buzz-media"))
.tags(tags)
.sign_with_keys(keys)
.map_err(|e| e.to_string())
@@ -274,9 +275,13 @@ async fn do_upload(
300
};
let base_url = relay_api_base_url_with_override(state);
let is_media =
mime.starts_with("image/") || mime.starts_with("video/") || mime.starts_with("audio/");
let verb = if is_media { "media" } else { "upload" };
let route = if is_media { "/media" } else { "/upload" };
let auth_event = {
let keys = state.signing_keys()?;
sign_blossom_upload_auth(&keys, &sha256, expiry_secs, &base_url)?
sign_blossom_upload_auth(&keys, &sha256, expiry_secs, &base_url, verb)?
};
let auth_header = format!(
@@ -285,7 +290,7 @@ async fn do_upload(
);
let req = state
.http_client
.put(format!("{base_url}/media/upload"))
.put(format!("{base_url}{route}"))
.header("Authorization", &auth_header)
.header("Content-Type", mime)
.header("X-SHA-256", &sha256);

View File

@@ -173,6 +173,16 @@ pub(super) fn transcode_to_mp4(
.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",
"-c:v",
"libx264",
"-preset",
@@ -187,6 +197,8 @@ pub(super) fn transcode_to_mp4(
"128k",
"-movflags",
"+faststart",
"-metadata",
"encoder=",
])
.arg(&output)
.stdout(std::process::Stdio::null())
@@ -232,7 +244,16 @@ pub(super) fn transcode_heic_to_jpeg(
.args(["-y", "-loglevel", "error"]) // 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()),

View File

@@ -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

@@ -177,6 +177,7 @@ import UserNotifications
exportSession.outputURL = outputURL
exportSession.outputFileType = .mp4
exportSession.shouldOptimizeForNetworkUse = true
exportSession.metadataItemFilter = AVMetadataItemFilter.forSharing()
exportSession.exportAsynchronously {
switch exportSession.status {

View File

@@ -12,7 +12,7 @@ import 'package:pointycastle/digests/sha256.dart';
import 'media_auth.dart';
import 'relay_provider.dart';
const _mediaUploadPath = '/media/upload';
const _mediaUploadPath = '/media';
const _mediaUploadPlatformChannelName = 'buzz/media_upload';
const _sanitizeImageForUploadMethod = 'sanitizeImageForUpload';
const _transcodeVideoToMp4Method = 'transcodeVideoToMp4';
@@ -309,7 +309,7 @@ class MediaUploadService {
final expiration =
(_now().millisecondsSinceEpoch ~/ 1000) + _uploadAuthLifetimeSeconds;
final tags = <List<String>>[
['t', 'upload'],
['t', 'media'],
['x', sha256],
['expiration', '$expiration'],
if (extractServerAuthority(_baseUrl) case final authority?)
@@ -318,7 +318,7 @@ class MediaUploadService {
return nostr.Event.from(
kind: _uploadAuthKind,
content: 'Upload buzz-media',
content: 'Process buzz-media',
tags: tags,
secretKey: privkeyHex,
verify: false,

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('/media').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/media',
);
expect(capturedRequest!.headers['Content-Type'], 'image/png');
expect(capturedRequest!.headers['X-SHA-256'], isNotEmpty);
@@ -380,7 +380,7 @@ void main() {
expect(authEvent['kind'], 24242);
expect(authEvent['pubkey'], keychain.public);
expect(tags, anyElement(equals(<String>['t', 'upload'])));
expect(tags, anyElement(equals(<String>['t', 'media'])));
expect(
tags,
anyElement(

View File

@@ -40,11 +40,12 @@ 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 POSTGRES_PASSWORD="${POSTGRES_PASSWORD:-buzz_dev}"
export DATABASE_URL="postgres://buzz:${POSTGRES_PASSWORD}@localhost:5432/buzz"
export PGHOST=localhost
export PGPORT=5432
export PGUSER=buzz
export PGPASSWORD=buzz_dev
export PGPASSWORD="${POSTGRES_PASSWORD}"
export PGDATABASE=buzz
export REDIS_URL="redis://localhost:6379"
fi
@@ -112,9 +113,15 @@ 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 || \
shopt -s nullglob
local auth_integration_tests=(crates/buzz-auth/tests/*.rs)
shopt -u nullglob
if (( ${#auth_integration_tests[@]} > 0 )); 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 || \