feat(media): add sharded compatibility reads

Co-authored-by: npub128x7j3pwgm4vs8yra3c42fcgcwcvh94g3luwzkqa376du2q6l0esqcrwch <51cde9442e46eac81c83ec71552708c3b0cb96a88ff8e1581d8fb4de281afbf3@buzz.block.builderlab.xyz>
Signed-off-by: npub128x7j3pwgm4vs8yra3c42fcgcwcvh94g3luwzkqa376du2q6l0esqcrwch <51cde9442e46eac81c83ec71552708c3b0cb96a88ff8e1581d8fb4de281afbf3@buzz.block.builderlab.xyz>
This commit is contained in:
npub128x7j3pwgm4vs8yra3c42fcgcwcvh94g3luwzkqa376du2q6l0esqcrwch
2026-07-31 17:12:33 -04:00
parent b1b283cd4c
commit a8219bfeb4
5 changed files with 241 additions and 27 deletions
+190
View File
@@ -0,0 +1,190 @@
//! Deterministic object-key derivation for media payloads.
//!
//! Public Blossom URLs stay flat (`/media/<sha>.<ext>`), while S3 payloads use
//! hash-leading shards so aggregate request traffic is distributed before the
//! community segment. Legacy keys remain read candidates during migration.
use buzz_core::tenant::{CommunityId, TenantContext};
/// Invalid data supplied to media object-key construction.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum MediaKeyError {
/// SHA-256 must be exactly 64 lowercase hexadecimal characters.
#[error("invalid SHA-256 digest")]
InvalidSha256,
/// Extensions are canonical lowercase alphanumeric tokens of 1-8 bytes.
#[error("invalid media extension")]
InvalidExtension,
/// Only `<sha>.<ext>` and `<sha>.thumb.jpg` payload names are accepted.
#[error("invalid media payload name")]
InvalidPayloadName,
}
/// Ordered object keys for compatibility reads.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MediaReadCandidates {
/// Hash-sharded, community-scoped key tried first.
pub sharded: String,
/// Flat pre-migration key tried only when `sharded` is not found.
pub legacy: String,
}
fn validate_sha256(sha256: &str) -> Result<(), MediaKeyError> {
if sha256.len() == 64
&& sha256
.bytes()
.all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
{
Ok(())
} else {
Err(MediaKeyError::InvalidSha256)
}
}
fn validate_extension(ext: &str) -> Result<(), MediaKeyError> {
if (1..=8).contains(&ext.len())
&& ext
.bytes()
.all(|byte| byte.is_ascii_digit() || byte.is_ascii_lowercase())
{
Ok(())
} else {
Err(MediaKeyError::InvalidExtension)
}
}
/// Flat pre-migration blob key: `<sha256>.<ext>`.
pub fn legacy_blob_key(sha256: &str, ext: &str) -> Result<String, MediaKeyError> {
validate_sha256(sha256)?;
validate_extension(ext)?;
Ok(format!("{sha256}.{ext}"))
}
/// Hash-leading blob key: `m/<2>/<2>/<community>/<sha256>.<ext>`.
pub fn sharded_blob_key(
community: CommunityId,
sha256: &str,
ext: &str,
) -> Result<String, MediaKeyError> {
let filename = legacy_blob_key(sha256, ext)?;
Ok(format!(
"m/{}/{}/{community}/{filename}",
&sha256[..2],
&sha256[2..4]
))
}
/// Flat pre-migration thumbnail key: `<sha256>.thumb.jpg`.
pub fn legacy_thumb_key(sha256: &str) -> Result<String, MediaKeyError> {
validate_sha256(sha256)?;
Ok(format!("{sha256}.thumb.jpg"))
}
/// Hash-leading thumbnail key: `m/<2>/<2>/<community>/<sha256>.thumb.jpg`.
pub fn sharded_thumb_key(community: CommunityId, sha256: &str) -> Result<String, MediaKeyError> {
let filename = legacy_thumb_key(sha256)?;
Ok(format!(
"m/{}/{}/{community}/{filename}",
&sha256[..2],
&sha256[2..4]
))
}
/// Build new-first, legacy-fallback candidates from a validated public payload name.
///
/// The community always comes from the server-resolved tenant context; callers
/// cannot supply it through a URL, sidecar, or upload record.
pub fn read_candidates(
ctx: &TenantContext,
payload_name: &str,
) -> Result<MediaReadCandidates, MediaKeyError> {
if let Some(sha256) = payload_name.strip_suffix(".thumb.jpg") {
return Ok(MediaReadCandidates {
sharded: sharded_thumb_key(ctx.community(), sha256)?,
legacy: legacy_thumb_key(sha256)?,
});
}
let (sha256, ext) = payload_name
.split_once('.')
.ok_or(MediaKeyError::InvalidPayloadName)?;
if ext.contains('.') {
return Err(MediaKeyError::InvalidPayloadName);
}
Ok(MediaReadCandidates {
sharded: sharded_blob_key(ctx.community(), sha256, ext)?,
legacy: legacy_blob_key(sha256, ext)?,
})
}
#[cfg(test)]
mod tests {
use super::*;
use uuid::Uuid;
const SHA: &str = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789";
fn tenant(n: u128) -> TenantContext {
TenantContext::resolved(CommunityId::from_uuid(Uuid::from_u128(n)), "media.example")
}
#[test]
fn derives_hash_leading_community_scoped_blob_and_thumb_keys() {
let ctx = tenant(1);
let community = ctx.community();
assert_eq!(
sharded_blob_key(community, SHA, "jpg").unwrap(),
format!("m/ab/cd/{community}/{SHA}.jpg")
);
assert_eq!(
sharded_thumb_key(community, SHA).unwrap(),
format!("m/ab/cd/{community}/{SHA}.thumb.jpg")
);
assert_ne!(
sharded_blob_key(community, SHA, "jpg").unwrap(),
sharded_blob_key(tenant(2).community(), SHA, "jpg").unwrap()
);
}
#[test]
fn orders_sharded_before_legacy_for_blobs_and_thumbnails() {
let ctx = tenant(1);
let community = ctx.community();
assert_eq!(
read_candidates(&ctx, &format!("{SHA}.png")).unwrap(),
MediaReadCandidates {
sharded: format!("m/ab/cd/{community}/{SHA}.png"),
legacy: format!("{SHA}.png"),
}
);
assert_eq!(
read_candidates(&ctx, &format!("{SHA}.thumb.jpg")).unwrap(),
MediaReadCandidates {
sharded: format!("m/ab/cd/{community}/{SHA}.thumb.jpg"),
legacy: format!("{SHA}.thumb.jpg"),
}
);
}
#[test]
fn rejects_noncanonical_or_ambiguous_inputs() {
for sha in ["abc", &"A".repeat(64), &"g".repeat(64)] {
assert_eq!(
legacy_blob_key(sha, "jpg"),
Err(MediaKeyError::InvalidSha256)
);
}
for ext in ["", "JPG", "tar.gz", "toolongext", "../jpg"] {
assert_eq!(
legacy_blob_key(SHA, ext),
Err(MediaKeyError::InvalidExtension)
);
}
assert_eq!(
read_candidates(&tenant(1), SHA),
Err(MediaKeyError::InvalidPayloadName)
);
}
}
+5
View File
@@ -6,6 +6,7 @@ pub mod auth;
pub mod bucket_index;
pub mod config;
pub mod error;
pub mod keys;
pub mod storage;
pub mod thumbnail;
pub mod types;
@@ -19,6 +20,10 @@ pub use bucket_index::{
};
pub use config::{MediaConfig, S3AddressingStyle};
pub use error::MediaError;
pub use keys::{
legacy_blob_key, legacy_thumb_key, read_candidates, sharded_blob_key, sharded_thumb_key,
MediaKeyError, MediaReadCandidates,
};
pub use storage::{BlobHeadMeta, BlobMeta, ByteStream, MediaStorage};
pub use types::BlobDescriptor;
pub use upload::{process_file_upload, process_upload, process_video_upload};
+21
View File
@@ -177,6 +177,27 @@ impl MediaStorage {
}
}
/// Resolve a media payload to its new-first, legacy-fallback object key.
///
/// Only an actual not-found result advances to the legacy candidate. Any
/// authorization, transport, throttling, or service error is returned so
/// the compatibility path cannot mask an unhealthy object store.
pub async fn resolve_read_key(
&self,
ctx: &TenantContext,
payload_name: &str,
) -> Result<String, MediaError> {
let candidates =
crate::keys::read_candidates(ctx, payload_name).map_err(|_| MediaError::NotFound)?;
match self.head_with_metadata(&candidates.sharded).await? {
Some(_) => Ok(candidates.sharded),
None => match self.head_with_metadata(&candidates.legacy).await? {
Some(_) => Ok(candidates.legacy),
None => Err(MediaError::NotFound),
},
}
}
/// Build the community-scoped sidecar key for a given sha256 (bare hash).
///
/// Raw media bytes remain shared content-addressed CAS (`{sha}.{ext}`), but
+15 -3
View File
@@ -666,7 +666,13 @@ pub(crate) async fn serve_blob_for_tenant(
"attachment"
};
let key = resolve_s3_key(&state.media_storage, tenant, sha256_ext).await?;
let key = state
.media_storage
.resolve_read_key(
tenant,
&resolve_payload_name(&state.media_storage, tenant, sha256_ext).await?,
)
.await?;
// Parse optional Range header.
let range_header = req_headers
@@ -835,7 +841,13 @@ pub async fn head_blob(
sidecar_mime
};
let key = resolve_s3_key(&state.media_storage, &tenant, &sha256_ext).await?;
let key = state
.media_storage
.resolve_read_key(
&tenant,
&resolve_payload_name(&state.media_storage, &tenant, &sha256_ext).await?,
)
.await?;
match state.media_storage.head_with_metadata(&key).await? {
Some(meta) => {
let size_str = meta.size.to_string();
@@ -861,7 +873,7 @@ pub async fn head_blob(
///
/// Sidecar-derived extensions are validated as safe tokens to prevent
/// object-key confusion if sidecar data is ever tampered with.
async fn resolve_s3_key(
async fn resolve_payload_name(
storage: &buzz_media::MediaStorage,
tenant: &TenantContext,
sha256_ext: &str,
+10 -24
View File
@@ -244,15 +244,12 @@ pub async fn verify_imeta_blobs(
.await
.map_err(|_| format!("imeta references nonexistent blob: {x_value}"))?;
// 2. HEAD the actual blob object
let blob_key = format!("{x_value}.{}", sidecar.ext);
let blob_exists = storage
.head(&blob_key)
// 2. Resolve the actual blob object across sharded and legacy layouts.
let blob_name = format!("{x_value}.{}", sidecar.ext);
storage
.resolve_read_key(ctx, &blob_name)
.await
.map_err(|e| format!("storage error checking blob {x_value}: {e}"))?;
if !blob_exists {
return Err(format!("imeta blob object missing in storage: {x_value}"));
}
// 3. Cross-check claimed metadata against sidecar.
if !m_value.is_empty() && sidecar.mime_type != m_value {
@@ -275,18 +272,12 @@ pub async fn verify_imeta_blobs(
}
}
// 4. If thumb is claimed, HEAD the thumbnail object too.
// 4. If thumb is claimed, resolve the thumbnail object too.
if !thumb_value.is_empty() {
let thumb_key = format!("{x_value}.thumb.jpg");
let thumb_exists = storage
.head(&thumb_key)
storage
.resolve_read_key(ctx, &format!("{x_value}.thumb.jpg"))
.await
.map_err(|e| format!("storage error checking thumbnail: {e}"))?;
if !thumb_exists {
return Err(format!(
"imeta thumb references missing thumbnail: {x_value}"
));
}
}
// 5. If image (poster frame) is claimed, verify sidecar + blob.
@@ -316,16 +307,11 @@ pub async fn verify_imeta_blobs(
}
}
let img_key = format!("{img_hash}.{}", img_sidecar.ext);
let img_exists = storage
.head(&img_key)
let img_name = format!("{img_hash}.{}", img_sidecar.ext);
storage
.resolve_read_key(ctx, &img_name)
.await
.map_err(|e| format!("storage error checking poster image: {e}"))?;
if !img_exists {
return Err(format!(
"imeta image references missing poster frame: {img_hash}"
));
}
}
}
Ok(())