mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
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>
294 lines
10 KiB
Rust
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>,
|
|
}
|