diff --git a/Cargo.lock b/Cargo.lock index 5aa78909a..5c53881c9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -945,6 +945,7 @@ dependencies = [ "futures-core", "futures-util", "hex", + "http", "image", "imagesize", "infer", diff --git a/crates/buzz-media/Cargo.toml b/crates/buzz-media/Cargo.toml index 8f5d206d7..5aff3b66d 100644 --- a/crates/buzz-media/Cargo.toml +++ b/crates/buzz-media/Cargo.toml @@ -20,6 +20,7 @@ hex = { workspace = true } chrono = { workspace = true } axum = { workspace = true } s3 = { version = "0.37", package = "rust-s3", default-features = false, features = ["tokio-rustls-tls", "fail-on-err", "tags"] } +http = "1" infer = "0.19" image = { version = "0.25", default-features = false, features = ["jpeg", "png", "gif", "webp"] } blurhash = "0.2" diff --git a/crates/buzz-media/src/lib.rs b/crates/buzz-media/src/lib.rs index f382c21f8..21e20f40f 100644 --- a/crates/buzz-media/src/lib.rs +++ b/crates/buzz-media/src/lib.rs @@ -15,5 +15,7 @@ pub use config::MediaConfig; 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_file_upload, process_upload, process_video_upload, UploadAttributionLabels, +}; pub use validation::{serve_inline, validate_video_file, VideoMeta}; diff --git a/crates/buzz-media/src/storage.rs b/crates/buzz-media/src/storage.rs index 743ebdcb0..999f4ae75 100644 --- a/crates/buzz-media/src/storage.rs +++ b/crates/buzz-media/src/storage.rs @@ -1,5 +1,6 @@ //! S3/MinIO storage client. +use std::collections::HashMap; use std::path::Path; use std::pin::Pin; @@ -15,6 +16,15 @@ use serde::{Deserialize, Serialize}; /// A stream of byte chunks from S3, usable with `axum::body::Body::from_stream()`. pub type ByteStream = Pin> + Send>>; +/// Bare S3 user-metadata key for the authenticated uploader pubkey. +pub const BUZZ_UPLOADER_ID_META_KEY: &str = "buzz-uploader-id"; +/// Bare S3 user-metadata key for the uploader's configured display name. +pub const BUZZ_UPLOADER_NAME_META_KEY: &str = "buzz-uploader-name"; +/// Bare S3 user-metadata key for the server-resolved community id. +pub const BUZZ_COMMUNITY_ID_META_KEY: &str = "buzz-community-id"; +/// Bare S3 user-metadata key for the server-resolved community host. +pub const BUZZ_COMMUNITY_HOST_META_KEY: &str = "buzz-community-host"; + /// S3-compatible object storage client. pub struct MediaStorage { bucket: Box, @@ -77,6 +87,32 @@ impl MediaStorage { Ok(()) } + /// Store an object from a byte slice with `x-amz-meta-*` object metadata. + /// + /// `metadata` keys are bare names (e.g. `buzz-uploader-id`); the S3 client + /// adds the `x-amz-meta-` prefix. Used for media blobs that carry upload + /// attribution so out-of-band consumers can read it from a HEAD without + /// touching relay internals. + pub async fn put_with_metadata( + &self, + key: &str, + bytes: &[u8], + content_type: &str, + metadata: &[(&str, &str)], + ) -> Result<(), MediaError> { + let mut builder = self + .bucket + .put_object_builder(key, bytes) + .with_content_type(content_type); + for (k, v) in metadata { + builder = builder + .with_metadata(k, v) + .map_err(|e| MediaError::StorageError(e.to_string()))?; + } + builder.execute().await?; + Ok(()) + } + /// Stream a file from disk into S3 without loading it into RAM. /// /// Uses rust-s3's `put_object_stream_with_content_type` which reads from @@ -87,6 +123,24 @@ impl MediaStorage { key: &str, path: &Path, content_type: &str, + ) -> Result<(), MediaError> { + self.put_file_with_metadata(key, path, content_type, &[]) + .await + } + + /// Stream a file from disk into S3 with `x-amz-meta-*` object metadata. + /// + /// Metadata is attached via bucket-level `extra_headers` (not the stream + /// builder's `with_metadata`) because rust-s3's streaming multipart path + /// only forwards builder headers on the small-file (single PUT) branch; + /// bucket `extra_headers` are applied to `InitiateMultipartUpload` too, so + /// metadata survives files larger than the 8 MiB chunk threshold. + pub async fn put_file_with_metadata( + &self, + key: &str, + path: &Path, + content_type: &str, + metadata: &[(&str, &str)], ) -> Result<(), MediaError> { const BUF: usize = 8 * 1024 * 1024; // 8 MiB read buffer @@ -95,7 +149,19 @@ impl MediaStorage { .map_err(|e| MediaError::Io(e.to_string()))?; let mut reader = tokio::io::BufReader::with_capacity(BUF, file); - self.bucket + if metadata.is_empty() { + self.bucket + .put_object_stream_with_content_type(&mut reader, key, content_type) + .await?; + return Ok(()); + } + + let headers = build_amz_meta_headers(metadata)?; + let bucket = self + .bucket + .with_extra_headers(headers) + .map_err(|e| MediaError::StorageError(e.to_string()))?; + bucket .put_object_stream_with_content_type(&mut reader, key, content_type) .await?; Ok(()) @@ -163,12 +229,10 @@ impl MediaStorage { Ok(()) } - /// HEAD with metadata — returns Content-Length (size). + /// HEAD with metadata — returns Content-Length (size) and user metadata. pub async fn head_with_metadata(&self, key: &str) -> Result, MediaError> { match self.bucket.head_object(key).await { - Ok((result, _)) => Ok(Some(BlobHeadMeta { - size: result.content_length.unwrap_or(0) as u64, - })), + Ok((result, _)) => Ok(Some(BlobHeadMeta::from_head_object_result(result))), Err(s3::error::S3Error::HttpFailWithBody(404, _)) => Ok(None), Err(e) => Err(MediaError::StorageError(e.to_string())), } @@ -336,11 +400,156 @@ mod tests { "video/mp4" ); } + + #[test] + fn amz_meta_headers_are_prefixed_and_validated() { + let headers = build_amz_meta_headers(&[ + (BUZZ_UPLOADER_ID_META_KEY, "aabbcc"), + (BUZZ_UPLOADER_NAME_META_KEY, "Ada"), + (BUZZ_COMMUNITY_ID_META_KEY, "0000-1111"), + (BUZZ_COMMUNITY_HOST_META_KEY, "moderation.buzz.example"), + ]) + .unwrap(); + assert_eq!( + headers + .get(format!("x-amz-meta-{BUZZ_UPLOADER_ID_META_KEY}")) + .unwrap(), + "aabbcc" + ); + assert_eq!( + headers + .get(format!("x-amz-meta-{BUZZ_UPLOADER_NAME_META_KEY}")) + .unwrap(), + "Ada" + ); + assert_eq!( + headers + .get(format!("x-amz-meta-{BUZZ_COMMUNITY_ID_META_KEY}")) + .unwrap(), + "0000-1111" + ); + assert_eq!( + headers + .get(format!("x-amz-meta-{BUZZ_COMMUNITY_HOST_META_KEY}")) + .unwrap(), + "moderation.buzz.example" + ); + + // Control characters in values are rejected, not silently mangled. + assert!(build_amz_meta_headers(&[(BUZZ_UPLOADER_ID_META_KEY, "bu\nzz")]).is_err()); + // Invalid header-name characters in the key are rejected. + assert!(build_amz_meta_headers(&[("bad key", "v")]).is_err()); + } + + #[test] + fn blob_head_meta_surfaces_s3_user_metadata() { + let mut metadata = HashMap::new(); + metadata.insert(BUZZ_UPLOADER_ID_META_KEY.to_string(), "aabbcc".to_string()); + metadata.insert(BUZZ_UPLOADER_NAME_META_KEY.to_string(), "Ada".to_string()); + metadata.insert( + BUZZ_COMMUNITY_ID_META_KEY.to_string(), + "0000-1111".to_string(), + ); + metadata.insert( + BUZZ_COMMUNITY_HOST_META_KEY.to_string(), + "moderation.buzz.example".to_string(), + ); + + let result = s3::serde_types::HeadObjectResult { + content_length: Some(42), + metadata: Some(metadata), + ..Default::default() + }; + + let head = BlobHeadMeta::from_head_object_result(result); + assert_eq!(head.size, 42); + assert_eq!( + head.metadata.get(BUZZ_UPLOADER_ID_META_KEY), + Some(&"aabbcc".to_string()) + ); + assert_eq!( + head.metadata.get(BUZZ_UPLOADER_NAME_META_KEY), + Some(&"Ada".to_string()) + ); + assert_eq!( + head.metadata.get(BUZZ_COMMUNITY_ID_META_KEY), + Some(&"0000-1111".to_string()) + ); + assert_eq!( + head.metadata.get(BUZZ_COMMUNITY_HOST_META_KEY), + Some(&"moderation.buzz.example".to_string()) + ); + } + + /// Old sidecars (written before upload attribution) must still parse, and + /// new fields must round-trip. + #[test] + fn sidecar_attribution_fields_are_backward_compatible() { + // Pre-attribution sidecar JSON — no uploader_id/community_id keys. + let old = r#"{"dim":"800x600","blurhash":"","thumb_url":"","ext":"jpg","mime_type":"image/jpeg","size":123,"uploaded_at":1700000000}"#; + let meta: BlobMeta = serde_json::from_str(old).unwrap(); + assert_eq!(meta.uploader_id, None); + assert_eq!(meta.uploader_name, None); + assert_eq!(meta.community_id, None); + assert_eq!(meta.community_host, None); + + // Absent attribution is omitted from serialized output (not null). + let json = serde_json::to_value(&meta).unwrap(); + assert!(json.get("uploader_id").is_none()); + assert!(json.get("uploader_name").is_none()); + assert!(json.get("community_id").is_none()); + assert!(json.get("community_host").is_none()); + + // Populated attribution round-trips. + let meta = BlobMeta { + uploader_id: Some("aa".repeat(32)), + uploader_name: Some("Ada".to_string()), + community_id: Some("6b8e1c2a-0000-0000-0000-000000000000".to_string()), + community_host: Some("moderation.buzz.example".to_string()), + ..meta + }; + let round: BlobMeta = serde_json::from_str(&serde_json::to_string(&meta).unwrap()).unwrap(); + assert_eq!(round.uploader_id, meta.uploader_id); + assert_eq!(round.uploader_name, meta.uploader_name); + assert_eq!(round.community_id, meta.community_id); + assert_eq!(round.community_host, meta.community_host); + } +} + +/// Build an `x-amz-meta-*` [`http::HeaderMap`] from bare metadata key/value +/// pairs. Keys must be valid header-name characters; values must be valid +/// header values (S3 object metadata is US-ASCII). +fn build_amz_meta_headers(metadata: &[(&str, &str)]) -> Result { + let mut headers = http::HeaderMap::new(); + for (k, v) in metadata { + let name: http::HeaderName = format!("x-amz-meta-{k}") + .parse() + .map_err(|_| MediaError::StorageError(format!("invalid metadata key: {k}")))?; + let value: http::HeaderValue = v + .parse() + .map_err(|_| MediaError::StorageError(format!("invalid metadata value for {k}")))?; + headers.insert(name, value); + } + Ok(headers) } -/// Metadata returned by HEAD — just enough for BUD-01 response headers. +/// Metadata returned by HEAD for BUD-01 responses and moderation tooling. +#[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct BlobHeadMeta { + /// Object size in bytes. pub size: u64, + /// S3 user metadata returned by HEAD, keyed by bare metadata name (without + /// the `x-amz-meta-` prefix), e.g. `buzz-uploader-id`. + pub metadata: HashMap, +} + +impl BlobHeadMeta { + fn from_head_object_result(result: s3::serde_types::HeadObjectResult) -> Self { + Self { + size: result.content_length.unwrap_or(0).max(0) as u64, + metadata: result.metadata.unwrap_or_default(), + } + } } /// Full blob metadata — stored as sidecar JSON in `_meta/{community}/{sha256}.json`. @@ -364,4 +573,23 @@ pub struct BlobMeta { /// Video duration in seconds. `None` for non-video blobs. #[serde(default, skip_serializing_if = "Option::is_none")] pub duration_secs: Option, + /// Authenticated uploader pubkey (hex). Upload attribution for + /// out-of-band consumers; `None` on sidecars written before attribution + /// existed. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub uploader_id: Option, + /// Best-effort configured display name for the authenticated uploader. This + /// is a readability hint, not an authority boundary; `uploader_id` and the + /// audit log remain authoritative. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub uploader_name: Option, + /// Host-resolved community id (UUID string). Mirrors the community segment + /// of the sidecar key so attribution survives even if the object is copied + /// out of its keyed location; `None` on pre-attribution sidecars. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub community_id: Option, + /// Server-resolved community host (for example `team.example.com`). + /// Readability hint only; `community_id` remains authoritative. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub community_host: Option, } diff --git a/crates/buzz-media/src/upload.rs b/crates/buzz-media/src/upload.rs index cbf0760f0..704a07d72 100644 --- a/crates/buzz-media/src/upload.rs +++ b/crates/buzz-media/src/upload.rs @@ -8,13 +8,84 @@ use tokio::io::AsyncWriteExt; use crate::auth::verify_blossom_upload_auth; use crate::config::MediaConfig; use crate::error::MediaError; -use crate::storage::{BlobMeta, MediaStorage}; +use crate::storage::{ + BlobMeta, MediaStorage, BUZZ_COMMUNITY_HOST_META_KEY, BUZZ_COMMUNITY_ID_META_KEY, + BUZZ_UPLOADER_ID_META_KEY, BUZZ_UPLOADER_NAME_META_KEY, +}; use crate::thumbnail::generate_image_metadata_sync; use crate::types::BlobDescriptor; use crate::validation::{ mime_to_ext, validate_content, validate_file_content, validate_video_file, }; +/// Readability metadata for upload attribution. The id fields remain +/// authoritative; names/hosts are best-effort labels for moderators. +#[derive(Debug, Clone, Default)] +pub struct UploadAttributionLabels { + /// Configured display name for the authenticated uploader, if known. + pub uploader_name: Option, + /// Server-resolved tenant host for the community. + pub community_host: Option, +} + +impl UploadAttributionLabels { + /// Build labels from optional profile data and the resolved tenant host. + pub fn from_profile_and_host(uploader_name: Option, tenant_host: &str) -> Self { + Self { + uploader_name: uploader_name.and_then(sanitize_label), + community_host: community_host_from_host(tenant_host), + } + } +} + +fn attribution_meta<'a>( + uploader_id: &'a str, + community_id: &'a str, + labels: &'a UploadAttributionLabels, +) -> Vec<(&'static str, &'a str)> { + let mut metadata = vec![ + (BUZZ_UPLOADER_ID_META_KEY, uploader_id), + (BUZZ_COMMUNITY_ID_META_KEY, community_id), + ]; + if let Some(uploader_name) = labels.uploader_name.as_deref() { + metadata.push((BUZZ_UPLOADER_NAME_META_KEY, uploader_name)); + } + if let Some(community_host) = labels.community_host.as_deref() { + metadata.push((BUZZ_COMMUNITY_HOST_META_KEY, community_host)); + } + metadata +} + +fn sanitize_label(label: String) -> Option { + let label = label.trim(); + if label.is_empty() { + return None; + } + + // S3 user metadata is represented as HTTP headers. Keep labels readable but + // header-safe and bounded; the id metadata remains the complete source. + let mut out = String::with_capacity(label.len().min(128)); + let mut last_was_space = false; + for ch in label.chars() { + if out.len() >= 128 { + break; + } + if ch.is_ascii_graphic() { + out.push(ch); + last_was_space = false; + } else if ch.is_ascii_whitespace() && !last_was_space && !out.is_empty() { + out.push(' '); + last_was_space = true; + } + } + let out = out.trim().to_string(); + (!out.is_empty()).then_some(out) +} + +fn community_host_from_host(host: &str) -> Option { + sanitize_label(host.to_string()) +} + /// Shared buffered-upload pipeline for the image and generic-file paths. /// /// Both paths are identical except for two steps, which are injected: @@ -37,14 +108,16 @@ async fn process_buffered_upload( ctx: &TenantContext, auth_event: &nostr::Event, body: Bytes, - validate: V, - store_metadata: M, + labels: UploadAttributionLabels, + ops: (V, M), ) -> Result where V: FnOnce(&Bytes, &MediaConfig) -> Result<(String, String), MediaError> + Send + 'static, M: FnOnce(MetadataInput) -> Fut, Fut: std::future::Future>, { + let (validate, store_metadata) = ops; + // CPU-bound: validate content, compute hash, verify auth. let auth = auth_event.clone(); let bytes = body.clone(); @@ -86,6 +159,17 @@ where // Compute uploaded_at once — single source of truth for sidecar and response. let uploaded_at = chrono::Utc::now().timestamp(); + // Upload attribution: the authenticated uploader pubkey and the + // host-resolved community, stamped as S3 object metadata so out-of-band + // consumers can attribute a blob from a HEAD + // alone. The community comes from the server-resolved `TenantContext`, + // never from client input. Note the blob is shared CAS across communities: + // if the same bytes are later uploaded under another tenant, the re-put + // overwrites this metadata with the most recent uploader — the + // community-scoped sidecar remains the authoritative per-tenant record. + let uploader_id = auth_event.pubkey.to_hex(); + let community_id = ctx.community().to_string(); + // Store blob first, then metadata. // On failure we intentionally do NOT delete the orphan blob — concurrent // uploads of the same hash could race and delete a blob that another @@ -93,7 +177,14 @@ where // content-addressed and bounded by the upload size limit, so the storage // cost is negligible. A V2 background GC job can sweep blobs with no // matching sidecar after a grace period. - storage.put(&key, &body, &mime).await?; + storage + .put_with_metadata( + &key, + &body, + &mime, + &attribution_meta(uploader_id.as_str(), community_id.as_str(), &labels), + ) + .await?; let meta_result = store_metadata(MetadataInput { sha256: sha256.clone(), @@ -101,6 +192,9 @@ where mime: mime.clone(), body: body.clone(), uploaded_at, + uploader_id, + community_id, + labels, }) .await; @@ -131,6 +225,12 @@ struct MetadataInput { mime: String, body: Bytes, uploaded_at: i64, + /// Authenticated uploader pubkey (hex), mirrored into the sidecar. + uploader_id: String, + /// Host-resolved community id, mirrored into the sidecar. + community_id: String, + /// Best-effort human-readable labels mirrored into S3 metadata and sidecar. + labels: UploadAttributionLabels, } /// Process an upload end-to-end: validate, store, thumbnail, return descriptor. @@ -143,6 +243,7 @@ pub async fn process_upload( ctx: &TenantContext, auth_event: &nostr::Event, body: Bytes, + labels: UploadAttributionLabels, ) -> Result { process_buffered_upload( storage, @@ -150,12 +251,15 @@ pub async fn process_upload( ctx, auth_event, body, - |bytes, cfg| { - let mime = validate_content(bytes, cfg)?; - let ext = mime_to_ext(&mime).to_string(); - Ok((mime, ext)) - }, - |input| async move { generate_and_store_metadata(storage, config, ctx, input).await }, + labels, + ( + |bytes, cfg| { + let mime = validate_content(bytes, cfg)?; + let ext = mime_to_ext(&mime).to_string(); + Ok((mime, ext)) + }, + |input| async move { generate_and_store_metadata(storage, config, ctx, input).await }, + ), ) .await } @@ -176,6 +280,7 @@ pub async fn process_file_upload( ctx: &TenantContext, auth_event: &nostr::Event, body: Bytes, + labels: UploadAttributionLabels, ) -> Result { process_buffered_upload( storage, @@ -183,22 +288,29 @@ pub async fn process_file_upload( ctx, auth_event, body, - |bytes, cfg| validate_file_content(bytes, cfg), - |input| async move { - // Minimal sidecar — no thumbnail/dim/blurhash/duration for generic files. - let meta = BlobMeta { - dim: String::new(), - blurhash: String::new(), - thumb_url: String::new(), - size: input.body.len() as u64, - ext: input.ext, - mime_type: input.mime, - uploaded_at: input.uploaded_at, - duration_secs: None, - }; - storage.put_sidecar(ctx, &input.sha256, &meta).await?; - Ok(meta) - }, + labels, + ( + |bytes, cfg| validate_file_content(bytes, cfg), + |input| async move { + // Minimal sidecar — no thumbnail/dim/blurhash/duration for generic files. + let meta = BlobMeta { + dim: String::new(), + blurhash: String::new(), + thumb_url: String::new(), + size: input.body.len() as u64, + ext: input.ext, + mime_type: input.mime, + uploaded_at: input.uploaded_at, + duration_secs: None, + uploader_id: Some(input.uploader_id), + uploader_name: input.labels.uploader_name, + community_id: Some(input.community_id), + community_host: input.labels.community_host, + }; + storage.put_sidecar(ctx, &input.sha256, &meta).await?; + Ok(meta) + }, + ), ) .await } @@ -221,6 +333,7 @@ pub async fn process_video_upload( auth_event: &nostr::Event, body_stream: impl futures_core::Stream> + Send + 'static, content_length: Option, + labels: UploadAttributionLabels, ) -> Result { // --- 1. Stream body to temp file, compute SHA-256 incrementally --- let tmp = tempfile::NamedTempFile::new().map_err(|e| MediaError::Io(e.to_string()))?; @@ -370,8 +483,20 @@ pub async fn process_video_upload( let uploaded_at = chrono::Utc::now().timestamp(); + // Upload attribution — see process_buffered_upload for the rationale and + // the shared-CAS re-put caveat. + let uploader_id = auth_event.pubkey.to_hex(); + let community_id = ctx.community().to_string(); + // --- 6. Stream blob from temp file to S3 --- - storage.put_file(&key, &tmp_path, &mime).await?; + storage + .put_file_with_metadata( + &key, + &tmp_path, + &mime, + &attribution_meta(uploader_id.as_str(), community_id.as_str(), &labels), + ) + .await?; drop(tmp); // Free temp file disk space immediately after S3 upload. // --- 7. Write sidecar (no thumbnail for video — desktop handles that) --- @@ -384,6 +509,10 @@ pub async fn process_video_upload( size: file_size, uploaded_at, duration_secs: Some(video_meta.duration_secs), + uploader_id: Some(uploader_id), + uploader_name: labels.uploader_name, + community_id: Some(community_id), + community_host: labels.community_host, }; storage.put_sidecar(ctx, &sha256_hex, &meta).await?; @@ -418,10 +547,30 @@ async fn generate_and_store_metadata( .map_err(|_| MediaError::Internal)??; meta.uploaded_at = input.uploaded_at; + meta.uploader_id = Some(input.uploader_id); + meta.uploader_name = input.labels.uploader_name; + meta.community_id = Some(input.community_id); + meta.community_host = input.labels.community_host; if let Some(ref tb) = thumb_bytes { + // The thumbnail is a derived object with its own S3 key, so it carries + // the same attribution metadata as the source blob. let thumb_key = format!("{}.thumb.jpg", input.sha256); - storage.put(&thumb_key, tb, "image/jpeg").await?; + storage + .put_with_metadata( + &thumb_key, + tb, + "image/jpeg", + &attribution_meta( + meta.uploader_id.as_deref().unwrap_or_default(), + meta.community_id.as_deref().unwrap_or_default(), + &UploadAttributionLabels { + uploader_name: meta.uploader_name.clone(), + community_host: meta.community_host.clone(), + }, + ), + ) + .await?; } storage.put_sidecar(ctx, &input.sha256, &meta).await?; @@ -484,6 +633,10 @@ mod tests { size: 5_000_000, uploaded_at: 1700000000, duration_secs: Some(29.5), + uploader_id: None, + uploader_name: None, + community_id: None, + community_host: None, }; let desc = build_descriptor( @@ -542,6 +695,10 @@ mod tests { size: 100_000, uploaded_at: 1700000000, duration_secs: None, + uploader_id: None, + uploader_name: None, + community_id: None, + community_host: None, }; let desc = build_descriptor( @@ -599,6 +756,24 @@ mod tests { assert_eq!(detect("connection reset"), std::io::ErrorKind::Other); } + #[test] + fn upload_attribution_labels_are_sanitized_and_use_full_host() { + let labels = UploadAttributionLabels::from_profile_and_host( + Some(" Ada Lovelace\n🚀 ".to_string()), + "moderation.buzz.example", + ); + + assert_eq!(labels.uploader_name.as_deref(), Some("Ada Lovelace")); + assert_eq!( + labels.community_host.as_deref(), + Some("moderation.buzz.example") + ); + + let localhost = UploadAttributionLabels::from_profile_and_host(None, "localhost:3000"); + assert_eq!(localhost.uploader_name, None); + assert_eq!(localhost.community_host.as_deref(), Some("localhost:3000")); + } + #[test] fn test_build_descriptor_no_meta() { // When meta is None, all optional fields should be None. diff --git a/crates/buzz-media/tests/static_creds_minio.rs b/crates/buzz-media/tests/static_creds_minio.rs index 38dc6618c..a9161f19b 100644 --- a/crates/buzz-media/tests/static_creds_minio.rs +++ b/crates/buzz-media/tests/static_creds_minio.rs @@ -18,7 +18,10 @@ //! `BUZZ_S3_SECRET_KEY` / `BUZZ_S3_BUCKET`. use buzz_media::config::MediaConfig; -use buzz_media::storage::MediaStorage; +use buzz_media::storage::{ + MediaStorage, BUZZ_COMMUNITY_HOST_META_KEY, BUZZ_COMMUNITY_ID_META_KEY, + BUZZ_UPLOADER_ID_META_KEY, BUZZ_UPLOADER_NAME_META_KEY, +}; fn minio_config() -> MediaConfig { MediaConfig { @@ -49,7 +52,17 @@ async fn static_creds_round_trip_against_minio() { // PUT storage - .put(&key, body, "application/octet-stream") + .put_with_metadata( + &key, + body, + "application/octet-stream", + &[ + (BUZZ_UPLOADER_ID_META_KEY, "test-uploader"), + (BUZZ_UPLOADER_NAME_META_KEY, "Test Uploader"), + (BUZZ_COMMUNITY_ID_META_KEY, "test-community"), + (BUZZ_COMMUNITY_HOST_META_KEY, "moderation.buzz.example"), + ], + ) .await .expect("put with static creds should succeed"); @@ -61,6 +74,22 @@ async fn static_creds_round_trip_against_minio() { .expect("head_with_metadata should succeed") .expect("object should exist"); assert_eq!(meta.size, body.len() as u64); + assert_eq!( + meta.metadata.get(BUZZ_UPLOADER_ID_META_KEY), + Some(&"test-uploader".to_string()) + ); + assert_eq!( + meta.metadata.get(BUZZ_UPLOADER_NAME_META_KEY), + Some(&"Test Uploader".to_string()) + ); + assert_eq!( + meta.metadata.get(BUZZ_COMMUNITY_ID_META_KEY), + Some(&"test-community".to_string()) + ); + assert_eq!( + meta.metadata.get(BUZZ_COMMUNITY_HOST_META_KEY), + Some(&"moderation.buzz.example".to_string()) + ); // GET round-trips the bytes let got = storage.get(&key).await.expect("get should succeed"); diff --git a/crates/buzz-relay/src/api/media.rs b/crates/buzz-relay/src/api/media.rs index 9cee7ab72..c9349518e 100644 --- a/crates/buzz-relay/src/api/media.rs +++ b/crates/buzz-relay/src/api/media.rs @@ -18,7 +18,7 @@ use axum::{ use base64::Engine; use buzz_audit::{AuditAction, NewAuditEntry}; use buzz_core::tenant::TenantContext; -use buzz_media::{BlobDescriptor, MediaError}; +use buzz_media::{BlobDescriptor, MediaError, UploadAttributionLabels}; use crate::state::AppState; @@ -203,6 +203,21 @@ impl FromRequestParts> for AuthenticatedUpload { } } +async fn upload_attribution_labels( + state: &AppState, + auth: &AuthenticatedUpload, +) -> UploadAttributionLabels { + let uploader_name = state + .db + .get_user(auth.tenant.community(), &auth.auth_event.pubkey.to_bytes()) + .await + .ok() + .flatten() + .and_then(|profile| profile.display_name); + + UploadAttributionLabels::from_profile_and_host(uploader_name, auth.tenant.host()) +} + /// PUT /media/upload — Blossom BUD-02 upload. /// /// Auth is validated via the [`AuthenticatedUpload`] extractor BEFORE the body @@ -232,6 +247,8 @@ pub async fn upload_blob( .and_then(|v| v.to_str().ok()) .unwrap_or(""); + let labels = upload_attribution_labels(&state, &auth).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 @@ -245,6 +262,7 @@ pub async fn upload_blob( &auth.auth_event, body.into_data_stream(), content_length, + labels, ) .await? } else { @@ -274,6 +292,7 @@ pub async fn upload_blob( &auth.tenant, &auth.auth_event, bytes, + labels, ) .await? } else { @@ -283,6 +302,7 @@ pub async fn upload_blob( &auth.tenant, &auth.auth_event, bytes, + labels, ) .await? }