mirror of
https://github.com/block/buzz.git
synced 2026-10-31 14:31:02 +00:00
Strip metadata from uploaded media
Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
@@ -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
|
||||
|
||||
12
.github/workflows/ci.yml
vendored
12
.github/workflows/ci.yml
vendored
@@ -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
3
.intersect/sadscan.yaml
Normal file
@@ -0,0 +1,3 @@
|
||||
exclude_rules_for_files:
|
||||
sq.pii.cc.visa:
|
||||
- Cargo.lock
|
||||
@@ -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
3
Cargo.lock
generated
@@ -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",
|
||||
|
||||
@@ -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 \
|
||||
|
||||
7
Justfile
7
Justfile
@@ -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
1
bin/.ffmpeg-6.1.1.pkg
Symbolic link
@@ -0,0 +1 @@
|
||||
hermit
|
||||
1
bin/.ffprobe-6.1.1.pkg
Symbolic link
1
bin/.ffprobe-6.1.1.pkg
Symbolic link
@@ -0,0 +1 @@
|
||||
hermit
|
||||
1
bin/ffmpeg
Symbolic link
1
bin/ffmpeg
Symbolic link
@@ -0,0 +1 @@
|
||||
.ffmpeg-6.1.1.pkg
|
||||
1
bin/ffprobe
Symbolic link
1
bin/ffprobe
Symbolic link
@@ -0,0 +1 @@
|
||||
.ffprobe-6.1.1.pkg
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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,
|
||||
|
||||
1068
crates/buzz-media/src/sanitize.rs
Normal file
1068
crates/buzz-media/src/sanitize.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
|
||||
15
crates/buzz-media/tests/fixtures/README.md
vendored
Normal file
15
crates/buzz-media/tests/fixtures/README.md
vendored
Normal 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.
|
||||
1
crates/buzz-media/tests/fixtures/tiny.heic.b64
vendored
Normal file
1
crates/buzz-media/tests/fixtures/tiny.heic.b64
vendored
Normal 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=
|
||||
352
crates/buzz-media/tests/media_compliance.rs
Normal file
352
crates/buzz-media/tests/media_compliance.rs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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!(""),
|
||||
format!(""),
|
||||
)
|
||||
.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(),
|
||||
])
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -177,6 +177,7 @@ import UserNotifications
|
||||
exportSession.outputURL = outputURL
|
||||
exportSession.outputFileType = .mp4
|
||||
exportSession.shouldOptimizeForNetworkUse = true
|
||||
exportSession.metadataItemFilter = AVMetadataItemFilter.forSharing()
|
||||
|
||||
exportSession.exportAsynchronously {
|
||||
switch exportSession.status {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 || \
|
||||
|
||||
Reference in New Issue
Block a user