From 783c54945326508677ea7d31861ee2ca55f67ad6 Mon Sep 17 00:00:00 2001 From: Bradley Axen Date: Fri, 3 Jul 2026 14:00:32 -0700 Subject: [PATCH 1/4] feat(media): stamp upload attribution on media objects and sidecars Media uploads now carry upload attribution so operators and out-of-band consumers can attribute any stored object without relay internals: - S3 object metadata on the blob and thumbnail PUTs: x-amz-meta-buzz-uploader-id (authenticated Blossom uploader pubkey, hex) and x-amz-meta-buzz-community-id (host-resolved community UUID), readable from a bare HEAD on the object. - The same fields on the BlobMeta sidecar (uploader_id / community_id), nullable with serde defaults so older sidecars still parse. The community always comes from the server-resolved TenantContext (row-zero host binding), never from client input. All three upload paths are covered: image, generic file, and streaming video (the video path attaches metadata via bucket extra_headers so it survives multipart uploads). Note: blobs are shared content-addressed storage across communities, so a re-upload of identical bytes under another tenant overwrites the object metadata with the most recent uploader; the community-scoped sidecar remains the authoritative per-tenant record. --- Cargo.lock | 1 + crates/buzz-media/Cargo.toml | 1 + crates/buzz-media/src/storage.rs | 133 ++++++++++++++++++++++++++++++- crates/buzz-media/src/upload.rs | 76 +++++++++++++++++- 4 files changed, 207 insertions(+), 4 deletions(-) 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/storage.rs b/crates/buzz-media/src/storage.rs index 743ebdcb0..b2661b1b9 100644 --- a/crates/buzz-media/src/storage.rs +++ b/crates/buzz-media/src/storage.rs @@ -77,6 +77,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 +113,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 +139,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(()) @@ -336,6 +392,71 @@ mod tests { "video/mp4" ); } + + #[test] + fn amz_meta_headers_are_prefixed_and_validated() { + let headers = build_amz_meta_headers(&[ + ("buzz-uploader-id", "aabbcc"), + ("buzz-community-id", "0000-1111"), + ]) + .unwrap(); + assert_eq!( + headers.get("x-amz-meta-buzz-uploader-id").unwrap(), + "aabbcc" + ); + assert_eq!( + headers.get("x-amz-meta-buzz-community-id").unwrap(), + "0000-1111" + ); + + // Control characters in values are rejected, not silently mangled. + assert!(build_amz_meta_headers(&[("buzz-uploader-id", "bu\nzz")]).is_err()); + // Invalid header-name characters in the key are rejected. + assert!(build_amz_meta_headers(&[("bad key", "v")]).is_err()); + } + + /// 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.community_id, 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("community_id").is_none()); + + // Populated attribution round-trips. + let meta = BlobMeta { + uploader_id: Some("aa".repeat(32)), + community_id: Some("6b8e1c2a-0000-0000-0000-000000000000".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.community_id, meta.community_id); + } +} + +/// 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. @@ -364,4 +485,14 @@ 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, + /// 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, } diff --git a/crates/buzz-media/src/upload.rs b/crates/buzz-media/src/upload.rs index cbf0760f0..26ae329ae 100644 --- a/crates/buzz-media/src/upload.rs +++ b/crates/buzz-media/src/upload.rs @@ -86,6 +86,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 +104,17 @@ 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, + &[ + ("buzz-uploader-id", uploader_id.as_str()), + ("buzz-community-id", community_id.as_str()), + ], + ) + .await?; let meta_result = store_metadata(MetadataInput { sha256: sha256.clone(), @@ -101,6 +122,8 @@ where mime: mime.clone(), body: body.clone(), uploaded_at, + uploader_id, + community_id, }) .await; @@ -131,6 +154,10 @@ 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, } /// Process an upload end-to-end: validate, store, thumbnail, return descriptor. @@ -195,6 +222,8 @@ pub async fn process_file_upload( 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) @@ -370,8 +399,23 @@ 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, + &[ + ("buzz-uploader-id", uploader_id.as_str()), + ("buzz-community-id", community_id.as_str()), + ], + ) + .await?; drop(tmp); // Free temp file disk space immediately after S3 upload. // --- 7. Write sidecar (no thumbnail for video — desktop handles that) --- @@ -384,6 +428,8 @@ pub async fn process_video_upload( size: file_size, uploaded_at, duration_secs: Some(video_meta.duration_secs), + uploader_id: Some(uploader_id), + community_id: Some(community_id), }; storage.put_sidecar(ctx, &sha256_hex, &meta).await?; @@ -418,10 +464,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.community_id = Some(input.community_id); 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", + &[ + ( + "buzz-uploader-id", + meta.uploader_id.as_deref().unwrap_or_default(), + ), + ( + "buzz-community-id", + meta.community_id.as_deref().unwrap_or_default(), + ), + ], + ) + .await?; } storage.put_sidecar(ctx, &input.sha256, &meta).await?; @@ -484,6 +550,8 @@ mod tests { size: 5_000_000, uploaded_at: 1700000000, duration_secs: Some(29.5), + uploader_id: None, + community_id: None, }; let desc = build_descriptor( @@ -542,6 +610,8 @@ mod tests { size: 100_000, uploaded_at: 1700000000, duration_secs: None, + uploader_id: None, + community_id: None, }; let desc = build_descriptor( From 412d5278aaf2d1c6ddf5851c657b8ef3a5527721 Mon Sep 17 00:00:00 2001 From: Bradley Axen Date: Fri, 3 Jul 2026 16:38:16 -0700 Subject: [PATCH 2/4] fix(media): surface upload metadata from HEAD Hoist upload attribution metadata keys and expose S3 user metadata from BlobHeadMeta so HEAD callers can verify the uploader/community stamp round-trips. Co-authored-by: Bradley Axen Signed-off-by: Bradley Axen --- crates/buzz-media/src/storage.rs | 69 ++++++++++++++++--- crates/buzz-media/src/upload.rs | 38 +++++----- crates/buzz-media/tests/static_creds_minio.rs | 20 +++++- 3 files changed, 96 insertions(+), 31 deletions(-) diff --git a/crates/buzz-media/src/storage.rs b/crates/buzz-media/src/storage.rs index b2661b1b9..ae86f6731 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,11 @@ 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 server-resolved community id. +pub const BUZZ_COMMUNITY_ID_META_KEY: &str = "buzz-community-id"; + /// S3-compatible object storage client. pub struct MediaStorage { bucket: Box, @@ -219,12 +225,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())), } @@ -396,25 +400,56 @@ mod tests { #[test] fn amz_meta_headers_are_prefixed_and_validated() { let headers = build_amz_meta_headers(&[ - ("buzz-uploader-id", "aabbcc"), - ("buzz-community-id", "0000-1111"), + (BUZZ_UPLOADER_ID_META_KEY, "aabbcc"), + (BUZZ_COMMUNITY_ID_META_KEY, "0000-1111"), ]) .unwrap(); assert_eq!( - headers.get("x-amz-meta-buzz-uploader-id").unwrap(), + headers + .get(format!("x-amz-meta-{BUZZ_UPLOADER_ID_META_KEY}")) + .unwrap(), "aabbcc" ); assert_eq!( - headers.get("x-amz-meta-buzz-community-id").unwrap(), + headers + .get(format!("x-amz-meta-{BUZZ_COMMUNITY_ID_META_KEY}")) + .unwrap(), "0000-1111" ); // Control characters in values are rejected, not silently mangled. - assert!(build_amz_meta_headers(&[("buzz-uploader-id", "bu\nzz")]).is_err()); + 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_COMMUNITY_ID_META_KEY.to_string(), + "0000-1111".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_COMMUNITY_ID_META_KEY), + Some(&"0000-1111".to_string()) + ); + } + /// Old sidecars (written before upload attribution) must still parse, and /// new fields must round-trip. #[test] @@ -459,9 +494,23 @@ fn build_amz_meta_headers(metadata: &[(&str, &str)]) -> Result, +} + +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`. diff --git a/crates/buzz-media/src/upload.rs b/crates/buzz-media/src/upload.rs index 26ae329ae..de15c2fb7 100644 --- a/crates/buzz-media/src/upload.rs +++ b/crates/buzz-media/src/upload.rs @@ -8,13 +8,25 @@ 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_ID_META_KEY, BUZZ_UPLOADER_ID_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, }; +fn attribution_meta<'a>( + uploader_id: &'a str, + community_id: &'a str, +) -> [(&'static str, &'a str); 2] { + [ + (BUZZ_UPLOADER_ID_META_KEY, uploader_id), + (BUZZ_COMMUNITY_ID_META_KEY, community_id), + ] +} + /// Shared buffered-upload pipeline for the image and generic-file paths. /// /// Both paths are identical except for two steps, which are injected: @@ -109,10 +121,7 @@ where &key, &body, &mime, - &[ - ("buzz-uploader-id", uploader_id.as_str()), - ("buzz-community-id", community_id.as_str()), - ], + &attribution_meta(uploader_id.as_str(), community_id.as_str()), ) .await?; @@ -410,10 +419,7 @@ pub async fn process_video_upload( &key, &tmp_path, &mime, - &[ - ("buzz-uploader-id", uploader_id.as_str()), - ("buzz-community-id", community_id.as_str()), - ], + &attribution_meta(uploader_id.as_str(), community_id.as_str()), ) .await?; drop(tmp); // Free temp file disk space immediately after S3 upload. @@ -476,16 +482,10 @@ async fn generate_and_store_metadata( &thumb_key, tb, "image/jpeg", - &[ - ( - "buzz-uploader-id", - meta.uploader_id.as_deref().unwrap_or_default(), - ), - ( - "buzz-community-id", - meta.community_id.as_deref().unwrap_or_default(), - ), - ], + &attribution_meta( + meta.uploader_id.as_deref().unwrap_or_default(), + meta.community_id.as_deref().unwrap_or_default(), + ), ) .await?; } diff --git a/crates/buzz-media/tests/static_creds_minio.rs b/crates/buzz-media/tests/static_creds_minio.rs index 38dc6618c..929681733 100644 --- a/crates/buzz-media/tests/static_creds_minio.rs +++ b/crates/buzz-media/tests/static_creds_minio.rs @@ -18,7 +18,7 @@ //! `BUZZ_S3_SECRET_KEY` / `BUZZ_S3_BUCKET`. use buzz_media::config::MediaConfig; -use buzz_media::storage::MediaStorage; +use buzz_media::storage::{MediaStorage, BUZZ_COMMUNITY_ID_META_KEY, BUZZ_UPLOADER_ID_META_KEY}; fn minio_config() -> MediaConfig { MediaConfig { @@ -49,7 +49,15 @@ 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_COMMUNITY_ID_META_KEY, "test-community"), + ], + ) .await .expect("put with static creds should succeed"); @@ -61,6 +69,14 @@ 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_COMMUNITY_ID_META_KEY), + Some(&"test-community".to_string()) + ); // GET round-trips the bytes let got = storage.get(&key).await.expect("get should succeed"); From dd8a3ca72a41b265b8a3bdd1cb8b88f685fcbe3d Mon Sep 17 00:00:00 2001 From: Bradley Axen Date: Fri, 3 Jul 2026 18:40:51 -0700 Subject: [PATCH 3/4] feat(media): add readable upload attribution labels Stamp uploader display name and tenant host alias alongside authoritative uploader and community IDs so moderation HEAD metadata is easier to read. Co-authored-by: Bradley Axen Signed-off-by: Bradley Axen --- crates/buzz-media/src/lib.rs | 4 +- crates/buzz-media/src/storage.rs | 49 +++++ crates/buzz-media/src/upload.rs | 168 ++++++++++++++---- crates/buzz-media/tests/static_creds_minio.rs | 15 +- crates/buzz-relay/src/api/media.rs | 22 ++- 5 files changed, 223 insertions(+), 35 deletions(-) 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? } From 8edc95acb3ffb9150f2e06ee43774385860a8bfd Mon Sep 17 00:00:00 2001 From: Bradley Axen Date: Fri, 3 Jul 2026 20:17:30 -0700 Subject: [PATCH 4/4] fix(media): use full community host label Replace the community alias label with the full server-resolved tenant hostname in S3 metadata and sidecars. Co-authored-by: Bradley Axen Signed-off-by: Bradley Axen --- crates/buzz-media/src/storage.rs | 33 ++++++++------- crates/buzz-media/src/upload.rs | 41 ++++++++++--------- crates/buzz-media/tests/static_creds_minio.rs | 8 ++-- 3 files changed, 41 insertions(+), 41 deletions(-) diff --git a/crates/buzz-media/src/storage.rs b/crates/buzz-media/src/storage.rs index 7e0a5c0a6..999f4ae75 100644 --- a/crates/buzz-media/src/storage.rs +++ b/crates/buzz-media/src/storage.rs @@ -22,8 +22,8 @@ pub const BUZZ_UPLOADER_ID_META_KEY: &str = "buzz-uploader-id"; 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 human-readable community host prefix. -pub const BUZZ_COMMUNITY_ALIAS_META_KEY: &str = "buzz-community-alias"; +/// 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 { @@ -407,7 +407,7 @@ mod tests { (BUZZ_UPLOADER_ID_META_KEY, "aabbcc"), (BUZZ_UPLOADER_NAME_META_KEY, "Ada"), (BUZZ_COMMUNITY_ID_META_KEY, "0000-1111"), - (BUZZ_COMMUNITY_ALIAS_META_KEY, "moderation"), + (BUZZ_COMMUNITY_HOST_META_KEY, "moderation.buzz.example"), ]) .unwrap(); assert_eq!( @@ -430,9 +430,9 @@ mod tests { ); assert_eq!( headers - .get(format!("x-amz-meta-{BUZZ_COMMUNITY_ALIAS_META_KEY}")) + .get(format!("x-amz-meta-{BUZZ_COMMUNITY_HOST_META_KEY}")) .unwrap(), - "moderation" + "moderation.buzz.example" ); // Control characters in values are rejected, not silently mangled. @@ -451,8 +451,8 @@ mod tests { "0000-1111".to_string(), ); metadata.insert( - BUZZ_COMMUNITY_ALIAS_META_KEY.to_string(), - "moderation".to_string(), + BUZZ_COMMUNITY_HOST_META_KEY.to_string(), + "moderation.buzz.example".to_string(), ); let result = s3::serde_types::HeadObjectResult { @@ -476,8 +476,8 @@ mod tests { Some(&"0000-1111".to_string()) ); assert_eq!( - head.metadata.get(BUZZ_COMMUNITY_ALIAS_META_KEY), - Some(&"moderation".to_string()) + head.metadata.get(BUZZ_COMMUNITY_HOST_META_KEY), + Some(&"moderation.buzz.example".to_string()) ); } @@ -491,28 +491,28 @@ mod tests { assert_eq!(meta.uploader_id, None); assert_eq!(meta.uploader_name, None); assert_eq!(meta.community_id, None); - assert_eq!(meta.community_alias, 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_alias").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_alias: Some("moderation".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_alias, meta.community_alias); + assert_eq!(round.community_host, meta.community_host); } } @@ -588,9 +588,8 @@ pub struct BlobMeta { /// 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. + /// 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_alias: Option, + pub community_host: Option, } diff --git a/crates/buzz-media/src/upload.rs b/crates/buzz-media/src/upload.rs index 24bd1f88b..704a07d72 100644 --- a/crates/buzz-media/src/upload.rs +++ b/crates/buzz-media/src/upload.rs @@ -9,7 +9,7 @@ use crate::auth::verify_blossom_upload_auth; use crate::config::MediaConfig; use crate::error::MediaError; use crate::storage::{ - BlobMeta, MediaStorage, BUZZ_COMMUNITY_ALIAS_META_KEY, BUZZ_COMMUNITY_ID_META_KEY, + 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; @@ -19,13 +19,13 @@ use crate::validation::{ }; /// Readability metadata for upload attribution. The id fields remain -/// authoritative; names/aliases are best-effort labels for moderators. +/// 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, - /// Human-readable alias derived from the server-resolved tenant host. - pub community_alias: Option, + /// Server-resolved tenant host for the community. + pub community_host: Option, } impl UploadAttributionLabels { @@ -33,7 +33,7 @@ impl UploadAttributionLabels { 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), + community_host: community_host_from_host(tenant_host), } } } @@ -50,8 +50,8 @@ fn attribution_meta<'a>( 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)); + if let Some(community_host) = labels.community_host.as_deref() { + metadata.push((BUZZ_COMMUNITY_HOST_META_KEY, community_host)); } metadata } @@ -82,10 +82,8 @@ fn sanitize_label(label: String) -> Option { (!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()) +fn community_host_from_host(host: &str) -> Option { + sanitize_label(host.to_string()) } /// Shared buffered-upload pipeline for the image and generic-file paths. @@ -307,7 +305,7 @@ pub async fn process_file_upload( uploader_id: Some(input.uploader_id), uploader_name: input.labels.uploader_name, community_id: Some(input.community_id), - community_alias: input.labels.community_alias, + community_host: input.labels.community_host, }; storage.put_sidecar(ctx, &input.sha256, &meta).await?; Ok(meta) @@ -514,7 +512,7 @@ pub async fn process_video_upload( uploader_id: Some(uploader_id), uploader_name: labels.uploader_name, community_id: Some(community_id), - community_alias: labels.community_alias, + community_host: labels.community_host, }; storage.put_sidecar(ctx, &sha256_hex, &meta).await?; @@ -552,7 +550,7 @@ async fn generate_and_store_metadata( 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; + 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 @@ -568,7 +566,7 @@ async fn generate_and_store_metadata( meta.community_id.as_deref().unwrap_or_default(), &UploadAttributionLabels { uploader_name: meta.uploader_name.clone(), - community_alias: meta.community_alias.clone(), + community_host: meta.community_host.clone(), }, ), ) @@ -638,7 +636,7 @@ mod tests { uploader_id: None, uploader_name: None, community_id: None, - community_alias: None, + community_host: None, }; let desc = build_descriptor( @@ -700,7 +698,7 @@ mod tests { uploader_id: None, uploader_name: None, community_id: None, - community_alias: None, + community_host: None, }; let desc = build_descriptor( @@ -759,18 +757,21 @@ mod tests { } #[test] - fn upload_attribution_labels_are_sanitized_and_host_aliased() { + 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_alias.as_deref(), Some("moderation")); + 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_alias.as_deref(), Some("localhost")); + assert_eq!(localhost.community_host.as_deref(), Some("localhost:3000")); } #[test] diff --git a/crates/buzz-media/tests/static_creds_minio.rs b/crates/buzz-media/tests/static_creds_minio.rs index 71a9d3a12..a9161f19b 100644 --- a/crates/buzz-media/tests/static_creds_minio.rs +++ b/crates/buzz-media/tests/static_creds_minio.rs @@ -19,7 +19,7 @@ use buzz_media::config::MediaConfig; use buzz_media::storage::{ - MediaStorage, BUZZ_COMMUNITY_ALIAS_META_KEY, BUZZ_COMMUNITY_ID_META_KEY, + MediaStorage, BUZZ_COMMUNITY_HOST_META_KEY, BUZZ_COMMUNITY_ID_META_KEY, BUZZ_UPLOADER_ID_META_KEY, BUZZ_UPLOADER_NAME_META_KEY, }; @@ -60,7 +60,7 @@ async fn static_creds_round_trip_against_minio() { (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"), + (BUZZ_COMMUNITY_HOST_META_KEY, "moderation.buzz.example"), ], ) .await @@ -87,8 +87,8 @@ async fn static_creds_round_trip_against_minio() { Some(&"test-community".to_string()) ); assert_eq!( - meta.metadata.get(BUZZ_COMMUNITY_ALIAS_META_KEY), - Some(&"moderation".to_string()) + meta.metadata.get(BUZZ_COMMUNITY_HOST_META_KEY), + Some(&"moderation.buzz.example".to_string()) ); // GET round-trips the bytes