Files
buzz/crates/buzz-media/src/storage.rs
Tyler 14fba21e57 Multi-tenant Buzz relay: community_id as a server-resolved key (comprehensive rewrite) (#1321)
Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
Signed-off-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: npub17jjz49l9jjmhhk7cac63j8yt9z555n9cw8vk7v5jz4vzw4ppld5qgj57cc <f4a42a97e594b77bdbd8ee35191c8b28a94a4cb871d96f32921558275421fb68@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Mari <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Sami <f4a42a97e594b77bdbd8ee35191c8b28a94a4cb871d96f32921558275421fb68@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Max <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Quinn <96f056ad5f2305c8ddf637dc65d048aa4c12d7daeb8867690e34fca46b0ef64c@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Dawn <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Co-authored-by: Sami <sami@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@sprout-oss.stage.blox.sqprod.co>
2026-06-29 12:39:02 -04:00

294 lines
10 KiB
Rust

//! S3/MinIO storage client.
use std::path::Path;
use std::pin::Pin;
use buzz_core::tenant::{CommunityId, TenantContext};
use crate::config::MediaConfig;
use crate::error::MediaError;
use bytes::Bytes;
use s3::creds::Credentials;
use s3::{Bucket, Region};
use serde::{Deserialize, Serialize};
/// A stream of byte chunks from S3, usable with `axum::body::Body::from_stream()`.
pub type ByteStream = Pin<Box<dyn futures_core::Stream<Item = Result<Bytes, MediaError>> + Send>>;
/// S3-compatible object storage client.
pub struct MediaStorage {
bucket: Box<Bucket>,
}
impl MediaStorage {
/// Create a new storage client from config.
pub fn new(config: &MediaConfig) -> Result<Self, MediaError> {
let region = Region::Custom {
region: "us-east-1".into(),
endpoint: config.s3_endpoint.clone(),
};
let creds = Credentials::new(
Some(&config.s3_access_key),
Some(&config.s3_secret_key),
None,
None,
None,
)
.map_err(|e| MediaError::StorageError(e.to_string()))?;
let bucket = Bucket::new(&config.s3_bucket, region, creds)
.map_err(|e| MediaError::StorageError(e.to_string()))?
.with_path_style();
Ok(Self { bucket })
}
/// Store an object from a byte slice.
///
/// Used for images, sidecars, and thumbnails. For large video files use
/// [`put_file`] to avoid loading the entire blob into RAM.
pub async fn put(&self, key: &str, bytes: &[u8], content_type: &str) -> Result<(), MediaError> {
self.bucket
.put_object_with_content_type(key, bytes, content_type)
.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
/// the file incrementally via an 8 MiB `BufReader`. The full file is never
/// held in memory simultaneously. Intended for video blobs (up to 500 MB).
pub async fn put_file(
&self,
key: &str,
path: &Path,
content_type: &str,
) -> Result<(), MediaError> {
const BUF: usize = 8 * 1024 * 1024; // 8 MiB read buffer
let file = tokio::fs::File::open(path)
.await
.map_err(|e| MediaError::Io(e.to_string()))?;
let mut reader = tokio::io::BufReader::with_capacity(BUF, file);
self.bucket
.put_object_stream_with_content_type(&mut reader, key, content_type)
.await?;
Ok(())
}
/// Retrieve an object's bytes.
pub async fn get(&self, key: &str) -> Result<Vec<u8>, MediaError> {
match self.bucket.get_object(key).await {
Ok(response) => Ok(response.to_vec()),
Err(s3::error::S3Error::HttpFailWithBody(404, _)) => Err(MediaError::NotFound),
Err(e) => Err(MediaError::StorageError(e.to_string())),
}
}
/// Retrieve a byte range from an object via S3-native `Range` GET.
///
/// `start` and `end` are inclusive byte offsets. Only the requested slice
/// is transferred from S3 — the full object is never loaded into RAM.
/// Intended for HTTP 206 range responses on large video blobs.
pub async fn get_range(&self, key: &str, start: u64, end: u64) -> Result<Vec<u8>, MediaError> {
match self.bucket.get_object_range(key, start, Some(end)).await {
Ok(response) => Ok(response.to_vec()),
Err(s3::error::S3Error::HttpFailWithBody(404, _)) => Err(MediaError::NotFound),
Err(e) => Err(MediaError::StorageError(e.to_string())),
}
}
/// Stream an object's bytes from S3 without loading into RAM.
///
/// Returns a pinned stream of `Result<Bytes, MediaError>` chunks.
/// The full object is never buffered — intended for streaming large
/// blobs (video) directly into HTTP responses via `Body::from_stream()`.
pub async fn get_stream(&self, key: &str) -> Result<ByteStream, MediaError> {
let response = self
.bucket
.get_object_stream(key)
.await
.map_err(|e| MediaError::StorageError(e.to_string()))?;
if response.status_code == 404 {
return Err(MediaError::NotFound);
}
let stream = futures_util::StreamExt::map(response.bytes, |chunk| {
chunk.map_err(|e| MediaError::StorageError(e.to_string()))
});
Ok(Box::pin(stream))
}
/// Check if an object exists. Returns false on 404.
pub async fn head(&self, key: &str) -> Result<bool, MediaError> {
match self.bucket.head_object(key).await {
Ok(_) => Ok(true),
Err(s3::error::S3Error::HttpFailWithBody(404, _)) => Ok(false),
Err(e) => Err(MediaError::StorageError(e.to_string())),
}
}
/// Delete an object. Returns an error on failure — callers decide whether to propagate.
pub async fn delete(&self, key: &str) -> Result<(), MediaError> {
self.bucket
.delete_object(key)
.await
.map_err(|e| MediaError::StorageError(e.to_string()))?;
Ok(())
}
/// HEAD with metadata — returns Content-Length (size).
pub async fn head_with_metadata(&self, key: &str) -> Result<Option<BlobHeadMeta>, MediaError> {
match self.bucket.head_object(key).await {
Ok((result, _)) => Ok(Some(BlobHeadMeta {
size: result.content_length.unwrap_or(0) as u64,
})),
Err(s3::error::S3Error::HttpFailWithBody(404, _)) => Ok(None),
Err(e) => Err(MediaError::StorageError(e.to_string())),
}
}
/// Build the community-scoped sidecar key for a given sha256 (bare hash).
///
/// Raw media bytes remain shared content-addressed CAS (`{sha}.{ext}`), but
/// the metadata sidecar is the tenant read gate. A blob in another
/// community must never be observable through a global `_meta/{sha}.json`
/// lookup.
pub fn sidecar_key(community: CommunityId, sha256: &str) -> String {
format!("_meta/{community}/{sha256}.json")
}
/// Build the community-scoped sidecar key from the resolved request tenant.
pub fn ctx_sidecar_key(ctx: &TenantContext, sha256: &str) -> String {
Self::sidecar_key(ctx.community(), sha256)
}
/// Read community-scoped sidecar JSON for a given sha256 (bare hash).
pub async fn get_sidecar(
&self,
ctx: &TenantContext,
sha256: &str,
) -> Result<BlobMeta, MediaError> {
let key = Self::ctx_sidecar_key(ctx, sha256);
let resp = self.bucket.get_object(&key).await?;
let meta: BlobMeta = serde_json::from_slice(&resp.to_vec())?;
Ok(meta)
}
/// Write community-scoped sidecar JSON for a given sha256 (bare hash).
///
/// `ctx` must be the server-resolved request tenant. Callers must never
/// derive the community from client-supplied blob metadata, URLs, or event
/// tags; this sidecar key is the tenant read gate for otherwise shared CAS
/// bytes.
pub async fn put_sidecar(
&self,
ctx: &TenantContext,
sha256: &str,
meta: &BlobMeta,
) -> Result<(), MediaError> {
let key = Self::ctx_sidecar_key(ctx, sha256);
let meta_json = serde_json::to_vec(meta)?;
self.put(&key, &meta_json, "application/json").await
}
/// Convenience: read just the MIME type from the community sidecar.
///
/// Returns `None` for both absent sidecars and storage read failures. Public
/// read handlers intentionally collapse that distinction to 404 so an
/// A-bound request cannot distinguish a B-only blob from a missing blob.
pub async fn read_sidecar_mime(&self, ctx: &TenantContext, sha256_ext: &str) -> Option<String> {
let sha256 = sha256_ext.split('.').next().unwrap_or(sha256_ext);
self.get_sidecar(ctx, sha256)
.await
.ok()
.map(|m| m.mime_type)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
fn tenant(n: u128) -> TenantContext {
TenantContext::resolved(
CommunityId::from_uuid(uuid::Uuid::from_u128(n)),
"media.example",
)
}
#[test]
fn sidecar_keys_are_community_scoped() {
let a = tenant(1);
let b = tenant(2);
let sha = "f".repeat(64);
assert_eq!(
MediaStorage::ctx_sidecar_key(&a, &sha),
format!("_meta/{}/{sha}.json", a.community())
);
assert_ne!(
MediaStorage::ctx_sidecar_key(&a, &sha),
MediaStorage::ctx_sidecar_key(&b, &sha)
);
assert_ne!(
MediaStorage::ctx_sidecar_key(&a, &sha),
format!("_meta/{sha}.json")
);
}
/// Mutate-bite shape for the media substrate: same CAS bytes/hash can be
/// known in A and B, but the sidecar is the read/existence gate. If the
/// community segment is dropped from `sidecar_key`, B's metadata overwrites
/// A's in this map and A observes B's MIME (wrong answer, not absence).
#[test]
fn same_sha_sidecars_do_not_bleed_between_communities() {
let a = tenant(1);
let b = tenant(2);
let sha = "a".repeat(64);
let mut sidecars = HashMap::new();
sidecars.insert(MediaStorage::ctx_sidecar_key(&a, &sha), "image/png");
sidecars.insert(MediaStorage::ctx_sidecar_key(&b, &sha), "video/mp4");
assert_eq!(
sidecars[&MediaStorage::ctx_sidecar_key(&a, &sha)],
"image/png"
);
assert_eq!(
sidecars[&MediaStorage::ctx_sidecar_key(&b, &sha)],
"video/mp4"
);
}
}
/// Metadata returned by HEAD — just enough for BUD-01 response headers.
pub struct BlobHeadMeta {
pub size: u64,
}
/// Full blob metadata — stored as sidecar JSON in `_meta/{community}/{sha256}.json`.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct BlobMeta {
/// Pixel dimensions ("WxH").
pub dim: String,
/// Blurhash string.
pub blurhash: String,
/// Full URL to thumbnail.
pub thumb_url: String,
/// File extension (e.g. "jpg").
pub ext: String,
/// MIME type (e.g. "image/jpeg").
pub mime_type: String,
/// File size in bytes.
pub size: u64,
/// Unix timestamp when the blob was first uploaded.
#[serde(default)]
pub uploaded_at: i64,
/// Video duration in seconds. `None` for non-video blobs.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub duration_secs: Option<f64>,
}