mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
@@ -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};
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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?
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user