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 ae86f6731..7e0a5c0a6 100644 --- a/crates/buzz-media/src/storage.rs +++ b/crates/buzz-media/src/storage.rs @@ -18,8 +18,12 @@ pub type ByteStream = Pin, + /// 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, + /// Human-readable community alias derived from the server-resolved host's + /// first label (for example `team` from `team.example.com`). Readability + /// hint only; `community_id` remains authoritative. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub community_alias: Option, } diff --git a/crates/buzz-media/src/upload.rs b/crates/buzz-media/src/upload.rs index de15c2fb7..24bd1f88b 100644 --- a/crates/buzz-media/src/upload.rs +++ b/crates/buzz-media/src/upload.rs @@ -9,7 +9,8 @@ use crate::auth::verify_blossom_upload_auth; use crate::config::MediaConfig; use crate::error::MediaError; use crate::storage::{ - BlobMeta, MediaStorage, BUZZ_COMMUNITY_ID_META_KEY, BUZZ_UPLOADER_ID_META_KEY, + BlobMeta, MediaStorage, BUZZ_COMMUNITY_ALIAS_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; @@ -17,14 +18,74 @@ 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/aliases 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, + /// Human-readable alias derived from the server-resolved tenant host. + pub community_alias: 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_alias: community_alias_from_host(tenant_host), + } + } +} + fn attribution_meta<'a>( uploader_id: &'a str, community_id: &'a str, -) -> [(&'static str, &'a str); 2] { - [ + 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_alias) = labels.community_alias.as_deref() { + metadata.push((BUZZ_COMMUNITY_ALIAS_META_KEY, community_alias)); + } + 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_alias_from_host(host: &str) -> Option { + let authority = host.split(':').next().unwrap_or(host).trim(); + let alias = authority.split('.').next().unwrap_or(authority); + sanitize_label(alias.to_string()) } /// Shared buffered-upload pipeline for the image and generic-file paths. @@ -49,14 +110,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(); @@ -121,7 +184,7 @@ where &key, &body, &mime, - &attribution_meta(uploader_id.as_str(), community_id.as_str()), + &attribution_meta(uploader_id.as_str(), community_id.as_str(), &labels), ) .await?; @@ -133,6 +196,7 @@ where uploaded_at, uploader_id, community_id, + labels, }) .await; @@ -167,6 +231,8 @@ struct MetadataInput { 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. @@ -179,6 +245,7 @@ pub async fn process_upload( ctx: &TenantContext, auth_event: &nostr::Event, body: Bytes, + labels: UploadAttributionLabels, ) -> Result { process_buffered_upload( storage, @@ -186,12 +253,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 } @@ -212,6 +282,7 @@ pub async fn process_file_upload( ctx: &TenantContext, auth_event: &nostr::Event, body: Bytes, + labels: UploadAttributionLabels, ) -> Result { process_buffered_upload( storage, @@ -219,24 +290,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, - uploader_id: Some(input.uploader_id), - community_id: Some(input.community_id), - }; - 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_alias: input.labels.community_alias, + }; + storage.put_sidecar(ctx, &input.sha256, &meta).await?; + Ok(meta) + }, + ), ) .await } @@ -259,6 +335,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()))?; @@ -419,7 +496,7 @@ pub async fn process_video_upload( &key, &tmp_path, &mime, - &attribution_meta(uploader_id.as_str(), community_id.as_str()), + &attribution_meta(uploader_id.as_str(), community_id.as_str(), &labels), ) .await?; drop(tmp); // Free temp file disk space immediately after S3 upload. @@ -435,7 +512,9 @@ pub async fn process_video_upload( 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_alias: labels.community_alias, }; storage.put_sidecar(ctx, &sha256_hex, &meta).await?; @@ -471,7 +550,9 @@ async fn generate_and_store_metadata( 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_alias = input.labels.community_alias; if let Some(ref tb) = thumb_bytes { // The thumbnail is a derived object with its own S3 key, so it carries @@ -485,6 +566,10 @@ async fn generate_and_store_metadata( &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_alias: meta.community_alias.clone(), + }, ), ) .await?; @@ -551,7 +636,9 @@ mod tests { uploaded_at: 1700000000, duration_secs: Some(29.5), uploader_id: None, + uploader_name: None, community_id: None, + community_alias: None, }; let desc = build_descriptor( @@ -611,7 +698,9 @@ mod tests { uploaded_at: 1700000000, duration_secs: None, uploader_id: None, + uploader_name: None, community_id: None, + community_alias: None, }; let desc = build_descriptor( @@ -669,6 +758,21 @@ mod tests { assert_eq!(detect("connection reset"), std::io::ErrorKind::Other); } + #[test] + fn upload_attribution_labels_are_sanitized_and_host_aliased() { + 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_alias.as_deref(), Some("moderation")); + + let localhost = UploadAttributionLabels::from_profile_and_host(None, "localhost:3000"); + assert_eq!(localhost.uploader_name, None); + assert_eq!(localhost.community_alias.as_deref(), Some("localhost")); + } + #[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 929681733..71a9d3a12 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, BUZZ_COMMUNITY_ID_META_KEY, BUZZ_UPLOADER_ID_META_KEY}; +use buzz_media::storage::{ + MediaStorage, BUZZ_COMMUNITY_ALIAS_META_KEY, BUZZ_COMMUNITY_ID_META_KEY, + BUZZ_UPLOADER_ID_META_KEY, BUZZ_UPLOADER_NAME_META_KEY, +}; fn minio_config() -> MediaConfig { MediaConfig { @@ -55,7 +58,9 @@ async fn static_creds_round_trip_against_minio() { "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_ALIAS_META_KEY, "moderation"), ], ) .await @@ -73,10 +78,18 @@ async fn static_creds_round_trip_against_minio() { 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_ALIAS_META_KEY), + Some(&"moderation".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? }