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