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 <baxen@squareup.com>
Signed-off-by: Bradley Axen <baxen@squareup.com>
This commit is contained in:
Bradley Axen
2026-07-03 18:40:51 -07:00
parent 412d5278aa
commit dd8a3ca72a
5 changed files with 223 additions and 35 deletions
+3 -1
View File
@@ -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};
+49
View File
@@ -18,8 +18,12 @@ pub type ByteStream = Pin<Box<dyn futures_core::Stream<Item = Result<Bytes, Medi
/// 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 human-readable community host prefix.
pub const BUZZ_COMMUNITY_ALIAS_META_KEY: &str = "buzz-community-alias";
/// S3-compatible object storage client.
pub struct MediaStorage {
@@ -401,7 +405,9 @@ mod tests {
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_ALIAS_META_KEY, "moderation"),
])
.unwrap();
assert_eq!(
@@ -410,12 +416,24 @@ mod tests {
.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_ALIAS_META_KEY}"))
.unwrap(),
"moderation"
);
// Control characters in values are rejected, not silently mangled.
assert!(build_amz_meta_headers(&[(BUZZ_UPLOADER_ID_META_KEY, "bu\nzz")]).is_err());
@@ -427,10 +445,15 @@ mod tests {
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_ALIAS_META_KEY.to_string(),
"moderation".to_string(),
);
let result = s3::serde_types::HeadObjectResult {
content_length: Some(42),
@@ -444,10 +467,18 @@ mod tests {
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_ALIAS_META_KEY),
Some(&"moderation".to_string())
);
}
/// Old sidecars (written before upload attribution) must still parse, and
@@ -458,22 +489,30 @@ mod tests {
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_alias, 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());
// 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()),
..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);
}
}
@@ -539,9 +578,19 @@ pub struct BlobMeta {
/// existed.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub uploader_id: Option<String>,
/// 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<String>,
/// 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<String>,
/// 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<String>,
}
+136 -32
View File
@@ -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<String>,
/// Human-readable alias derived from the server-resolved tenant host.
pub community_alias: Option<String>,
}
impl UploadAttributionLabels {
/// Build labels from optional profile data and the resolved tenant host.
pub fn from_profile_and_host(uploader_name: Option<String>, 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<String> {
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<String> {
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<V, M, Fut>(
ctx: &TenantContext,
auth_event: &nostr::Event,
body: Bytes,
validate: V,
store_metadata: M,
labels: UploadAttributionLabels,
ops: (V, M),
) -> Result<BlobDescriptor, MediaError>
where
V: FnOnce(&Bytes, &MediaConfig) -> Result<(String, String), MediaError> + Send + 'static,
M: FnOnce(MetadataInput) -> Fut,
Fut: std::future::Future<Output = Result<BlobMeta, MediaError>>,
{
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<BlobDescriptor, MediaError> {
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<BlobDescriptor, MediaError> {
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<Item = Result<Bytes, axum::Error>> + Send + 'static,
content_length: Option<u64>,
labels: UploadAttributionLabels,
) -> Result<BlobDescriptor, MediaError> {
// --- 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.
+14 -1
View File
@@ -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");
+21 -1
View File
@@ -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<Arc<AppState>> 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?
}