Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions crates/sprout-media/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ fn default_max_video_bytes() -> u64 {
524_288_000 // 500 MB
}

fn default_max_file_bytes() -> u64 {
104_857_600 // 100 MB
}

/// Configuration for media storage (S3/MinIO).
#[derive(Debug, Clone, serde::Deserialize)]
pub struct MediaConfig {
Expand All @@ -22,6 +26,9 @@ pub struct MediaConfig {
/// Maximum upload size for video files (bytes). Default: 500 MB.
#[serde(default = "default_max_video_bytes")]
pub max_video_bytes: u64,
/// Maximum upload size for generic (non-image, non-video) files (bytes). Default: 100 MB.
#[serde(default = "default_max_file_bytes")]
pub max_file_bytes: u64,
/// Public base URL for media URLs in BlobDescriptor (must include `/media` path).
pub public_base_url: String,
/// Server authority for BUD-11 server tag validation.
Expand Down Expand Up @@ -56,6 +63,9 @@ impl MediaConfig {
if self.max_video_bytes == 0 {
return Err("max_video_bytes must be > 0".to_string());
}
if self.max_file_bytes == 0 {
return Err("max_file_bytes must be > 0".to_string());
}
Ok(())
}
}
4 changes: 2 additions & 2 deletions crates/sprout-media/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,5 @@ pub use config::MediaConfig;
pub use error::MediaError;
pub use storage::{BlobHeadMeta, BlobMeta, ByteStream, MediaStorage};
pub use types::BlobDescriptor;
pub use upload::{process_upload, process_video_upload};
pub use validation::{validate_video_file, VideoMeta};
pub use upload::{process_file_upload, process_upload, process_video_upload};
pub use validation::{serve_inline, validate_video_file, VideoMeta};
155 changes: 138 additions & 17 deletions crates/sprout-media/src/upload.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,38 +10,58 @@ use crate::error::MediaError;
use crate::storage::{BlobMeta, MediaStorage};
use crate::thumbnail::generate_image_metadata_sync;
use crate::types::BlobDescriptor;
use crate::validation::{mime_to_ext, validate_content, validate_video_file};
use crate::validation::{
mime_to_ext, validate_content, validate_file_content, validate_video_file,
};

/// Process an upload end-to-end: validate, store, thumbnail, return descriptor.
/// Shared buffered-upload pipeline for the image and generic-file paths.
///
/// This is the image path — body is already fully buffered in RAM. Do NOT use
/// this for video uploads; use [`process_video_upload`] instead.
pub async fn process_upload(
/// Both paths are identical except for two steps, which are injected:
/// - `validate`: a CPU-bound check (run inside `spawn_blocking`) that returns
/// the `(mime, ext)` pair for the body. Images derive `ext` from the MIME;
/// generic files get both from the deny-list validator.
/// - `store_metadata`: stores the sidecar (and any derived artifacts such as a
/// thumbnail) and returns the resulting [`BlobMeta`]. Images run the full
/// image-metadata pipeline; generic files write a minimal sidecar. It
/// receives the already-computed `(sha256, ext, mime, uploaded_at)` so no
/// work is repeated.
///
/// Everything else — hash, Blossom auth (10-minute window), content-addressed
/// key, the both-exist idempotency short-circuit, blob store, orphan-blob
/// handling, and descriptor build — is common. The streaming video path stays
/// separate (see [`process_video_upload`]) because it never buffers in RAM.
async fn process_buffered_upload<V, M, Fut>(
storage: &MediaStorage,
config: &MediaConfig,
auth_event: &nostr::Event,
body: Bytes,
) -> Result<BlobDescriptor, MediaError> {
// CPU-bound: validate content, compute hash, verify auth
validate: V,
store_metadata: 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>>,
{
// CPU-bound: validate content, compute hash, verify auth.
let auth = auth_event.clone();
let bytes = body.clone();
let cfg = config.clone();
let (mime, sha256, ext) = tokio::task::spawn_blocking(move || -> Result<_, MediaError> {
let mime = validate_content(&bytes, &cfg)?;
let (mime, ext) = validate(&bytes, &cfg)?;
let sha256 = hex::encode(Sha256::digest(&bytes));
let ext = mime_to_ext(&mime).to_string();
// Images: 10-minute auth window is plenty.
// Buffered uploads (image + file): 10-minute auth window is plenty.
verify_blossom_upload_auth(&auth, &sha256, cfg.server_domain.as_deref(), 600)?;
Ok((mime, sha256, ext))
})
.await
.map_err(|_| MediaError::Internal)??;

let key = format!("{sha256}.{ext}");
let meta_key = format!("_meta/{sha256}.json"); // used in idempotency check below
let meta_key = format!("_meta/{sha256}.json");

// Idempotent: check BOTH sidecar AND blob exist before short-circuiting.
// If sidecar exists but blob is missing, fall through to re-upload.
// Idempotent: short-circuit only if BOTH sidecar and blob exist. If the
// sidecar exists but the blob is missing, fall through to re-upload.
let sidecar_exists = storage.head(&meta_key).await?;
let blob_exists = storage.head(&key).await?;
if sidecar_exists && blob_exists {
Expand All @@ -60,7 +80,7 @@ pub async fn process_upload(
// Compute uploaded_at once — single source of truth for sidecar and response.
let uploaded_at = chrono::Utc::now().timestamp();

// Store blob first, then generate metadata.
// Store blob first, then metadata.
// On failure we intentionally do NOT delete the orphan blob — concurrent
// uploads of the same hash could race and delete a blob that another
// request is about to reference via its sidecar. Orphan blobs are
Expand All @@ -69,9 +89,16 @@ pub async fn process_upload(
// matching sidecar after a grace period.
storage.put(&key, &body, &mime).await?;

match generate_and_store_metadata(storage, config, &sha256, &ext, &mime, &body, uploaded_at)
.await
{
let meta_result = store_metadata(MetadataInput {
sha256: sha256.clone(),
ext: ext.clone(),
mime: mime.clone(),
body: body.clone(),
uploaded_at,
})
.await;

match meta_result {
Ok(meta) => Ok(build_descriptor(
config,
&sha256,
Expand All @@ -88,6 +115,99 @@ pub async fn process_upload(
}
}

/// Inputs handed to a buffered-upload metadata builder, after the shared
/// pipeline has already validated, hashed, and stored the blob. Owned so the
/// builder's future doesn't borrow the pipeline's locals; `body` is a `Bytes`
/// handle, so cloning it is a refcount bump, not a copy.
struct MetadataInput {
sha256: String,
ext: String,
mime: String,
body: Bytes,
uploaded_at: i64,
}

/// Process an upload end-to-end: validate, store, thumbnail, return descriptor.
///
/// This is the image path — body is already fully buffered in RAM. Do NOT use
/// this for video uploads; use [`process_video_upload`] instead.
pub async fn process_upload(
storage: &MediaStorage,
config: &MediaConfig,
auth_event: &nostr::Event,
body: Bytes,
) -> Result<BlobDescriptor, MediaError> {
process_buffered_upload(
storage,
config,
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,
&input.sha256,
&input.ext,
&input.mime,
&input.body,
input.uploaded_at,
)
.await
},
)
.await
}

/// Process a generic (non-image, non-video) file upload end-to-end.
///
/// This is the catch-all attachment path: documents, archives, audio, text,
/// data — anything that isn't a previewable image or an H.264 MP4. The body is
/// fully buffered in RAM (bounded by `config.max_file_bytes` at the transport
/// layer), validated against the deny-list + size cap, stored, and recorded in
/// a minimal sidecar. No thumbnail, no dimensions, no duration.
///
/// The resulting blob is served with `Content-Disposition: attachment`, so the
/// client always downloads it rather than rendering it inline.
pub async fn process_file_upload(
storage: &MediaStorage,
config: &MediaConfig,
auth_event: &nostr::Event,
body: Bytes,
) -> Result<BlobDescriptor, MediaError> {
process_buffered_upload(
storage,
config,
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,
};
let meta_key = format!("_meta/{}.json", input.sha256);
let meta_json = serde_json::to_vec(&meta)?;
storage
.put(&meta_key, &meta_json, "application/json")
.await?;
Ok(meta)
},
)
.await
}

/// Process a video upload end-to-end using a streaming pipeline.
///
/// Unlike [`process_upload`], this function:
Expand Down Expand Up @@ -356,6 +476,7 @@ mod tests {
max_image_bytes: 50 * 1024 * 1024,
max_gif_bytes: 10 * 1024 * 1024,
max_video_bytes: 524_288_000,
max_file_bytes: 104_857_600,
public_base_url: "https://media.example.com".to_string(),
server_domain: None,
}
Expand Down
Loading