diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d84033e..10d4890d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,11 @@ All notable changes to Allthing are recorded here. The format follows [Keep a Ch ## [Unreleased] +### Added + +- Videos up to 250 MB upload in 8 MB chunks. Retry continues from the last acknowledged chunk; Remove cancels the pending upload. Preparation and finishing have visible states, and the cast stays disabled until its attachments are ready. +- Compatible MP4 and MOV videos are prepared for HLS from bounded metadata and file slices, preserving their frames and sound without copying the entire video into WebAssembly. Existing image limits and storage safeguards remain. + ## [0.5.1] - 2026-10-23 ### Changed diff --git a/crates/allthing-core/src/fmp4.rs b/crates/allthing-core/src/fmp4.rs index 57acce8e..f0d00c9a 100644 --- a/crates/allthing-core/src/fmp4.rs +++ b/crates/allthing-core/src/fmp4.rs @@ -3,9 +3,11 @@ //! app plays in a cast: it shows any `.m3u8` embed as a video, wherever it //! is hosted, and a plain `.mp4` as "No preview found for shared link". //! -//! The browser remuxes the MP4 or MOV the reader picked ([`remux`]): the +//! The browser plans the MP4 or MOV the reader picked ([`plan`]): the //! same coded samples, without a byte decoded or re-encoded, put back -//! together as one fragmented MP4 (an init segment, then a `moof` and an +//! together as one fragmented MP4 from small headers and original File +//! slices, without loading the video payload into WebAssembly (an init +//! segment, then a `moof` and an //! `mdat` for each few seconds, each starting at a key frame). That file is //! uploaded and kept as `.mp4`, which any browser plays by itself, and //! where its fragments are is sent with it as an [`HlsIndex`], a line short @@ -28,6 +30,15 @@ pub const MAX_SEGMENTS: usize = 80; pub const SEGMENT_SECONDS: f64 = 4.0; /// The longest an index is, in bytes. pub const MAX_INDEX_BYTES: usize = 1_800; +/// The most MP4 movie metadata the browser reads into WebAssembly. Video +/// payload stays in the selected File and is referenced by source ranges. +pub const MAX_MOOV_BYTES: usize = 8 * 1024 * 1024; +/// A combined sample-table budget, including extra tracks we inspect. +pub const MAX_SAMPLES: usize = 250_000; +/// The most owned headers and source ranges in a browser remux plan. +pub const MAX_PLAN_PARTS: usize = 32_768; +/// Empty or malicious box lists must not allocate unbounded descriptors. +const MAX_BOXES: usize = 32_768; /// The header the browser sends an index in, with the upload. pub const INDEX_HEADER: &str = "x-allthing-hls"; /// The custom metadata key the Worker keeps it under, in R2. @@ -155,6 +166,21 @@ pub struct Remuxed { pub index: HlsIndex, } +/// One ordered piece of a fragmented MP4. Only headers are owned: the +/// browser appends `File.slice(offset, offset + len)` for source pieces. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum RemuxPart { + Bytes(Vec), + Source { offset: u64, len: u64 }, +} + +/// A fragmented MP4 described without reading or copying sample payload. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RemuxPlan { + pub parts: Vec, + pub index: HlsIndex, +} + /// Why a file wasn't remuxed: it is kept as it is. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum NotRemuxed { @@ -162,7 +188,8 @@ pub enum NotRemuxed { Unreadable, /// No H.264 or HEVC video track, or audio that isn't AAC. Unsupported, - /// Already fragmented, or with no samples. + /// Already fragmented, no samples, or beyond the bounded metadata, + /// sample, source-piece or playlist limits. Unsuitable, } @@ -188,6 +215,9 @@ fn boxes(data: &[u8]) -> Option> { if size < header || size > rest.len() as u64 { return None; } + if out.len() == MAX_BOXES { + return None; + } out.push((kind, &rest[header as usize..size as usize])); at += size as usize; } @@ -288,7 +318,11 @@ fn handler(trak: &[u8]) -> Option<[u8; 4]> { path(trak, &[b"mdia", b"hdlr"])?.get(8..12)?.try_into().ok() } -fn read_track(trak: &[u8], file_len: u64) -> Result>, NotRemuxed> { +fn read_track<'a>( + trak: &'a [u8], + file_len: u64, + remaining_samples: &mut usize, +) -> Result>, NotRemuxed> { use NotRemuxed::*; let kind = match handler(trak).ok_or(Unreadable)? { h if &h == b"vide" => Kind::Video, @@ -323,6 +357,11 @@ fn read_track(trak: &[u8], file_len: u64) -> Result>, NotRemuxe if timescale == 0 { return Err(Unreadable); } + let count = u32_at(child(stbl, b"stsz").ok_or(Unreadable)?, 8).ok_or(Unreadable)? as usize; + if count > *remaining_samples { + return Err(Unsuitable); + } + *remaining_samples -= count; let samples = samples(stbl, file_len, kind).ok_or(Unreadable)?; if samples.is_empty() { return Err(Unsuitable); @@ -335,7 +374,7 @@ fn samples(stbl: &[u8], file_len: u64, kind: Kind) -> Option> { // Sizes. let stsz = child(stbl, b"stsz")?; let (fixed, count) = (u32_at(stsz, 4)?, u32_at(stsz, 8)?); - if count == 0 || count > 2_000_000 { + if count == 0 || count as usize > MAX_SAMPLES { return Some(Vec::new()); } let sizes: Vec = if fixed != 0 { vec![fixed; count as usize] } else { words(table(stsz, 8, 4)?.1) }; @@ -351,15 +390,28 @@ fn samples(stbl: &[u8], file_len: u64, kind: Kind) -> Option> { let (_, stsc) = table(child(stbl, b"stsc")?, 4, 12)?; let stsc = words(stsc); let runs: Vec<(u32, u32)> = stsc.as_chunks::<3>().0.iter().map(|[first, per, _]| (*first, *per)).collect(); + if chunks.len() > count + || runs.first()?.0 != 1 + || runs.iter().any(|(_, per)| *per == 0) + || runs.windows(2).any(|pair| pair[0].0 >= pair[1].0) + { + return None; + } let mut offsets = Vec::with_capacity(count); + let mut run = 0; for (i, chunk_offset) in chunks.iter().enumerate() { let chunk = i as u32 + 1; - let per = runs.iter().rev().find(|(first, _)| *first <= chunk)?.1; + while runs.get(run + 1).is_some_and(|(first, _)| *first <= chunk) { + run += 1; + } + let per = runs[run].1 as usize; + if offsets.len().checked_add(per)? > count { + return None; + } let mut at = *chunk_offset; - for _ in 0..per { - let Some(size) = sizes.get(offsets.len()) else { break }; + for size in &sizes[offsets.len()..offsets.len() + per] { offsets.push(at); - at += u64::from(*size); + at = at.checked_add(u64::from(*size))?; } } if offsets.len() != count { @@ -492,21 +544,27 @@ fn audio_codec(kind: &[u8; 4], entry: &[u8]) -> Option { // --------------------------------------------------------------------------- // Remuxing -/// `file`, an MP4 or MOV, as one fragmented MP4 with its index; or why not. -pub fn remux(file: &[u8]) -> Result { +/// Plan a remux from the body of the source file's `moov` box alone. +/// `file_len` is the entire original file's length, used to validate every +/// source range. The caller reads only bounded metadata, then constructs +/// a Blob from the owned headers and slices of its original File. No media +/// sample payload is passed through WebAssembly. +pub fn plan(moov: &[u8], file_len: u64) -> Result { use NotRemuxed::*; - let top = boxes(file).ok_or(Unreadable)?; - let moov = top.iter().find(|(k, _)| k == b"moov").map(|(_, b)| *b).ok_or(Unreadable)?; + if moov.len() > MAX_MOOV_BYTES { + return Err(Unsuitable); + } if child(moov, b"mvex").is_some() { return Err(Unsuitable); } let mut video = None; let mut audio = None; + let mut remaining_samples = MAX_SAMPLES; for (kind, trak) in boxes(moov).ok_or(Unreadable)? { if &kind != b"trak" { continue; } - match read_track(trak, file.len() as u64) { + match read_track(trak, file_len, &mut remaining_samples) { Ok(Some(t)) if t.kind == Kind::Video && video.is_none() => video = Some(t), Ok(Some(t)) if t.kind == Kind::Audio && audio.is_none() => audio = Some(t), Ok(_) => {} @@ -515,7 +573,10 @@ pub fn remux(file: &[u8]) -> Result { } } let video = video.ok_or(Unsupported)?; - if video.width == 0 || video.height == 0 || audio.as_ref().is_some_and(|a| a.id == video.id) { + if !(1..=16_384).contains(&video.width) + || !(1..=16_384).contains(&video.height) + || audio.as_ref().is_some_and(|a| a.id == video.id) + { return Err(Unreadable); } let tracks: Vec<&Track> = std::iter::once(&video).chain(audio.as_ref()).collect(); @@ -545,9 +606,20 @@ pub fn remux(file: &[u8]) -> Result { .map(|&(_, b)| video.samples.get(b).map_or(f64::INFINITY, |s| s.dts as f64 / f64::from(video.timescale))) .collect(); - let mut out = Vec::with_capacity(file.len() + 4096); - init_segment(&mut out, moov, &tracks).ok_or(Unreadable)?; - let init = out.len() as u64; + // A corrupt table must not duplicate a small file into an enormous + // output, even when all of its individual ranges are in bounds. + let payload_bytes = tracks + .iter() + .flat_map(|t| &t.samples) + .try_fold(0u64, |n, s| n.checked_add(u64::from(s.size))) + .ok_or(Unreadable)?; + if payload_bytes > file_len { + return Err(Unreadable); + } + let mut init_bytes = Vec::new(); + init_segment(&mut init_bytes, moov, &tracks).ok_or(Unreadable)?; + let init = init_bytes.len() as u64; + let mut pieces = vec![RemuxPart::Bytes(init_bytes)]; let mut segments = Vec::with_capacity(bounds.len()); let mut audio_at = 0usize; for (k, &(a, b)) in bounds.iter().enumerate() { @@ -563,25 +635,76 @@ pub fn remux(file: &[u8]) -> Result { parts.push((audio, &audio.samples[from..audio_at])); } } - let start = out.len(); - fragment(&mut out, k as u32 + 1, &parts, file); + let (header, len) = fragment_header(k as u32 + 1, &parts).ok_or(Unreadable)?; + append_part(&mut pieces, RemuxPart::Bytes(header))?; + for (_, samples) in &parts { + for s in *samples { + append_part(&mut pieces, RemuxPart::Source { offset: s.offset, len: u64::from(s.size) })?; + } + } let ticks: u64 = video.samples[a..b].iter().map(|s| u64::from(s.duration)).sum(); let ms = ((ticks as f64 * 1000.0 / f64::from(video.timescale)).round() as u32).max(1); - segments.push((ms, (out.len() - start) as u64)); + segments.push((ms, len)); } let codecs = tracks.iter().map(|t| t.codec.as_str()).collect::>().join(","); let index = HlsIndex { width: video.width, height: video.height, codecs, init, segments }; - if index.encode().len() > MAX_INDEX_BYTES { + if HlsIndex::parse(&index.encode()).as_ref() != Some(&index) { return Err(Unsuitable); } - Ok(Remuxed { bytes: out, index }) + Ok(RemuxPlan { parts: pieces, index }) +} + +/// Append a piece, joining adjacent source ranges without crossing a +/// fragment header. Bounding the list also bounds browser Blob objects. +fn append_part(parts: &mut Vec, part: RemuxPart) -> Result<(), NotRemuxed> { + if let RemuxPart::Source { offset, len } = part { + if len == 0 { + return Ok(()); + } + if let Some(RemuxPart::Source { offset: previous, len: previous_len }) = parts.last_mut() + && previous.checked_add(*previous_len) == Some(offset) + { + *previous_len = previous_len.checked_add(len).ok_or(NotRemuxed::Unreadable)?; + return Ok(()); + } + if parts.len() == MAX_PLAN_PARTS { + return Err(NotRemuxed::Unsuitable); + } + parts.push(RemuxPart::Source { offset, len }); + } else { + if parts.len() == MAX_PLAN_PARTS { + return Err(NotRemuxed::Unsuitable); + } + parts.push(part); + } + Ok(()) +} + +/// `file`, an MP4 or MOV, as one fragmented MP4 with its index; or why not. +/// Native callers concatenate the same plan the browser uses with slices. +pub fn remux(file: &[u8]) -> Result { + let moov = child(file, b"moov").ok_or(NotRemuxed::Unreadable)?; + let planned = plan(moov, file.len() as u64)?; + let mut bytes = Vec::new(); + for piece in planned.parts { + match piece { + RemuxPart::Bytes(header) => bytes.extend_from_slice(&header), + RemuxPart::Source { offset, len } => { + let start = usize::try_from(offset).map_err(|_| NotRemuxed::Unreadable)?; + let end = usize::try_from(offset.checked_add(len).ok_or(NotRemuxed::Unreadable)?) + .map_err(|_| NotRemuxed::Unreadable)?; + bytes.extend_from_slice(file.get(start..end).ok_or(NotRemuxed::Unreadable)?); + } + } + } + Ok(Remuxed { bytes, index: planned.index }) } /// `ftyp` and a `moov` of the tracks, without samples, with `mvex`. fn init_segment(out: &mut Vec, moov: &[u8], tracks: &[&Track]) -> Option<()> { put_raw(out, b"ftyp", b"iso6\0\0\0\0iso6isomiso5dashmp41"); let mvhd = child(moov, b"mvhd")?; - let next_id = tracks.iter().map(|t| t.id).max()? + 1; + let next_id = tracks.iter().map(|t| t.id).max()?.checked_add(1)?; let mut trak_bodies = Vec::new(); for t in tracks { let mut trak = Vec::new(); @@ -642,11 +765,12 @@ fn mdia(out: &mut Vec, body: &[u8]) { } } -/// One `moof` and its `mdat`: each part's samples, as a track fragment. -fn fragment(out: &mut Vec, sequence: u32, parts: &[(&Track, &[Sample])], file: &[u8]) { - let moof_start = out.len(); +/// One `moof` and the `mdat` header, without reading any sample data. +/// Returns its owned bytes and the complete fragment's length. +fn fragment_header(sequence: u32, parts: &[(&Track, &[Sample])]) -> Option<(Vec, u64)> { + let mut out = Vec::new(); let mut data_offsets = Vec::new(); - put_box(out, b"moof", |m| { + put_box(&mut out, b"moof", |m| { put_full(m, b"mfhd", 0, 0, |b| b.extend_from_slice(&sequence.to_be_bytes())); for (track, samples) in parts { put_box(m, b"traf", |t| { @@ -670,21 +794,18 @@ fn fragment(out: &mut Vec, sequence: u32, parts: &[(&Track, &[Sample])], fil }); } }); - let moof_len = out.len() - moof_start; + let moof_len = out.len() as u64; // Each part's data, in order, after the mdat's header. let mut at = moof_len + 8; for (i, (_, samples)) in parts.iter().enumerate() { let field = data_offsets[i]; - out[field..field + 4].copy_from_slice(&(at as u32).to_be_bytes()); - at += samples.iter().map(|s| s.size as usize).sum::(); + out[field..field + 4].copy_from_slice(&i32::try_from(at).ok()?.to_be_bytes()); + at = samples.iter().try_fold(at, |n, s| n.checked_add(u64::from(s.size)))?; } - put_box(out, b"mdat", |d| { - for (_, samples) in parts { - for s in *samples { - d.extend_from_slice(&file[s.offset as usize..s.offset as usize + s.size as usize]); - } - } - }); + let mdat_len = u32::try_from(at.checked_sub(moof_len)?).ok()?; + out.extend_from_slice(&mdat_len.to_be_bytes()); + out.extend_from_slice(b"mdat"); + Some((out, at)) } #[cfg(test)] @@ -702,11 +823,12 @@ mod tests { /// Every sample of `track` in `file`, as (dts, bytes), in order. fn source_samples(file: &[u8], kind: Kind) -> Vec<(u64, Vec)> { let moov = child(file, b"moov").unwrap(); + let mut remaining_samples = MAX_SAMPLES; let trak = boxes(moov) .unwrap() .into_iter() .filter(|(k, _)| k == b"trak") - .map(|(_, b)| read_track(b, file.len() as u64).unwrap().unwrap()) + .map(|(_, b)| read_track(b, file.len() as u64, &mut remaining_samples).unwrap().unwrap()) .find(|t| t.kind == kind) .unwrap(); trak.samples @@ -749,6 +871,86 @@ mod tests { out } + #[test] + fn a_metadata_only_plan_preserves_samples_and_ranges() { + for name in ["video-h264-aac.mp4", "video-silent.mov", "video-hevc.mp4"] { + let source = fixture(name); + let planned = plan(child(&source, b"moov").unwrap(), source.len() as u64).unwrap(); + let mut joined = Vec::new(); + let mut owned = 0; + let mut ranges = 0; + for part in &planned.parts { + match part { + RemuxPart::Bytes(header) => { + owned += header.len(); + joined.extend_from_slice(header); + } + RemuxPart::Source { offset, len } => { + assert!(*len > 0); + assert!(offset.checked_add(*len).unwrap() <= source.len() as u64); + ranges += 1; + joined.extend_from_slice(&source[*offset as usize..(*offset + *len) as usize]); + } + } + } + assert!(owned < source.len(), "only headers are owned for {name}"); + assert!(ranges > 0 && planned.parts.len() <= MAX_PLAN_PARTS); + assert_eq!(joined.len() as u64, planned.index.total()); + assert_eq!(fragment_samples(&joined, 1), source_samples(&source, Kind::Video)); + if planned.index.codecs.contains(',') { + assert_eq!(fragment_samples(&joined, 2), source_samples(&source, Kind::Audio)); + } + let native = remux(&source).unwrap(); + assert_eq!(native.index, planned.index); + assert_eq!(native.bytes, joined); + } + } + + #[test] + fn planning_rejects_oversized_metadata_and_sample_tables_before_expansion() { + assert_eq!(plan(&vec![0; MAX_MOOV_BYTES + 1], u64::MAX), Err(NotRemuxed::Unsuitable)); + let source = fixture("video-h264-aac.mp4"); + let mut moov = child(&source, b"moov").unwrap().to_vec(); + let size_at = moov.windows(4).position(|bytes| bytes == b"stsz").unwrap(); + // The fixed-size form occupies only twelve bytes but could ask for + // billions of decoded table entries. Refuse it before allocating. + moov[size_at + 8..size_at + 12].copy_from_slice(&1u32.to_be_bytes()); + moov[size_at + 12..size_at + 16].copy_from_slice(&u32::MAX.to_be_bytes()); + assert_eq!(plan(&moov, u64::MAX), Err(NotRemuxed::Unsuitable)); + } + + #[test] + fn source_pieces_coalesce_and_the_plan_cannot_grow_without_bound() { + let mut pieces = Vec::new(); + append_part(&mut pieces, RemuxPart::Source { offset: 12, len: 4 }).unwrap(); + append_part(&mut pieces, RemuxPart::Source { offset: 16, len: 3 }).unwrap(); + append_part(&mut pieces, RemuxPart::Source { offset: 19, len: 0 }).unwrap(); + assert_eq!(pieces, vec![RemuxPart::Source { offset: 12, len: 7 }]); + append_part(&mut pieces, RemuxPart::Bytes(vec![0; 8])).unwrap(); + append_part(&mut pieces, RemuxPart::Source { offset: 19, len: 2 }).unwrap(); + assert_eq!(pieces.len(), 3, "a header separates source ranges"); + pieces.resize(MAX_PLAN_PARTS, RemuxPart::Source { offset: 0, len: 1 }); + assert_eq!(append_part(&mut pieces, RemuxPart::Source { offset: 2, len: 1 }), Err(NotRemuxed::Unsuitable)); + assert_eq!(pieces.len(), MAX_PLAN_PARTS); + } + + #[test] + fn bad_metadata_cannot_overflow_source_ranges_or_track_ids() { + let source = fixture("video-h264-aac.mp4"); + let original = child(&source, b"moov").unwrap(); + assert_eq!(plan(original, 1), Err(NotRemuxed::Unreadable)); + let mut moov = original.to_vec(); + let tkhd = moov.windows(4).position(|bytes| bytes == b"tkhd").unwrap(); + // tkhd version zero: twelve bytes from its body to the track id. + moov[tkhd + 16..tkhd + 20].copy_from_slice(&u32::MAX.to_be_bytes()); + assert_eq!(plan(&moov, source.len() as u64), Err(NotRemuxed::Unreadable)); + // A prefix is refused or is itself readable metadata, never a + // panic (for example it may end after the complete video track). + for end in (0..original.len()).step_by(113) { + let _ = plan(&original[..end], source.len() as u64); + } + } + #[test] fn remuxes_h264_and_aac_sample_for_sample() { let file = fixture("video-h264-aac.mp4"); diff --git a/crates/allthing-core/src/media.rs b/crates/allthing-core/src/media.rs index ebda5c34..79480201 100644 --- a/crates/allthing-core/src/media.rs +++ b/crates/allthing-core/src/media.rs @@ -12,7 +12,7 @@ //! sandbox policy ([`served_headers`]), so a file that isn't what it says //! can't run as a page. //! -//! Limits: 10 MiB an image and 50 MiB a video; per reader, 300 MiB and 60 +//! Limits: 10 MiB an image and 250 MiB a video; per reader, 300 MiB and 60 //! files in any 24 hours; 2,000 uploads a day from everyone together, which //! keeps R2's writes and D1's rows within their free plans; and 9 GiB //! stored in all, under R2's free 10 GB-month ([`admit`]). Pure logic, @@ -35,7 +35,21 @@ pub const SERVED_PREFIX: &str = "/m/"; const MIB: u64 = 1024 * 1024; /// The largest image, and the largest video. pub const MAX_IMAGE_BYTES: u64 = 10 * MIB; -pub const MAX_VIDEO_BYTES: u64 = 50 * MIB; +pub const MAX_VIDEO_BYTES: u64 = 250 * MIB; +/// A video travels in uniform 8 MiB parts, below the zone request limit. +pub const VIDEO_PART_BYTES: u64 = 8 * MIB; +/// R2 returns opaque part tags (local R2 tokens exceed 128 bytes). +pub const MAX_VIDEO_PART_ETAG_BYTES: usize = 512; +/// Bound all R2 writes, including failed/retried multipart attempts. +/// At most 31,000 Class A operations in a 31-day month. +pub const MEDIA_R2_OPERATIONS_PER_DAY: u32 = 1_000; +/// An idle upload can resume for an hour. Its storage reservation is only +/// released after R2 confirms cancellation/deletion, never just on expiry. +pub const UPLOAD_IDLE_SECONDS: i64 = 3_600; +/// Bound aggregate quota reads, including tiny incomplete files. +pub const MAX_PENDING_UPLOADS: u32 = 128; +/// A request holds a lease while R2 consumes its stream. +pub const UPLOAD_LEASE_SECONDS: i64 = 600; /// The most embeds a cast carries: Farcaster's limit. pub const MAX_EMBEDS: u32 = 2; /// The longest URL a cast can embed: Farcaster's limit, in bytes. @@ -298,6 +312,63 @@ pub struct Uploaded { pub bytes: u64, } +/// Start an authenticated, account-bound multipart video upload. +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct NewVideoUpload { + /// A browser-generated correlation id makes a lost create response recoverable. + #[serde(default)] + pub id: Option, + #[serde(rename = "type")] + pub media_type: String, + pub bytes: u64, + #[serde(default)] + pub hls: Option, +} + +/// Only the opaque app id is sent to the browser, never R2's upload id. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct VideoUploadSession { + pub id: String, + pub part_bytes: u64, +} + +/// A part R2 kept, in order. The server retains its own copy for replays. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct VideoPart { + pub part_number: u16, + pub etag: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CompleteVideoUpload { + pub parts: Vec, +} + +/// Exactly the expected size of one 1-based video part. +pub fn video_part_bytes(total: u64, part: u16) -> Option { + if total == 0 || total > MAX_VIDEO_BYTES || part == 0 { + return None; + } + let offset = u64::from(part - 1).checked_mul(VIDEO_PART_BYTES)?; + (offset < total).then(|| (total - offset).min(VIDEO_PART_BYTES)) +} + +/// A completed manifest is contiguous, bounded, and names real part tags. +pub fn complete_video_parts(total: u64, parts: &[VideoPart]) -> bool { + let count = total.div_ceil(VIDEO_PART_BYTES); + total > 0 + && total <= MAX_VIDEO_BYTES + && parts.len() as u64 == count + && parts.iter().enumerate().all(|(i, part)| { + usize::from(part.part_number) == i + 1 + && !part.etag.is_empty() + && part.etag.len() <= MAX_VIDEO_PART_ETAG_BYTES + }) +} + /// Why an upload is refused: its HTTP status, what the reader is told, and /// when to try again, in seconds, when that is known. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -315,7 +386,7 @@ pub const BAD_TYPE: Refused = refused(400, "Attach a PNG, JPEG, GIF or WebP imag pub const NO_LENGTH: Refused = refused(400, "An upload needs its size, in its Content-Length"); pub const EMPTY: Refused = refused(400, "That file is empty"); pub const IMAGE_TOO_LARGE: Refused = refused(413, "Images can be up to 10 MB"); -pub const VIDEO_TOO_LARGE: Refused = refused(413, "Videos can be up to 50 MB"); +pub const VIDEO_TOO_LARGE: Refused = refused(413, "Videos can be up to 250 MB"); pub const READER_FILES_USED: Refused = refused(507, "You've attached 60 files in the past 24 hours, the most a day takes"); pub const READER_BYTES_USED: Refused = @@ -577,9 +648,9 @@ mod tests { ("image/jpeg", "jpg", 10_485_760), ("image/gif", "gif", 10_485_760), ("image/webp", "webp", 10_485_760), - ("video/mp4", "mp4", 52_428_800), - ("video/quicktime", "mov", 52_428_800), - ("video/webm", "webm", 52_428_800), + ("video/mp4", "mp4", 262_144_000), + ("video/quicktime", "mov", 262_144_000), + ("video/webm", "webm", 262_144_000), ] ); for media in MediaType::ALL { @@ -694,10 +765,10 @@ mod tests { #[test] fn an_upload_declares_its_type_and_length() { assert_eq!(check_upload(Some("image/png"), Some("1234")), Ok((MediaType::Png, 1234))); - assert_eq!(check_upload(Some("video/webm"), Some(" 52428800 ")), Ok((MediaType::Webm, MAX_VIDEO_BYTES))); + assert_eq!(check_upload(Some("video/webm"), Some(" 262144000 ")), Ok((MediaType::Webm, MAX_VIDEO_BYTES))); assert_eq!(check_upload(Some("image/gif"), Some("10485760")), Ok((MediaType::Gif, MAX_IMAGE_BYTES))); assert_eq!(check_upload(Some("image/gif"), Some("10485761")), Err(IMAGE_TOO_LARGE)); - assert_eq!(check_upload(Some("video/quicktime"), Some("52428801")), Err(VIDEO_TOO_LARGE)); + assert_eq!(check_upload(Some("video/quicktime"), Some("262144001")), Err(VIDEO_TOO_LARGE)); assert_eq!(check_upload(Some("image/jpeg"), Some("0")), Err(EMPTY)); for length in [None, Some(""), Some("-1"), Some("12a"), Some("+5"), Some("1e6"), Some("99999999999999999999999")] @@ -953,8 +1024,8 @@ mod tests { "big.jpg is too large: images can be up to 10 MB." ); assert_eq!( - check_file("long.mp4", "video/mp4", 60 * MIB, &limits).unwrap_err(), - "long.mp4 is too large: videos can be up to 50 MB." + check_file("long.mp4", "video/mp4", 251 * MIB, &limits).unwrap_err(), + "long.mp4 is too large: videos can be up to 250 MB." ); assert_eq!(check_file("none.gif", "image/gif", 0, &limits).unwrap_err(), "none.gif is empty."); // The server's limits, whatever they are. @@ -970,6 +1041,31 @@ mod tests { assert_eq!(accepted(), "image/png,image/jpeg,image/gif,image/webp,video/mp4,video/quicktime,video/webm"); } + #[test] + fn video_parts_are_bounded_contiguous_and_exact() { + assert_eq!(video_part_bytes(0, 1), None); + assert_eq!(video_part_bytes(MAX_VIDEO_BYTES + 1, 1), None); + assert_eq!(video_part_bytes(MAX_VIDEO_BYTES, 0), None); + assert_eq!(video_part_bytes(MAX_VIDEO_BYTES, 1), Some(VIDEO_PART_BYTES)); + assert_eq!(video_part_bytes(MAX_VIDEO_BYTES, 32), Some(2 * MIB)); + assert_eq!(video_part_bytes(MAX_VIDEO_BYTES, 33), None); + assert_eq!(video_part_bytes(1, 1), Some(1)); + assert_eq!(video_part_bytes(VIDEO_PART_BYTES, 2), None); + let parts: Vec<_> = (1..=32).map(|part_number| VideoPart { part_number, etag: "part-tag".into() }).collect(); + assert!(complete_video_parts(MAX_VIDEO_BYTES, &parts)); + assert!(!complete_video_parts(MAX_VIDEO_BYTES, &parts[..31])); + assert!(!complete_video_parts(0, &[])); + let mut wrong = parts.clone(); + wrong[1].part_number = 1; + assert!(!complete_video_parts(MAX_VIDEO_BYTES, &wrong)); + wrong = parts; + wrong[0].etag = "x".repeat(MAX_VIDEO_PART_ETAG_BYTES + 1); + assert!(!complete_video_parts(MAX_VIDEO_BYTES, &wrong)); + let opaque = vec![VideoPart { part_number: 1, etag: "x".repeat(176) }]; + assert!(complete_video_parts(1, &opaque), "R2 part tags are opaque, not fixed-size MD5s"); + const { assert!(MEDIA_R2_OPERATIONS_PER_DAY as u64 * 31 < 1_000_000) }; + } + #[test] fn a_cast_takes_two_embeds_with_its_gif_and_its_quote() { assert_eq!(embeds_left(0, false, false), 2); @@ -984,7 +1080,7 @@ mod tests { fn the_answers_are_as_the_composer_reads_them() { assert_eq!( serde_json::to_string(&LIMITS).unwrap(), - r#"{"max_image_bytes":10485760,"max_video_bytes":52428800,"max_embeds":2}"# + r#"{"max_image_bytes":10485760,"max_video_bytes":262144000,"max_embeds":2}"# ); let uploaded = Uploaded { url: "https://allthing.social/m/1/x.png".into(), media_type: "image/png".into(), bytes: 7 }; diff --git a/crates/allthing-web/src/components/attach.rs b/crates/allthing-web/src/components/attach.rs index 53972875..48cacf58 100644 --- a/crates/allthing-web/src/components/attach.rs +++ b/crates/allthing-web/src/components/attach.rs @@ -30,7 +30,7 @@ use wasm_bindgen::closure::Closure; use wasm_bindgen_futures::JsFuture; use web_sys::{Blob, BlobPropertyBag, File, ProgressEvent, XmlHttpRequest}; -use crate::components::compose::{Attachment, Composer, INCOMING, LIVE, Live, Sending}; +use crate::components::compose::{Attachment, ChunkUpload, Composer, INCOMING, LIVE, Live, Sending}; /// A picture with a frame and a corner of sky: attach. const PICTURE: &str = "M3 3.5h10a1 1 0 0 1 1 1v7a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1v-7a1 1 0 0 1 1-1zM2.5 10.5l3.2-3.2 2.6 2.6 1.7-1.7 3.5 3.5"; @@ -70,10 +70,13 @@ pub fn add(composer: Composer, files: Vec) { fn start(composer: Composer, file: File, media: MediaType) { let id = Attachment::next_id(); let local = web_sys::Url::create_object_url_with_blob(&file).ok(); - let state = Sending::Up { sent: 0.0, of: file.size() }; + let state = if media.is_video() { Sending::Preparing } else { Sending::Up { sent: 0.0, of: file.size() } }; composer.files.update(|files| files.push(Attachment { id, name: file.name(), media, local, state })); LIVE.with(|live| { - live.borrow_mut().insert(id, Live { file, body: None, hls: None, request: None, progress: None, ended: None }) + live.borrow_mut().insert( + id, + Live { file, body: None, hls: None, multipart: None, request: None, progress: None, ended: None }, + ) }); send(composer, id); } @@ -81,13 +84,18 @@ fn start(composer: Composer, file: File, media: MediaType) { /// Try a file that didn't go again. pub fn retry(composer: Composer, id: u64) { if LIVE.with(|live| live.borrow().contains_key(&id)) { - set(composer, id, Sending::Up { sent: 0.0, of: 0.0 }); + let state = if media_of(composer, id).is_some_and(MediaType::is_video) { + Sending::Preparing + } else { + Sending::Up { sent: 0.0, of: 0.0 } + }; + set(composer, id, state); send(composer, id); } } fn set(composer: Composer, id: u64, state: Sending) { - composer.files.update(|files| { + let _ = composer.files.try_update(|files| { if let Some(file) = files.iter_mut().find(|f| f.id == id) { file.state = state; } @@ -103,6 +111,9 @@ fn media_of(composer: Composer, id: u64) -> Option { fn send(composer: Composer, id: u64) { spawn_local(async move { let Some(media) = media_of(composer, id) else { return }; + let maximum = composer.limits.get_untracked().map_or(media.max_bytes(), |limits| { + if media.is_video() { limits.max_video_bytes } else { limits.max_image_bytes } + }); let Some((file, kept, hls)) = LIVE.with(|live| live.borrow().get(&id).map(|l| (l.file.clone(), l.body.clone(), l.hls.clone()))) else { @@ -114,14 +125,36 @@ fn send(composer: Composer, id: u64) { Ok(body) => (body, None), Err(why) => return set(composer, id, Sending::Failed { why }), }, - None if matches!(media, MediaType::Mp4 | MediaType::Mov) => match remuxed(&file).await { + None if matches!(media, MediaType::Mp4 | MediaType::Mov) => match remuxed(&file, id).await { Some((body, index)) => (body, Some(index)), None => (file.clone().into(), None), }, None => (file.clone().into(), None), }; + if body.size() > maximum as f64 { + return set( + composer, + id, + Sending::Failed { + why: format!("The prepared file is too large: the limit is {}.", size_label(maximum)), + }, + ); + } // A remuxed video goes as an MP4, whatever it was. let mime = if hls.is_some() { MediaType::Mp4.mime() } else { media.mime() }; + if media.is_video() && body.size() > allthing_core::media::VIDEO_PART_BYTES as f64 { + LIVE.with(|live| { + if let Some(live) = live.borrow_mut().get_mut(&id) { + live.body = Some(body.clone()); + live.hls = hls.clone(); + } + }); + if let Err(why) = send_chunks(composer, id, &body, mime, hls.as_deref()).await { + set(composer, id, Sending::Failed { why }); + } + return; + } + set(composer, id, Sending::Up { sent: 0.0, of: body.size() }); // Removed meanwhile, or the draft cleared. let Some(request) = LIVE.with(|live| { let mut live = live.borrow_mut(); @@ -136,7 +169,12 @@ fn send(composer: Composer, id: u64) { }; let progress = Closure::::new(move |ev: ProgressEvent| { if ev.length_computable() { - set(composer, id, Sending::Up { sent: ev.loaded(), of: ev.total() }); + let state = if media.is_video() && ev.loaded() >= ev.total() { + Sending::Finishing + } else { + Sending::Up { sent: ev.loaded(), of: ev.total() } + }; + set(composer, id, state); } }); let answer = request.clone(); @@ -164,6 +202,8 @@ fn send(composer: Composer, id: u64) { }); request.set_onload(Some(ended.as_ref().unchecked_ref())); request.set_onerror(Some(ended.as_ref().unchecked_ref())); + request.set_ontimeout(Some(ended.as_ref().unchecked_ref())); + request.set_timeout(180_000); // Kept until the file is let go: a handler isn't dropped while it runs. LIVE.with(|live| { if let Some(live) = live.borrow_mut().get_mut(&id) { @@ -177,6 +217,196 @@ fn send(composer: Composer, id: u64) { }); } +/// A chunked upload keeps the acknowledged parts in this draft. Retry +/// continues with the first missing part, rather than sending a video again. +async fn send_chunks(composer: Composer, id: u64, body: &Blob, mime: &str, hls: Option<&str>) -> Result<(), String> { + let kept = LIVE.with(|live| live.borrow().get(&id).and_then(|l| l.multipart.clone())); + let mut upload = match kept { + Some(kept) => kept, + None => { + let key = web_sys::window().and_then(|w| w.crypto().ok()).ok_or(LOST)?.random_uuid().replace('-', ""); + let upload = ChunkUpload { + id: key, + part_bytes: allthing_core::media::VIDEO_PART_BYTES, + created: false, + parts: Vec::new(), + }; + LIVE.with(|live| { + if let Some(live) = live.borrow_mut().get_mut(&id) { + live.multipart = Some(upload.clone()); + } + }); + upload + } + }; + if !upload.created { + let data = serde_json::json!({"id":upload.id,"type": mime, "bytes": body.size() as u64, "hls": hls}); + let created = chunk_request( + composer, + id, + &format!("{MEDIA_PATH}?upload=create"), + json_blob(&data)?, + "application/json", + None, + ) + .await?; + if created.get("id").and_then(serde_json::Value::as_str) != Some(upload.id.as_str()) + || created.get("part_bytes").and_then(serde_json::Value::as_u64) != Some(upload.part_bytes) + { + return Err(LOST.to_owned()); + } + upload.created = true; + let kept = LIVE.with(|live| { + let mut live = live.borrow_mut(); + let Some(live) = live.get_mut(&id) else { return false }; + live.multipart = Some(upload.clone()); + true + }); + if !kept { + abort_upload(&upload.id); + return Ok(()); + } + } + let total = body.size() as u64; + let count = total.div_ceil(upload.part_bytes); + while (upload.parts.len() as u64) < count { + if !LIVE.with(|live| live.borrow().contains_key(&id)) { + return Ok(()); + } + let number = upload.parts.len() as u32 + 1; + let first = u64::from(number - 1) * upload.part_bytes; + let end = (first + upload.part_bytes).min(total); + let part = body.slice_with_f64_and_f64(first as f64, end as f64).map_err(|_| LOST)?; + set(composer, id, Sending::Up { sent: first as f64, of: total as f64 }); + let url = format!("{MEDIA_PATH}?upload=part&id={}&part={number}", upload.id); + let answer = chunk_request(composer, id, &url, part, "application/octet-stream", Some((first, total))).await?; + if answer.get("part_number").and_then(serde_json::Value::as_u64) != Some(u64::from(number)) { + return Err(LOST.to_owned()); + } + let etag = answer + .get("etag") + .and_then(serde_json::Value::as_str) + .filter(|tag| !tag.is_empty() && tag.len() <= 512) + .ok_or(LOST)?; + upload.parts.push((number, etag.to_owned())); + LIVE.with(|live| { + if let Some(live) = live.borrow_mut().get_mut(&id) { + live.multipart = Some(upload.clone()); + } + }); + } + if !LIVE.with(|live| live.borrow().contains_key(&id)) { + return Ok(()); + } + set(composer, id, Sending::Finishing); + let parts: Vec<_> = + upload.parts.iter().map(|(number, etag)| serde_json::json!({"part_number":number,"etag":etag})).collect(); + let url = format!("{MEDIA_PATH}?upload=complete&id={}", upload.id); + let answer = + chunk_request(composer, id, &url, json_blob(&serde_json::json!({"parts":parts}))?, "application/json", None) + .await?; + let url = answer.get("url").and_then(serde_json::Value::as_str).ok_or(LOST)?; + LIVE.with(|live| { + if let Some(live) = live.borrow_mut().get_mut(&id) { + live.multipart = None; + } + }); + set(composer, id, Sending::Done { url: url.to_owned() }); + Ok(()) +} + +fn json_blob(value: &serde_json::Value) -> Result { + let parts = js_sys::Array::of1(&wasm_bindgen::JsValue::from_str(&value.to_string())); + Blob::new_with_str_sequence(&parts).map_err(|_| LOST.to_owned()) +} + +/// One network request, with its callbacks scoped to this future. Aborting +/// a removed attachment fires loadend too, so nothing waits indefinitely. +async fn chunk_request( + composer: Composer, + id: u64, + url: &str, + body: Blob, + mime: &str, + progress: Option<(u64, u64)>, +) -> Result { + let request = XmlHttpRequest::new().map_err(|_| LOST)?; + request.open_with_async("POST", url, true).map_err(|_| LOST)?; + request.set_timeout(180_000); + request.set_request_header("content-type", mime).map_err(|_| LOST)?; + let live = LIVE.with(|live| { + let mut live = live.borrow_mut(); + let Some(live) = live.get_mut(&id) else { return false }; + live.request = Some(request.clone()); + true + }); + if !live { + return Err(LOST.to_owned()); + } + let progress_handler = Closure::::new(move |ev: ProgressEvent| { + if let Some((offset, total)) = progress { + set(composer, id, Sending::Up { sent: (offset as f64 + ev.loaded()).min(total as f64), of: total as f64 }); + } + }); + let upload = request.upload().map_err(|_| LOST)?; + upload.set_onprogress(Some(progress_handler.as_ref().unchecked_ref())); + let (promise, ended) = { + let mut ended = None; + let promise = js_sys::Promise::new(&mut |resolve, _reject| { + let callback = Closure::::new(move || { + let _ = resolve.call0(&wasm_bindgen::JsValue::NULL); + }); + request.set_onloadend(Some(callback.as_ref().unchecked_ref())); + ended = Some(callback); + }); + (promise, ended) + }; + let sent = request.send_with_opt_blob(Some(&body)); + if sent.is_ok() { + let _ = JsFuture::from(promise).await; + } + request.set_onloadend(None); + upload.set_onprogress(None); + drop(ended); + drop(progress_handler); + LIVE.with(|live| { + if let Some(live) = live.borrow_mut().get_mut(&id) { + live.request = None; + } + }); + if sent.is_err() { + return Err(LOST.to_owned()); + } + let status = request.status().unwrap_or(0); + let text = request.response_text().ok().flatten().unwrap_or_default(); + let value: serde_json::Value = serde_json::from_str(&text).unwrap_or(serde_json::Value::Null); + if matches!(status, 404 | 410) { + LIVE.with(|live| { + if let Some(live) = live.borrow_mut().get_mut(&id) { + live.multipart = None; + } + }); + } + if status != 200 { + return Err(value.get("error").and_then(serde_json::Value::as_str).unwrap_or(LOST).to_owned()); + } + Ok(value) +} + +/// Cancel on Remove. The server also reclaims abandoned uploads, including +/// when a browser disappears before it can send this request. +pub fn abort_upload(id: &str) { + let url = format!("{MEDIA_PATH}?upload=abort&id={id}"); + spawn_local(async move { + let options = web_sys::RequestInit::new(); + options.set_method("POST"); + let _ = js_sys::Reflect::set(options.as_ref(), &"keepalive".into(), &true.into()); + if let Some(window) = web_sys::window() { + let _ = JsFuture::from(window.fetch_with_str_and_init(&url, &options)).await; + } + }); +} + /// A text field of the JSON `request` was answered with. fn field(request: &XmlHttpRequest, name: &str) -> Option { let text = request.response_text().ok().flatten()?; @@ -198,16 +428,81 @@ async fn without_metadata(file: &File, media: MediaType) -> Result } /// A video as a fragmented MP4 and its HLS index, if it can be remuxed. -async fn remuxed(file: &File) -> Option<(Blob, String)> { - let buffer = JsFuture::from(file.array_buffer()).await.ok()?; - let bytes = js_sys::Uint8Array::new(&buffer).to_vec(); - let remuxed = fmp4::remux(&bytes).ok()?; - drop(bytes); - let parts = js_sys::Array::of1(&js_sys::Uint8Array::from(remuxed.bytes.as_slice())); - let options = BlobPropertyBag::new(); - options.set_type(MediaType::Mp4.mime()); - let blob = Blob::new_with_u8_array_sequence_and_options(&parts, &options).ok()?; - Some((blob, remuxed.index.encode())) +async fn remuxed(file: &File, id: u64) -> Option<(Blob, String)> { + let length = file.size() as u64; + let mut offset = 0_u64; + // Top-level boxes can put moov after a very large mdat. Read only + // headers and moov: never copy the video's payload into WebAssembly. + for n in 0..1024 { + if n % 16 == 0 && !yield_upload(id).await { + return None; + } + if offset.checked_add(8)? > length { + return None; + } + let head = read_slice(file, offset, (offset + 16).min(length)).await?; + let short = u32::from_be_bytes(head.get(..4)?.try_into().ok()?) as u64; + let (size, header) = match short { + 0 => (length - offset, 8), + 1 => (u64::from_be_bytes(head.get(8..16)?.try_into().ok()?), 16), + n => (n, 8), + }; + if size < header || offset.checked_add(size)? > length { + return None; + } + if head.get(4..8)? == b"moov" { + if size - header > fmp4::MAX_MOOV_BYTES as u64 { + return None; + } + let moov = read_slice(file, offset + header, offset + size).await?; + if !yield_upload(id).await { + return None; + } + let plan = fmp4::plan(&moov, length).ok()?; + let parts = js_sys::Array::new(); + for (n, part) in plan.parts.into_iter().enumerate() { + if n % 256 == 0 && !yield_upload(id).await { + return None; + } + match part { + fmp4::RemuxPart::Bytes(bytes) => { + parts.push(&js_sys::Uint8Array::from(bytes.as_slice())); + } + fmp4::RemuxPart::Source { offset, len } => { + parts.push(&file.slice_with_f64_and_f64(offset as f64, (offset + len) as f64).ok()?.into()); + } + } + } + let options = BlobPropertyBag::new(); + options.set_type(MediaType::Mp4.mime()); + let blob = Blob::new_with_blob_sequence_and_options(&parts, &options).ok()?; + return Some((blob, plan.index.encode())); + } + offset += size; + } + None +} + +/// Give paint, input and cancellation a task between bounded preparation steps. +async fn yield_upload(id: u64) -> bool { + if !LIVE.with(|live| live.borrow().contains_key(&id)) { + return false; + } + if let Some(window) = web_sys::window() { + let promise = js_sys::Promise::new(&mut |resolve, reject| { + if window.set_timeout_with_callback_and_timeout_and_arguments_0(&resolve, 0).is_err() { + let _ = reject.call0(&wasm_bindgen::JsValue::NULL); + } + }); + let _ = JsFuture::from(promise).await; + } + LIVE.with(|live| live.borrow().contains_key(&id)) +} + +async fn read_slice(file: &File, first: u64, end: u64) -> Option> { + let blob = file.slice_with_f64_and_f64(first as f64, end as f64).ok()?; + let buffer = JsFuture::from(blob.array_buffer()).await.ok()?; + Some(js_sys::Uint8Array::new(&buffer).to_vec()) } /// "a JPEG", for what is said of a file. @@ -333,6 +628,14 @@ fn Tile(composer: Composer, id: u64) -> impl IntoView { }; let label = format!("Remove {name}"); let progress = move || match state.get() { + Some(Sending::Preparing | Sending::Finishing) => { + let label = if matches!(state.get(), Some(Sending::Preparing)) { + "Preparing video…" + } else { + "Finishing upload…" + }; + Some(view! { {label} }.into_any()) + } Some(Sending::Up { sent, of }) => { let percent = if of > 0.0 { (sent / of * 100.0).clamp(0.0, 100.0).round() } else { 0.0 }; let name = name.clone(); @@ -360,7 +663,7 @@ fn Tile(composer: Composer, id: u64) -> impl IntoView { view! {
  • {shown} diff --git a/crates/allthing-web/src/components/compose.rs b/crates/allthing-web/src/components/compose.rs index de923501..c6b893a1 100644 --- a/crates/allthing-web/src/components/compose.rs +++ b/crates/allthing-web/src/components/compose.rs @@ -165,6 +165,10 @@ pub struct Attachment { /// How an attachment's upload goes. #[derive(Clone, Debug, PartialEq)] pub enum Sending { + /// Reading video metadata and preparing it for playback. + Preparing, + /// All bytes sent; the server is finishing the attachment. + Finishing, /// On its way: bytes sent, of how many. Up { sent: f64, of: f64 }, /// Kept by the Worker, at the URL the cast embeds. @@ -225,23 +229,38 @@ pub struct Live { /// A video remuxed for HLS: its index (`allthing_core::fmp4`), sent with /// `body`, which is then the remuxed MP4. pub hls: Option, + /// A larger video continues from its last acknowledged chunk on Retry. + pub multipart: Option, pub request: Option, pub progress: Option>, pub ended: Option>, } +/// Browser-local multipart progress; no upload credential is persisted. +#[derive(Clone)] +pub struct ChunkUpload { + pub id: String, + pub part_bytes: u64, + pub created: bool, + pub parts: Vec<(u32, String)>, +} + /// Let an attachment go: its upload stopped if it's on its way, and its /// preview's object URL let go. pub fn let_go(file: &Attachment) { - if let Some(live) = LIVE.with(|l| l.borrow_mut().remove(&file.id)) - && let Some(request) = &live.request - { - request.set_onload(None); - request.set_onerror(None); - if let Ok(upload) = request.upload() { - upload.set_onprogress(None); + if let Some(live) = LIVE.with(|l| l.borrow_mut().remove(&file.id)) { + if let Some(request) = &live.request { + request.set_onload(None); + request.set_onerror(None); + request.set_ontimeout(None); + if let Ok(upload) = request.upload() { + upload.set_onprogress(None); + } + let _ = request.abort(); + } + if let Some(upload) = live.multipart { + super::attach::abort_upload(&upload.id); } - let _ = request.abort(); } if let Some(local) = &file.local { let _ = web_sys::Url::revoke_object_url(local); @@ -752,7 +771,9 @@ impl Composer { /// Whether a file is on its way. pub fn uploading(self) -> bool { - self.files.with(|files| files.iter().any(|f| matches!(f.state, Sending::Up { .. }))) + self.files.with(|files| { + files.iter().any(|f| matches!(f.state, Sending::Preparing | Sending::Up { .. } | Sending::Finishing)) + }) } /// Whether a file didn't go. diff --git a/crates/allthing-worker/src/index.rs b/crates/allthing-worker/src/index.rs index 8c59b5d7..485b166a 100644 --- a/crates/allthing-worker/src/index.rs +++ b/crates/allthing-worker/src/index.rs @@ -16,7 +16,7 @@ use std::rc::Rc; use allthing_core::activity::{ActiveWeek, Fids, week_set}; use allthing_core::emoji::ReactionReply; use allthing_core::index::{Entry, RANKED, Ranking, RankingMeta, Span, sql_statements}; -use allthing_core::media::{Usage, WINDOW, day_row}; +use allthing_core::media::{self, Usage, WINDOW, day_row}; use allthing_core::scheduled::{self, Cancelled, Checked, ScheduledCast, State}; use allthing_data::activity::{ActivityStore, AppCount, SUMMARY, Saving}; use allthing_data::indexer::{Index, IndexError, Saved, Snapshot}; @@ -27,7 +27,7 @@ use worker::wasm_bindgen::JsValue; use worker::{D1Database, Env}; /// The schema, from the migrations that also create it. -const SCHEMA: [&str; 9] = [ +const SCHEMA: [&str; 10] = [ include_str!("../../../migrations/0001_index.sql"), include_str!("../../../migrations/0002_snapshots.sql"), include_str!("../../../migrations/0003_activity.sql"), @@ -37,6 +37,7 @@ const SCHEMA: [&str; 9] = [ include_str!("../../../migrations/0007_media.sql"), include_str!("../../../migrations/0008_poll.sql"), include_str!("../../../migrations/0009_presence.sql"), + include_str!("../../../migrations/0010_media_uploads.sql"), ]; /// Store casts given as one JSON array of `[hash, ts, points]`, updating the @@ -164,15 +165,16 @@ const SAVE_HOME_SNAPSHOT: &str = "INSERT INTO snapshots (name, built, body) VALU /// many bytes and when the oldest came; everyone's uploads on the UTC day /// whose row is ?3; and the bytes stored in all. Reads the reader's index /// entries in the window, and two rows of totals. -const MEDIA_USAGE: &str = "SELECT COUNT(*) AS files, COALESCE(SUM(bytes), 0) AS bytes, MIN(created_at) AS oldest, \ - COALESCE((SELECT files FROM media_total WHERE name = ?3), 0) AS today, \ - COALESCE((SELECT bytes FROM media_total WHERE name = 'all'), 0) AS stored \ - FROM media WHERE fid = ?1 AND created_at > ?2"; -/// An attachment R2 has kept; then it is added to the total (`all`) and to -/// its day's row. -const RECORD_MEDIA: &str = "INSERT INTO media (key, fid, bytes, type, created_at) VALUES (?1, ?2, ?3, ?4, ?5)"; -const ADD_MEDIA: &str = "INSERT INTO media_total (name, bytes, files) VALUES (?1, ?2, 1) \ - ON CONFLICT (name) DO UPDATE SET bytes = bytes + excluded.bytes, files = files + 1"; +const MEDIA_USAGE: &str = "SELECT \ + (SELECT COUNT(*) FROM media WHERE fid = ?1 AND created_at > ?2) + \ + (SELECT COUNT(*) FROM media_uploads WHERE fid = ?1 AND state <> 'done') AS files, \ + (SELECT COALESCE(SUM(bytes), 0) FROM media WHERE fid = ?1 AND created_at > ?2) + \ + (SELECT COALESCE(SUM(bytes), 0) FROM media_uploads WHERE fid = ?1 AND state <> 'done') AS bytes, \ + (SELECT MIN(created_at) FROM media WHERE fid = ?1 AND created_at > ?2) AS oldest, \ + COALESCE((SELECT files FROM media_total WHERE name = ?3), 0) + \ + (SELECT COUNT(*) FROM media_uploads WHERE state <> 'done' AND created_at >= ?4) AS today, \ + COALESCE((SELECT bytes FROM media_total WHERE name = 'all'), 0) + \ + (SELECT COALESCE(SUM(bytes), 0) FROM media_uploads WHERE state <> 'done') AS stored"; /// Count voter ?2's vote ?3 in poll ?1, unless that voter has voted in it: /// run before the vote is kept, in the same transaction, so a vote counts /// once. Reads one vote's row by its key, not every vote. @@ -322,6 +324,27 @@ struct MetaRow { floor: f64, } +/// A resumable upload. Its R2 identifier never leaves the Worker. +#[derive(Clone, Debug, Deserialize)] +pub struct MediaUploadRow { + pub id: String, + pub key: String, + pub fid: u64, + pub bytes: u64, + #[serde(rename = "type")] + pub media_type: String, + pub hls: Option, + pub upload_id: Option, + pub mode: String, + pub state: String, + pub parts: String, + pub created_at: i64, + pub expires_at: i64, + pub lock_id: String, + pub lock_until: i64, + pub cancel_requested: u32, +} + pub struct D1Index { db: Rc, } @@ -734,11 +757,322 @@ impl D1Index { /// What readers' attachments take, for `crate::media`. impl D1Index { + /// Reserve quota in one conditional INSERT, before R2 sees any bytes. + /// Other requests see every active reservation, including expired ones + /// whose R2 parts have not yet been confirmed deleted. + pub async fn reserve_media(&self, row: &MediaUploadRow) -> Result { + ensure_schema(&self.db).await?; + const SQL: &str = "INSERT INTO media_uploads \ + (id, key, fid, bytes, type, hls, mode, created_at, expires_at, lock_id, lock_until) \ + SELECT ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11 WHERE \ + COALESCE((SELECT bytes FROM media_total WHERE name = 'all'), 0) + \ + (SELECT COALESCE(SUM(bytes), 0) FROM media_uploads WHERE state <> 'done') + ?4 <= ?12 AND \ + (SELECT COALESCE(SUM(bytes), 0) FROM media WHERE fid = ?3 AND created_at > ?13) + \ + (SELECT COALESCE(SUM(bytes), 0) FROM media_uploads WHERE fid = ?3 AND state <> 'done') + ?4 <= ?14 AND \ + (SELECT COUNT(*) FROM media WHERE fid = ?3 AND created_at > ?13) + \ + (SELECT COUNT(*) FROM media_uploads WHERE fid = ?3 AND state <> 'done') < ?15 AND \ + COALESCE((SELECT files FROM media_total WHERE name = ?16), 0) + \ + (SELECT COUNT(*) FROM media_uploads WHERE state <> 'done' AND created_at >= ?17) < ?18 AND \ + (SELECT COUNT(*) FROM media_uploads WHERE fid = ?3 AND state <> 'done') < 2 AND \ + NOT EXISTS (SELECT 1 FROM media_upload_cancellations WHERE id = ?1 AND fid = ?3 AND expires_at > ?8) AND \ + (SELECT COUNT(*) FROM media_uploads WHERE state <> 'done') < ?19 AND \ + NOT EXISTS (SELECT 1 FROM media WHERE key = ?2) RETURNING id"; + #[derive(Deserialize)] + struct Id { + id: String, + } + let saved: Option = self + .db + .prepare(SQL) + .bind(&[ + row.id.clone().into(), + row.key.clone().into(), + number(row.fid as f64), + number(row.bytes as f64), + row.media_type.clone().into(), + row.hls.clone().map_or(JsValue::NULL, Into::into), + row.mode.clone().into(), + number(row.created_at as f64), + number(row.expires_at as f64), + row.lock_id.clone().into(), + number(row.lock_until as f64), + number(media::TOTAL_BYTES as f64), + number((row.created_at - WINDOW) as f64), + number(media::READER_BYTES as f64), + number(media::READER_FILES as f64), + day_row(row.created_at).into(), + number((row.created_at.div_euclid(86_400) * 86_400) as f64), + number(media::UPLOADS_PER_DAY as f64), + number(media::MAX_PENDING_UPLOADS as f64), + ]) + .map_err(failed)? + .first(None) + .await + .map_err(failed)?; + Ok(saved.is_some_and(|saved| saved.id == row.id)) + } + + /// Charge every R2 write attempt, including failures and retries. + pub async fn media_operation(&self, now: i64) -> Result { + ensure_schema(&self.db).await?; + #[derive(Deserialize)] + struct Count { + operations: u32, + } + const SQL: &str = "INSERT INTO media_operations (day, operations) VALUES (?1, 1) \ + ON CONFLICT(day) DO UPDATE SET operations = operations + 1 WHERE operations < ?2 RETURNING operations"; + let saved: Option = self + .db + .prepare(SQL) + .bind(&[number(now.div_euclid(86_400) as f64), number(media::MEDIA_R2_OPERATIONS_PER_DAY as f64)]) + .map_err(failed)? + .first(None) + .await + .map_err(failed)?; + Ok(saved.is_some_and(|saved| saved.operations <= media::MEDIA_R2_OPERATIONS_PER_DAY)) + } + + pub async fn media_upload(&self, id: &str, fid: u64) -> Result, IndexError> { + ensure_schema(&self.db).await?; + self.db + .prepare("SELECT * FROM media_uploads WHERE id = ?1 AND fid = ?2") + .bind(&[id.into(), number(fid as f64)]) + .map_err(failed)? + .first(None) + .await + .map_err(failed) + } + + /// Acquire a lease for one network operation. There is no parallel part + /// replacement, complete or abort while a live request holds this lease. + pub async fn claim_media( + &self, + id: &str, + fid: u64, + token: &str, + now: i64, + cleanup: bool, + ) -> Result, IndexError> { + const SQL: &str = "UPDATE media_uploads SET lock_id = ?3, lock_until = ?4 \ + WHERE id = ?1 AND fid = ?2 AND state <> 'done' AND lock_until <= ?5 AND (?6 OR (state = 'active' AND expires_at > ?5 AND cancel_requested = 0)) RETURNING *"; + self.db + .prepare(SQL) + .bind(&[ + id.into(), + number(fid as f64), + token.into(), + number((now + media::UPLOAD_LEASE_SECONDS) as f64), + number(now as f64), + number(u8::from(cleanup) as f64), + ]) + .map_err(failed)? + .first(None) + .await + .map_err(failed) + } + + pub async fn media_open(&self, row: &MediaUploadRow, upload_id: &str, now: i64) -> Result { + let result = self + .db + .prepare( + "UPDATE media_uploads SET upload_id = ?3, lock_id = ?5, lock_until = ?6, expires_at = ?4 \ + WHERE id = ?1 AND lock_id = ?2 AND state = 'active' AND cancel_requested = 0", + ) + .bind(&[ + row.id.clone().into(), + row.lock_id.clone().into(), + upload_id.into(), + number((now + media::UPLOAD_IDLE_SECONDS) as f64), + if row.mode == "single" { row.lock_id.clone().into() } else { "".into() }, + number(if row.mode == "single" { row.lock_until } else { 0 } as f64), + ]) + .map_err(failed)? + .run() + .await + .map_err(failed)?; + Ok(result.meta().map_err(failed)?.and_then(|m| m.changes) == Some(1)) + } + + pub async fn media_parts(&self, row: &MediaUploadRow, parts: &str, now: i64) -> Result { + let result = self + .db + .prepare( + "UPDATE media_uploads SET parts = ?3, lock_id = '', lock_until = 0, expires_at = ?4 \ + WHERE id = ?1 AND lock_id = ?2 AND state = 'active' AND cancel_requested = 0", + ) + .bind(&[ + row.id.clone().into(), + row.lock_id.clone().into(), + parts.into(), + number((now + media::UPLOAD_IDLE_SECONDS) as f64), + ]) + .map_err(failed)? + .run() + .await + .map_err(failed)?; + Ok(result.meta().map_err(failed)?.and_then(|m| m.changes) == Some(1)) + } + + pub async fn release_media_lock(&self, row: &MediaUploadRow) -> Result<(), IndexError> { + self.db + .prepare("UPDATE media_uploads SET lock_id = '', lock_until = 0 WHERE id = ?1 AND lock_id = ?2") + .bind(&[row.id.clone().into(), row.lock_id.clone().into()]) + .map_err(failed)? + .run() + .await + .map_err(failed)?; + Ok(()) + } + + /// The object, total and pending reservation change in one transaction. + /// Only the lease holder can count this file, exactly once. + pub async fn finish_media(&self, row: &MediaUploadRow, now: i64) -> Result { + let source = " FROM media_uploads WHERE id = ?1 AND lock_id = ?2 AND state = 'active' AND cancel_requested = 0"; + let args = [row.id.clone().into(), row.lock_id.clone().into(), number(now as f64)]; + let record = self + .db + .prepare(format!( + "INSERT INTO media (key, fid, bytes, type, created_at) SELECT key, fid, bytes, type, ?3{source}" + )) + .bind(&args) + .map_err(failed)?; + let total = self + .db + .prepare(format!( + "INSERT INTO media_total (name, bytes, files) SELECT 'all', bytes, 1{source} \ + ON CONFLICT(name) DO UPDATE SET bytes = bytes + excluded.bytes, files = files + 1" + )) + .bind(&args[..2]) + .map_err(failed)?; + let day_args = [args[0].clone(), args[1].clone(), day_row(now).into()]; + let day = self + .db + .prepare(format!( + "INSERT INTO media_total (name, bytes, files) SELECT ?3, bytes, 1{source} \ + ON CONFLICT(name) DO UPDATE SET bytes = bytes + excluded.bytes, files = files + 1" + )) + .bind(&day_args) + .map_err(failed)?; + let done_args = [args[0].clone(), args[1].clone(), number((now + 86_400) as f64)]; + let done = self + .db + .prepare( + "UPDATE media_uploads SET state = 'done', lock_id = '', lock_until = 0, expires_at = ?3 \ + WHERE id = ?1 AND lock_id = ?2 AND state = 'active' AND cancel_requested = 0", + ) + .bind(&done_args) + .map_err(failed)?; + let results = self.db.batch(vec![record, total, day, done]).await.map_err(failed)?; + Ok(results.first().and_then(|r| r.meta().ok().flatten()).and_then(|m| m.changes) == Some(1)) + } + + /// Fence cleanup before R2 I/O. A stale lease holder cannot delete an + /// object another request already completed, or race a new complete. + pub async fn begin_media_cleanup(&self, row: &MediaUploadRow, now: i64) -> Result { + let result = self + .db + .prepare( + "UPDATE media_uploads SET state = 'cleaning', expires_at = MIN(expires_at, ?3), lock_until = ?4 \ + WHERE id = ?1 AND lock_id = ?2 AND state <> 'done'", + ) + .bind(&[ + row.id.clone().into(), + row.lock_id.clone().into(), + number(now as f64), + number((now + media::UPLOAD_LEASE_SECONDS) as f64), + ]) + .map_err(failed)? + .run() + .await + .map_err(failed)?; + Ok(result.meta().map_err(failed)?.and_then(|m| m.changes) == Some(1)) + } + + /// Call only once R2 confirmed no parts or object remain. + pub async fn forget_media(&self, row: &MediaUploadRow) -> Result<(), IndexError> { + self.db + .prepare("DELETE FROM media_uploads WHERE id = ?1 AND lock_id = ?2 AND state <> 'done'") + .bind(&[row.id.clone().into(), row.lock_id.clone().into()]) + .map_err(failed)? + .run() + .await + .map_err(failed)?; + Ok(()) + } + + pub async fn media_cancelled(&self, id: &str, fid: u64, now: i64) -> Result { + ensure_schema(&self.db).await?; + #[derive(Deserialize)] + struct CancelledRow { + id: String, + } + let row: Option = self + .db + .prepare("SELECT id FROM media_upload_cancellations WHERE id = ?1 AND fid = ?2 AND expires_at > ?3") + .bind(&[id.into(), number(fid as f64), number(now as f64)]) + .map_err(failed)? + .first(None) + .await + .map_err(failed)?; + Ok(row.is_some_and(|row| row.id == id)) + } + + /// Persist cancellation before attempting R2 cleanup. Even if a create + /// request or part is still in flight, it cannot subsequently publish. + pub async fn cancel_media(&self, id: &str, fid: u64, now: i64) -> Result<(), IndexError> { + ensure_schema(&self.db).await?; + let tombstone = self + .db + .prepare( + "INSERT INTO media_upload_cancellations (id, fid, expires_at) VALUES (?1, ?2, ?3) \ + ON CONFLICT(id, fid) DO NOTHING", + ) + .bind(&[id.into(), number(fid as f64), number((now + 86_400) as f64)]) + .map_err(failed)?; + let cancel = self.db.prepare("UPDATE media_uploads SET cancel_requested = 1, expires_at = MIN(expires_at, ?3) WHERE id = ?1 AND fid = ?2 AND state <> 'done' AND cancel_requested = 0") + .bind(&[id.into(), number(fid as f64), number(now as f64)]).map_err(failed)?; + self.db.batch(vec![tombstone, cancel]).await.map_err(failed)?; + Ok(()) + } + + /// A bounded sweep, shared by the existing minute cron (no new trigger). + pub async fn expired_media(&self, now: i64) -> Result, IndexError> { + ensure_schema(&self.db).await?; + self.db + .prepare("DELETE FROM media_uploads WHERE state = 'done' AND expires_at <= ?1") + .bind(&[number(now as f64)]) + .map_err(failed)? + .run() + .await + .map_err(failed)?; + self.db + .prepare("DELETE FROM media_upload_cancellations WHERE expires_at <= ?1") + .bind(&[number(now as f64)]) + .map_err(failed)? + .run() + .await + .map_err(failed)?; + self.db + .prepare("DELETE FROM media_operations WHERE day < ?1") + .bind(&[number((now.div_euclid(86_400) - 2) as f64)]) + .map_err(failed)? + .run() + .await + .map_err(failed)?; + self.db.prepare("SELECT * FROM media_uploads WHERE state <> 'done' AND expires_at <= ?1 AND lock_until <= ?1 ORDER BY expires_at LIMIT 1") + .bind(&[number(now as f64)]).map_err(failed)?.all().await.map_err(failed)?.results().map_err(failed) + } + /// What `fid` has uploaded in the 24 hours to `now` (Unix seconds), /// what everyone has today, and what is stored in all. pub async fn media_usage(&self, fid: u64, now: i64) -> Result { ensure_schema(&self.db).await?; - let args = [number(fid as f64), number((now - WINDOW) as f64), day_row(now).into()]; + let args = [ + number(fid as f64), + number((now - WINDOW) as f64), + day_row(now).into(), + number((now.div_euclid(86_400) * 86_400) as f64), + ]; let statement = self.db.prepare(MEDIA_USAGE).bind(&args).map_err(failed)?; let row: Option = statement.first(None).await.map_err(failed)?; Ok(row.map_or_else(Usage::default, |row| Usage { @@ -749,18 +1083,6 @@ impl D1Index { stored: row.stored as u64, })) } - - /// Record an attachment that R2 has kept, and add it to the total and - /// to its day's count, in one transaction. - pub async fn record_media(&self, key: &str, fid: u64, bytes: u64, mime: &str, now: i64) -> Result<(), IndexError> { - let size = number(bytes as f64); - let args = [key.into(), number(fid as f64), size.clone(), mime.into(), number(now as f64)]; - let record = self.db.prepare(RECORD_MEDIA).bind(&args).map_err(failed)?; - let total = self.db.prepare(ADD_MEDIA).bind(&["all".into(), size.clone()]).map_err(failed)?; - let day = self.db.prepare(ADD_MEDIA).bind(&[day_row(now).into(), size]).map_err(failed)?; - self.db.batch(vec![record, total, day]).await.map_err(failed)?; - Ok(()) - } } /// Who is reading now, for `crate::presence`. diff --git a/crates/allthing-worker/src/lib.rs b/crates/allthing-worker/src/lib.rs index f28fc36c..b22ee5fa 100644 --- a/crates/allthing-worker/src/lib.rs +++ b/crates/allthing-worker/src/lib.rs @@ -275,7 +275,14 @@ pub async fn scheduled(event: ScheduledEvent, env: Env, ctx: ScheduleContext) { return; }; let host = WorkerHost::new(Lifetime::Schedule(ctx), Some(index.clone()), solana_rpc(&env), node_stand_in(&env)); - let inv = Invocation::new(Rc::new(host), SUBREQUEST_BUDGET); + // The index tick reserves sixteen of the existing 45 subrequests for + // bounded multipart cleanup. Latest/activity keep their original budget. + let budget = if event.cron() != LATEST_CRON && event.cron() != ACTIVITY_CRON && media::ready(&env) { + SUBREQUEST_BUDGET - 16 + } else { + SUBREQUEST_BUDGET + }; + let inv = Invocation::new(Rc::new(host), budget); if event.cron() == LATEST_CRON { match pages::store_latest(Rc::new(inv)).await { Ok(()) => console_log!("latest: stored"), @@ -300,6 +307,7 @@ pub async fn scheduled(event: ScheduledEvent, env: Env, ctx: ScheduleContext) { Ok(report) => console_log!("indexer: {report:?}"), Err(error) => console_error!("indexer: {error}"), } + media::cleanup(&env, index.as_ref(), (inv.now() / 1000.0) as i64).await; } /// `/wasm/`: the app's WebAssembly. For a browser that accepts brotli, diff --git a/crates/allthing-worker/src/media.rs b/crates/allthing-worker/src/media.rs index 5788de40..aa07b793 100644 --- a/crates/allthing-worker/src/media.rs +++ b/crates/allthing-worker/src/media.rs @@ -22,12 +22,16 @@ use std::cell::RefCell; use std::collections::HashMap; +use std::future::Future; + +use serde::de::DeserializeOwned; use allthing_core::fmp4::{self, HlsIndex}; use allthing_core::media::{ - self, ByteRange, LIMITS, MAX_EMBED_URL_BYTES, MediaKey, MediaType, Playlist, RangeRead, Refused, UploadLimiter, - Uploaded, admit, check_upload, content_range, none_match, preflight_headers, served_headers, served_type, - unsatisfied_range, + self, ByteRange, CompleteVideoUpload, LIMITS, MAX_EMBED_URL_BYTES, MediaKey, MediaType, NewVideoUpload, Playlist, + RangeRead, Refused, UploadLimiter, Uploaded, VIDEO_PART_BYTES, VideoPart, VideoUploadSession, admit, check_upload, + complete_video_parts, content_range, none_match, preflight_headers, served_headers, served_type, unsatisfied_range, + video_part_bytes, }; use futures::future::FutureExt; use worker::web_sys::ReadableStream; @@ -38,7 +42,7 @@ use worker::{ use crate::auth::{json, random_hex, signed_in_fid}; use crate::error; -use crate::index::D1Index; +use crate::index::{D1Index, MediaUploadRow}; /// The R2 binding attachments are kept in (bucket `allthing-media`). const BUCKET: &str = "MEDIA"; @@ -72,6 +76,9 @@ pub fn ready(env: &Env) -> bool { pub async fn handle(req: Request, env: &Env, ctx: &Context, now_ms: f64) -> Result { match req.method() { Method::Get | Method::Head => limits(&req, env, (now_ms / 1000.0) as i64), + Method::Post if req.url()?.query_pairs().any(|(key, _)| key == "upload") => { + multipart(req, env, ctx, now_ms).await + } Method::Post => upload(req, env, ctx, now_ms).await, _ => error(405, "Attachments take GET, and POST", None), } @@ -97,16 +104,129 @@ fn refuse(refused: Refused) -> Result { error(refused.status, refused.message, refused.retry_after) } -/// `POST /api/v1/media`: the file is the body, its type the `Content-Type` -/// (one of `allthing_core::media::MediaType`) and its size the -/// `Content-Length`. Everything is checked before any of it is stored: the -/// origin, the session, the type and size, the reader's pace, their day's -/// quota and the space left. +const MAX_JSON_BYTES: usize = 24 * 1024; +const SINGLE_VIDEO_BYTES: u64 = 50 * 1024 * 1024; +const BUSY: Refused = + Refused { status: 409, message: "That upload is busy; try again in a moment", retry_after: Some(2) }; +const EXPIRED: Refused = + Refused { status: 410, message: "That upload expired; remove it and attach the video again", retry_after: None }; +const BAD_PART: Refused = + Refused { status: 400, message: "That video's upload parts don't match; try again", retry_after: None }; +const OPERATIONS_FULL: Refused = Refused { + status: 503, + message: "Allthing has taken all the upload work it can today; try again tomorrow", + retry_after: Some(3600), +}; + +fn unavailable(failure: impl std::fmt::Display) -> Refused { + console_error!("media: {failure}"); + NOT_KEPT +} + +/// Mutations settle under waitUntil even if their response loses its reader. +async fn durable( + ctx: &Context, + future: impl Future> + 'static, +) -> std::result::Result { + let kept = future.boxed_local().shared(); + let background = kept.clone(); + ctx.wait_until(async move { + let _ = background.await; + }); + kept.await +} + +/// Control JSON is bounded before and after reading. File bodies never enter Wasm. +async fn control(req: &mut Request) -> Option { + let size = req.headers().get("content-length").ok().flatten()?.parse::().ok()?; + if size == 0 || size > MAX_JSON_BYTES { + return None; + } + let body = req.text().await.ok()?; + (body.len() == size && body.len() <= MAX_JSON_BYTES).then(|| serde_json::from_str(&body).ok()).flatten() +} + +fn hls_index(media: MediaType, length: u64, line: Option<&str>) -> std::result::Result, Refused> { + match line { + None => Ok(None), + Some(line) => match HlsIndex::parse(line) { + Some(index) if media == MediaType::Mp4 && index.total() == length => Ok(Some(index)), + _ => Err(BAD_INDEX), + }, + } +} + +fn valid_id(id: &str) -> bool { + id.len() == 32 && id.bytes().all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase()) +} + +fn new_row( + fid: u64, + media: MediaType, + length: u64, + hls: Option, + mode: &str, + now: i64, + correlation: Option, +) -> Option { + let id = correlation.or_else(|| random_hex(16))?; + let key = MediaKey::new(fid, &id, media)?; + Some(MediaUploadRow { + id, + key: key.object(), + fid, + bytes: length, + media_type: media.mime().to_owned(), + hls, + upload_id: None, + mode: mode.to_owned(), + state: "active".to_owned(), + parts: "[]".to_owned(), + created_at: now, + expires_at: now + media::UPLOAD_IDLE_SECONDS, + lock_id: random_hex(16)?, + lock_until: now + media::UPLOAD_LEASE_SECONDS, + cancel_requested: 0, + }) +} + +fn uploaded(row: &MediaUploadRow, origin: &str) -> std::result::Result { + let key = MediaKey::from_path(&format!("/m/{}", row.key)).ok_or(NOT_KEPT)?; + let url = if row.hls.is_some() { format!("{origin}{}", key.playlist_path()) } else { key.url(origin) }; + if url.len() > MAX_EMBED_URL_BYTES { + return Err(NOT_KEPT); + } + Ok(Uploaded { url, media_type: row.media_type.clone(), bytes: row.bytes }) +} + +async fn reserve(index: &D1Index, row: &MediaUploadRow) -> std::result::Result<(), Refused> { + if index.reserve_media(row).await.map_err(unavailable)? { + return Ok(()); + } + if index.media_cancelled(&row.id, row.fid, row.created_at).await.map_err(unavailable)? { + return Err(EXPIRED); + } + let usage = index.media_usage(row.fid, row.created_at).await.map_err(unavailable)?; + admit(&usage, row.bytes, row.created_at)?; + Err(Refused { + status: 409, + message: "Finish or remove another attachment before starting this one", + retry_after: Some(5), + }) +} + +async fn operation(index: &D1Index, now: i64) -> std::result::Result<(), Refused> { + if index.media_operation(now).await.map_err(unavailable)? { + Ok(()) + } else { + Err(Refused { retry_after: Some((86_400 - now.rem_euclid(86_400)) as u32), ..OPERATIONS_FULL }) + } +} + +/// The legacy single-body endpoint remains for images and small videos. async fn upload(req: Request, env: &Env, ctx: &Context, now_ms: f64) -> Result { let now = (now_ms / 1000.0) as i64; let origin = req.url()?.origin().ascii_serialization(); - // Browsers send Origin with every POST: another site can't upload as a - // reader. if req.headers().get("origin")?.as_deref() != Some(origin.as_str()) { return error(403, "Uploads must come from Allthing itself", None); } @@ -117,89 +237,454 @@ async fn upload(req: Request, env: &Env, ctx: &Context, now_ms: f64) -> Result checked, Err(refused) => return refuse(refused), }; - // A video remuxed for HLS: its index, which must be the MP4's, whole. - let hls = match req.headers().get(fmp4::INDEX_HEADER)? { - None => None, - Some(line) => match HlsIndex::parse(&line) { - Some(index) if media == MediaType::Mp4 && index.total() == length => Some(index), - _ => return refuse(BAD_INDEX), - }, - }; + if media.is_video() && length > SINGLE_VIDEO_BYTES { + return error(413, "Large videos use a chunked upload: attach the video again in Allthing", None); + } + let hls = req.headers().get(fmp4::INDEX_HEADER)?; + if let Err(refused) = hls_index(media, length, hls.as_deref()) { + return refuse(refused); + } if let Err(wait) = LIMITER.with(|limiter| limiter.borrow_mut().allow(fid, now_ms)) { return error(429, "That's a lot of uploads at once; try again in a moment", Some(wait)); } - let Some(key) = random_hex(16).and_then(|id| MediaKey::new(fid, &id, media)) else { - return error(500, "No randomness for a file's name", None); + let Some(row) = new_row(fid, media, length, hls, "single", now, None) else { return refuse(NOT_KEPT) }; + let answer = match uploaded(&row, &origin) { + Ok(answer) => answer, + Err(why) => return refuse(why), }; - // The cast embeds the playlist, which the Farcaster app plays; the file - // itself, which it doesn't, only when there is none. - let url = match hls { - Some(_) => format!("{origin}{}", key.playlist_path()), - None => key.url(&origin), + let Some(body) = req.inner().body() else { return refuse(media::EMPTY) }; + match durable(ctx, keep_single(bucket, index, row, body, now)).await { + Ok(()) => json(&answer, &[]), + Err(why) => refuse(why), + } +} + +async fn keep_single( + bucket: Bucket, + index: D1Index, + mut row: MediaUploadRow, + body: ReadableStream, + now: i64, +) -> std::result::Result<(), Refused> { + operation(&index, now).await?; + reserve(&index, &row).await?; + // Even the legacy single-body route uses an abortable R2 multipart. + // Otherwise a very slow put could finish after its expired reservation + // was cleaned and leave an uncounted object behind. + let metadata = HttpMetadata { content_type: Some(row.media_type.clone()), ..HttpMetadata::default() }; + let mut create = bucket.create_multipart_upload(row.key.as_str()).http_metadata(metadata); + if let Some(hls) = &row.hls { + create = create.custom_metadata(HashMap::from([(fmp4::INDEX_METADATA.to_owned(), hls.clone())])); + } + let upload = match create.execute().await { + Ok(upload) => upload, + Err(why) => { + let _ = index.forget_media(&row).await; + return Err(unavailable(why)); + } }; - if url.len() > MAX_EMBED_URL_BYTES { - return error(500, "This server's address is too long for a cast to embed its files", None); + row.upload_id = Some(upload.upload_id().await); + match index.media_open(&row, row.upload_id.as_deref().unwrap(), now).await { + Ok(true) => {} + result => { + if upload.abort().await.is_ok() { + let _ = index.forget_media(&row).await; + } + return Err(match result { + Err(why) => unavailable(why.0), + _ => EXPIRED, + }); + } + } + if let Err(why) = operation(&index, now).await { + let _ = discard(&bucket, &index, &row).await; + return Err(why); } - let usage = match index.media_usage(fid, now).await { - Ok(usage) => usage, - Err(failure) => { - console_error!("media: usage of {fid}: {}", failure.0); - return error(503, "Uploads can't be counted just now; try again shortly", Some(30)); + let part = match upload.upload_part(1, body).await { + Ok(part) => part, + Err(why) => { + let _ = discard(&bucket, &index, &row).await; + return Err(unavailable(why)); } }; - if let Err(refused) = admit(&usage, length, now) { - return refuse(refused); + if let Err(why) = operation(&index, now).await { + let _ = discard(&bucket, &index, &row).await; + return Err(why); + } + match upload.complete([part]).await { + Ok(stored) if stored.size() == row.bytes => match index.finish_media(&row, now).await { + Ok(true) => return Ok(()), + Err(why) => { + let _ = discard(&bucket, &index, &row).await; + return Err(unavailable(why.0)); + } + _ => {} + }, + Ok(_) => { + let _ = discard(&bucket, &index, &row).await; + return Err(NOT_WHOLE); + } + Err(why) => { + let _ = discard(&bucket, &index, &row).await; + return Err(unavailable(why)); + } + } + let _ = discard(&bucket, &index, &row).await; + Err(NOT_KEPT) +} + +/// Session control and parts use the same authenticated same-origin route. +async fn multipart(mut req: Request, env: &Env, ctx: &Context, now_ms: f64) -> Result { + let now = (now_ms / 1000.0) as i64; + let url = req.url()?; + let origin = url.origin().ascii_serialization(); + if req.headers().get("origin")?.as_deref() != Some(origin.as_str()) { + return error(403, "Uploads must come from Allthing itself", None); + } + let (Some(bucket), Some(index)) = (bucket(env), D1Index::new(env)) else { return error(404, UNSET, None) }; + let Some(fid) = signed_in_fid(&req, env, now)? else { return error(401, SIGN_IN, None) }; + let query = |name: &str| url.query_pairs().find(|(key, _)| key == name).map(|(_, value)| value.into_owned()); + let action = query("upload").unwrap_or_default(); + if action == "create" { + let Some(asked) = control::(&mut req).await else { + return error(400, "That upload request couldn't be read", None); + }; + let (media, length) = match check_upload(Some(&asked.media_type), Some(&asked.bytes.to_string())) { + Ok(pair) if pair.0.is_video() => pair, + Ok(_) => return error(400, "Chunked uploads are for videos", None), + Err(why) => return refuse(why), + }; + if let Err(why) = hls_index(media, length, asked.hls.as_deref()) { + return refuse(why); + } + if let Err(wait) = LIMITER.with(|limiter| limiter.borrow_mut().allow(fid, now_ms)) { + return error(429, "That's a lot of uploads at once; try again in a moment", Some(wait)); + } + if asked.id.as_ref().is_some_and(|id| !valid_id(id)) { + return error(400, "That upload id couldn't be read", None); + } + if let Some(id) = &asked.id { + match index.media_cancelled(id, fid, now).await { + Ok(true) => return refuse(EXPIRED), + Ok(false) => {} + Err(why) => return refuse(unavailable(why.0)), + } + match index.media_upload(id, fid).await { + Ok(Some(row)) => { + if row.mode != "multipart" + || row.bytes != length + || row.media_type != media.mime() + || row.hls != asked.hls + { + return error(409, "That upload id belongs to a different attachment", None); + } + if row.state != "active" && row.state != "done" + || row.cancel_requested != 0 + || row.expires_at <= now + { + return refuse(EXPIRED); + } + if row.upload_id.is_none() { + return refuse(BUSY); + } + return json(&VideoUploadSession { id: row.id, part_bytes: VIDEO_PART_BYTES }, &[]); + } + Ok(None) => {} + Err(why) => return refuse(unavailable(why.0)), + } + } + let Some(row) = new_row(fid, media, length, asked.hls, "multipart", now, asked.id) else { + return refuse(NOT_KEPT); + }; + if uploaded(&row, &origin).is_err() { + return refuse(NOT_KEPT); + } + return match durable(ctx, create_video(bucket, index, row, now)).await { + Ok(answer) => json(&answer, &[]), + Err(why) => refuse(why), + }; + } + let Some(id) = query("id").filter(|id| valid_id(id)) else { + return error(400, "That upload id couldn't be read", None); + }; + let existing = match index.media_upload(&id, fid).await { + Ok(row) => row, + Err(why) => return refuse(unavailable(why.0)), + }; + if action == "abort" { + // Known files can always be cancelled, even after today's write + // budget is exhausted. Unknown-id tombstones share that budget. + if existing.is_none() { + match index.media_cancelled(&id, fid, now).await { + Ok(true) => return Ok(Response::empty()?.with_status(204)), + Ok(false) => {} + Err(why) => return refuse(unavailable(why.0)), + } + if let Err(why) = operation(&index, now).await { + return refuse(why); + } + } + if let Err(why) = index.cancel_media(&id, fid, now).await { + return refuse(unavailable(why.0)); + } + } + let row = match existing { + Some(row) if row.mode == "multipart" => row, + _ if action == "abort" => return Ok(Response::empty()?.with_status(204)), + _ => return error(404, "That upload wasn't found", None), + }; + if action == "abort" { + if row.state == "done" { + return Ok(Response::empty()?.with_status(204)); + } + if operation(&index, now).await.is_err() { + return Ok(Response::empty()?.with_status(204)); + } + return match durable(ctx, abort_video(bucket, index, row, now)).await { + Ok(()) => Ok(Response::empty()?.with_status(204)), + Err(why) => refuse(why), + }; + } + if action == "complete" { + let Some(asked) = control::(&mut req).await else { + return error(400, "That upload request couldn't be read", None); + }; + let parts: Vec = serde_json::from_str(&row.parts).unwrap_or_default(); + if !complete_video_parts(row.bytes, &asked.parts) || asked.parts != parts { + return refuse(BAD_PART); + } + let answer = match uploaded(&row, &origin) { + Ok(answer) => answer, + Err(why) => return refuse(why), + }; + if row.state == "done" { + return json(&answer, &[]); + } + if row.cancel_requested != 0 || row.expires_at <= now { + return refuse(EXPIRED); + } + return match durable(ctx, complete_video(bucket, index, row, asked.parts, now)).await { + Ok(()) => json(&answer, &[]), + Err(why) => refuse(why), + }; + } + if action != "part" { + return error(400, "Unknown upload operation", None); + } + if row.state != "active" || row.cancel_requested != 0 || row.expires_at <= now { + return refuse(EXPIRED); + } + let Some(part) = query("part").and_then(|part| part.parse::().ok()) else { return refuse(BAD_PART) }; + let expected = video_part_bytes(row.bytes, part); + let declared = req.headers().get("content-length")?.and_then(|value| value.parse::().ok()); + if expected.is_none() || expected != declared { + return refuse(BAD_PART); + } + let parts: Vec = serde_json::from_str(&row.parts).unwrap_or_default(); + if let Some(kept) = parts.get(usize::from(part - 1)) { + return json(kept, &[]); + } + if usize::from(part) != parts.len() + 1 { + return refuse(BAD_PART); } let Some(body) = req.inner().body() else { return refuse(media::EMPTY) }; - // Kept and recorded under waitUntil, so a reader who leaves as their - // last byte arrives leaves no file that the totals don't count. - let kept = keep(bucket, index, key, body, length, hls, now).boxed_local().shared(); - let background = kept.clone(); - ctx.wait_until(async move { - let _ = background.await; - }); - match kept.await { - Ok(bytes) => json(&Uploaded { url, media_type: media.mime().to_owned(), bytes }, &[]), - Err(refused) => refuse(refused), + match durable(ctx, keep_part(bucket, index, row, part, body, now)).await { + Ok(answer) => json(&answer, &[]), + Err(why) => refuse(why), + } +} + +async fn create_video( + bucket: Bucket, + index: D1Index, + row: MediaUploadRow, + now: i64, +) -> std::result::Result { + operation(&index, now).await?; + reserve(&index, &row).await?; + let metadata = HttpMetadata { content_type: Some(row.media_type.clone()), ..HttpMetadata::default() }; + let mut create = bucket.create_multipart_upload(row.key.as_str()).http_metadata(metadata); + if let Some(hls) = &row.hls { + create = create.custom_metadata(HashMap::from([(fmp4::INDEX_METADATA.to_owned(), hls.clone())])); + } + let upload = match create.execute().await { + Ok(upload) => upload, + Err(why) => { + let _ = index.forget_media(&row).await; + return Err(unavailable(why)); + } + }; + let upload_id = upload.upload_id().await; + match index.media_open(&row, &upload_id, now).await { + Ok(true) => {} + result => { + // Cancellation may have arrived while create was in flight. + // No parts exist yet; clear only after R2 confirms abort. + if upload.abort().await.is_ok() { + let _ = index.forget_media(&row).await; + } + return Err(match result { + Err(why) => unavailable(why.0), + _ => EXPIRED, + }); + } } + Ok(VideoUploadSession { id: row.id, part_bytes: VIDEO_PART_BYTES }) } -/// Hand the request's body to R2 as it comes, as the declared type, then -/// record it. A file R2 kept that isn't whole, or that can't be recorded, is -/// deleted again, so what is stored is always what is counted. -async fn keep( +async fn claim( + index: &D1Index, + row: &MediaUploadRow, + now: i64, + cleanup: bool, +) -> std::result::Result { + let token = random_hex(16).ok_or(NOT_KEPT)?; + index.claim_media(&row.id, row.fid, &token, now, cleanup).await.map_err(unavailable)?.ok_or(BUSY) +} + +async fn keep_part( bucket: Bucket, index: D1Index, - key: MediaKey, + row: MediaUploadRow, + part: u16, body: ReadableStream, - length: u64, - hls: Option, now: i64, -) -> std::result::Result { - let object = key.object(); - let metadata = HttpMetadata { content_type: Some(key.media.mime().to_owned()), ..HttpMetadata::default() }; - let mut put = bucket.put(object.as_str(), body).http_metadata(metadata); - if let Some(hls) = &hls { - put = put.custom_metadata(HashMap::from([(fmp4::INDEX_METADATA.to_owned(), hls.encode())])); - } - let size = match put.execute().await { - Ok(Some(stored)) => stored.size(), - Ok(None) => return Err(NOT_KEPT), - Err(failure) => { - console_error!("media: put {object}: {failure}"); - return Err(NOT_KEPT); +) -> std::result::Result { + operation(&index, now).await?; + let row = claim(&index, &row, now, false).await?; + // The state may have advanced since the request read it, before claiming. + let mut parts: Vec = serde_json::from_str(&row.parts).map_err(unavailable)?; + if let Some(kept) = parts.get(usize::from(part - 1)) { + let _ = index.release_media_lock(&row).await; + return Ok(kept.clone()); + } + if usize::from(part) != parts.len() + 1 { + let _ = index.release_media_lock(&row).await; + return Err(BAD_PART); + } + let upload_id = row.upload_id.as_deref().ok_or(BUSY)?; + let upload = bucket.resume_multipart_upload(&row.key, upload_id).map_err(unavailable)?; + let kept = match upload.upload_part(part, body).await { + Ok(kept) => VideoPart { part_number: kept.part_number(), etag: kept.etag() }, + Err(why) => { + let _ = index.release_media_lock(&row).await; + return Err(unavailable(why)); + } + }; + parts.push(kept.clone()); + match index.media_parts(&row, &serde_json::to_string(&parts).map_err(unavailable)?, now).await { + Ok(true) => Ok(kept), + Ok(false) => { + let _ = discard(&bucket, &index, &row).await; + Err(EXPIRED) + } + Err(why) => { + let _ = index.release_media_lock(&row).await; + Err(unavailable(why.0)) + } + } +} + +async fn complete_video( + bucket: Bucket, + index: D1Index, + row: MediaUploadRow, + parts: Vec, + now: i64, +) -> std::result::Result<(), Refused> { + operation(&index, now).await?; + let row = claim(&index, &row, now, false).await?; + // A previous complete may have kept the object but lost the D1 response. + let stored = match bucket.head(&row.key).await { + Err(why) => { + let _ = index.release_media_lock(&row).await; + return Err(unavailable(why)); + } + Ok(Some(stored)) => stored, + Ok(None) => { + let upload = + bucket.resume_multipart_upload(&row.key, row.upload_id.as_deref().ok_or(BUSY)?).map_err(unavailable)?; + match upload + .complete(parts.into_iter().map(|part| worker::UploadedPart::new(part.part_number, part.etag))) + .await + { + Ok(stored) => stored, + Err(why) => { + let _ = index.release_media_lock(&row).await; + return Err(unavailable(why)); + } + } } }; - if size != length { - let _ = bucket.delete(object.as_str()).await; + if stored.size() != row.bytes { + let _ = discard(&bucket, &index, &row).await; return Err(NOT_WHOLE); } - if let Err(failure) = index.record_media(&object, key.fid, size, key.media.mime(), now).await { - console_error!("media: record {object}: {}", failure.0); - let _ = bucket.delete(object.as_str()).await; - return Err(NOT_KEPT); + match index.finish_media(&row, now).await { + Ok(true) => Ok(()), + Ok(false) => { + let _ = discard(&bucket, &index, &row).await; + Err(EXPIRED) + } + // Leave its reservation and object intact so Retry can finish safely. + Err(why) => { + let _ = index.release_media_lock(&row).await; + Err(unavailable(why.0)) + } + } +} + +async fn abort_video( + bucket: Bucket, + index: D1Index, + row: MediaUploadRow, + now: i64, +) -> std::result::Result<(), Refused> { + let row = claim(&index, &row, now, true).await?; + discard(&bucket, &index, &row).await +} + +/// Keep the reservation unless R2 confirms no parts or completed object remain. +async fn discard(bucket: &Bucket, index: &D1Index, row: &MediaUploadRow) -> std::result::Result<(), Refused> { + if !index.begin_media_cleanup(row, (worker::Date::now().as_millis() / 1000) as i64).await.map_err(unavailable)? { + return Err(BUSY); + } + if let Some(upload_id) = &row.upload_id { + let upload = bucket.resume_multipart_upload(&row.key, upload_id).map_err(unavailable)?; + if let Err(why) = upload.abort().await { + // A completed multipart no longer has an upload to abort. An + // existing object proves that transition. R2's documented 10024 + // also confirms the upload no longer exists (including expiry). + // Any other unknown failure conservatively retains its quota. + if !why.to_string().contains("(10024)") && bucket.head(&row.key).await.map_err(unavailable)?.is_none() { + let _ = index.release_media_lock(row).await; + return Err(unavailable(why)); + } + } + } + bucket.delete(&row.key).await.map_err(unavailable)?; + index.forget_media(row).await.map_err(unavailable)?; + Ok(()) +} + +/// Existing index cron: one expired upload every other minute, no new trigger. +pub async fn cleanup(env: &Env, index: &D1Index, now: i64) { + if now.div_euclid(60).rem_euclid(2) != 0 { + return; + } + let Some(bucket) = bucket(env) else { return }; + let expired = match index.expired_media(now).await { + Ok(rows) => rows, + Err(why) => { + console_error!("media cleanup: {}", why.0); + return; + } + }; + for old in expired { + if let Ok(row) = claim(index, &old, now, true).await { + let _ = discard(&bucket, index, &row).await; + } } - Ok(size) } /// `GET` or `HEAD /m//.`: an attachment, to anyone, as the diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 6b4a706e..ef2b555d 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -424,11 +424,12 @@ The Worker never holds a reader's key or signs as a reader, and submits to Farca A signed-in reader can attach images and videos to a cast. The Worker keeps them in R2, in the bucket `allthing-media` (the `MEDIA` binding), and serves them on the site's own origin, so the URL a cast embeds is Allthing's and any Farcaster app can load it (`allthing_core::media`, `allthing-worker/src/media.rs`). The composer attaches them ([Writing a cast](#writing-a-cast)) once `GET /api/v1/media` says this server takes them. -- **Uploading.** `POST /api/v1/media`, with the file itself as the body, its type as `Content-Type` and its size as `Content-Length`, from a signed-in session and from Allthing itself (`Origin`). It takes PNG, JPEG, GIF and WebP images up to 10 MiB, and MP4, MOV (QuickTime) and WebM videos up to 50 MiB; the Workers Free plan takes request bodies up to 100 MB. It answers `{"url","type","bytes"}`. The URL is `https://allthing.social/m//.`: the reader's fid, 16 random bytes in hex (`crypto.getRandomValues`) and the type's extension, 84 bytes at most, well within a cast embed's 256. A cast carries two embeds at most. -- **The Worker never reads the bytes.** It hands the request's own body stream to R2's `put` as a JavaScript value, so no byte of a file passes through WebAssembly, and an upload costs the same CPU at 1 KB as at 50 MB: the Worker's WebAssembly memory stayed at 1.31 MiB through two 45 MiB uploads, and the work around the stream is about a microsecond of Rust ([BUDGET.md](BUDGET.md#12-attachments-r2)). So nothing looks inside a file either: it is kept as the type it was declared, which must be one of the seven, and it is only ever served as that type. -- **Quotas**, counted in D1 (`media`, one row a file, `migrations/0007_media.sql`): per reader, 300 MiB and 60 files in any 24 hours; 2,000 uploads a UTC day from everyone together, which keeps R2's writes and D1's rows written within their free plans; and 9 GiB stored in all, under R2's free 10 GB-month. `media_total` keeps the sums (`all`, and a row a day), so a check reads the reader's own index entries for the day and two rows. Each is checked before anything is stored; past one, the answer is 507 and why, with when the reader's day, or the site's, frees up (`Retry-After`). A reader may also start 20 uploads a minute in each isolate (429), under the API's per-IP limit. Once R2 has a file, the Worker records it and adds it to the sums, in one D1 batch; a file it can't record, or that didn't arrive whole, is deleted again. Both run under `waitUntil`, so a reader who leaves as their file arrives leaves nothing stored that isn't counted. A cast never uploaded leaves its files kept and counted. +- **Uploading.** `POST /api/v1/media`, with the file itself as the body, its type as `Content-Type` and its size as `Content-Length`, from a signed-in session and from Allthing itself (`Origin`). It takes PNG, JPEG, GIF and WebP images up to 10 MiB, and MP4, MOV (QuickTime) and WebM videos up to 250 MiB. Images and small videos use this single request (internally stored through an abortable R2 multipart upload); larger videos use the multipart flow below, keeping each request under the zone’s 100 MB limit. It answers `{"url","type","bytes"}`. The URL is `https://allthing.social/m//.`: the reader's fid, 16 random bytes in hex (`crypto.getRandomValues`) and the type's extension, 84 bytes at most, well within a cast embed's 256. A cast carries two embeds at most. +- **Larger videos.** `POST /api/v1/media?upload=create` takes a bounded JSON descriptor (`id`, `type`, `bytes`, optional `hls`) and atomically reserves the reader's quota and storage before any data is sent. The browser creates a random 32-hex correlation id so a lost create response can be retried safely. The authenticated account owns the session; object keys and R2 upload ids are never accepted from the client. `?upload=part&id=…&part=N` streams exact 8 MiB parts (a shorter final part), and returns `{part_number,etag}`. Parts are recorded in order; a repeated acknowledged part returns its prior receipt. `?upload=complete&id=…` accepts the exact ordered receipts and returns the normal attachment response once storage and accounting are complete. Repeated completion returns the same URL. Retry in the same draft continues from the last acknowledged part. Remove calls `?upload=abort&id=…`, including before create completes; cancellation prevents later publication. Abandoned sessions are reclaimed at most one every other existing index-cron tick, within its reserved subrequest allowance. Pending sessions are capped at 128 globally. Reservations are released only after R2 confirms abort or deletion, never on time alone. +- **The Worker never reads the bytes.** It hands the request's own body stream to R2’s multipart part upload as a JavaScript value, so no byte of a file passes through WebAssembly, and an upload costs the same CPU at 1 KB as at 50 MB: the Worker's WebAssembly memory stayed at 1.31 MiB through two 45 MiB uploads, and the work around the stream is about a microsecond of Rust ([BUDGET.md](BUDGET.md#12-attachments-r2)). So nothing looks inside a file either: it is kept as the type it was declared, which must be one of the seven, and it is only ever served as that type. +- **Quotas**, counted in D1 (`media`, one row a file, `migrations/0007_media.sql`): per reader, 300 MiB and 60 files in any 24 hours; 2,000 files a UTC day from everyone together, with a separate 1,000 upload-work-attempt cap shared by multipart requests and the compatible single-file endpoint; and 9 GiB stored in all, under R2's free 10 GB-month. `media_total` keeps the sums (`all`, and a row a day), so a check reads the reader's own index entries for the day and two rows. Pending uploads reserve the same quotas atomically before anything is stored; past one, the answer is 507 and why, with when the reader's day, or the site's, frees up (`Retry-After`). A reader may also start 20 uploads a minute in each isolate (429), under the API's per-IP limit. Once R2 has a file, the Worker records it and adds it to the sums, in one D1 batch; a file it can't record, or that didn't arrive whole, is deleted again. Both run under `waitUntil`, so a reader who leaves as their file arrives leaves nothing stored that isn't counted. A cast never uploaded leaves its files kept and counted. - **Serving.** `GET /m//.` (and `HEAD`) streams the file from R2 into the response, without the Cache API: every view is a Worker request anyway, and R2's free reads are many times what the Worker's daily requests could use. A `Range` gets those bytes (206, as Safari's video player asks for them; 416 past the end), and an `If-None-Match` naming the file 304. Each answer has the type kept with the file (one of the seven, or else `application/octet-stream`), `Cache-Control: public, max-age=31536000, immutable` (a name is never reused), `X-Content-Type-Options: nosniff`, `Content-Security-Policy: default-src 'none'; img-src 'self'; media-src 'self'; style-src 'unsafe-inline'; sandbox`, `Cross-Origin-Resource-Policy: cross-origin` and `Access-Control-Allow-Origin: *`. So a file that isn't what it says, opened in a tab of its own, runs nothing and loads nothing but itself, while the browser's own viewer still shows an image and plays a video (with `default-src 'none'` alone, a video opened in a tab wouldn't play). The path is read strictly: an fid, 32 lowercase hex digits and one of the seven extensions; anything else is 404, as is every path without the binding. -- **Videos for the Farcaster app.** The Farcaster app plays only HLS in a cast: an `.m3u8` from any host shows as a video (its server reads the stream for the size and a thumbnail), and an `.mp4` as "No preview found for shared link", however it is served (checked on 22 October 2026 against casts with live MP4s on other hosts, through the app's own API). So the composer remuxes an MP4 or MOV in the browser before uploading it (`allthing_core::fmp4`): the same coded samples, as one fragmented MP4, an init segment then a `moof` and `mdat` from each key frame at least 4 s after the last (fewer, longer segments for a long video, 80 at most), without `udta` and `meta`, where a phone writes where and with what it was recorded. Only H.264 or HEVC video, with AAC audio or none, is remuxed, which every HLS player decodes in fragmented MP4; anything else goes as it is. The upload carries the index (`x-allthing-hls`: size, codecs, the init's length and each segment's milliseconds and bytes); the Worker checks that it parses and adds up to the file, keeps it as the object's R2 custom metadata (2 KiB at most), and answers the playlist's URL, `/m//.m3u8`, which the cast embeds. From that metadata alone, it writes the master playlist (one rendition: `RESOLUTION`, `CODECS`, `BANDWIDTH`) and, at `.v.m3u8`, the media playlist, whose `EXT-X-MAP` and segments are byte ranges of `.mp4` (HLS version 7). Allthing's own player plays the MP4, which any browser plays by itself, in place of its own playlist (`media::progressive_of`). +- **Videos for the Farcaster app.** The Farcaster app plays only HLS in a cast: an `.m3u8` from any host shows as a video (its server reads the stream for the size and a thumbnail), and an `.mp4` as "No preview found for shared link", however it is served (checked on 22 October 2026 against casts with live MP4s on other hosts, through the app's own API). So the composer remuxes an MP4 or MOV in the browser before uploading it (`allthing_core::fmp4`). It reads only bounded box headers and up to 8 MiB of movie metadata, then builds a Blob from small generated headers and slices of the original file; media payload bytes never need a full-file ArrayBuffer or a WebAssembly copy. Preparation yields between bounded steps, and preparation, upload progress and finishing have distinct visible states. This is lossless repackaging, not transcoding: the same coded samples, as one fragmented MP4, an init segment then a `moof` and `mdat` from each key frame at least 4 s after the last (fewer, longer segments for a long video, 80 at most), without `udta` and `meta`, where a phone writes where and with what it was recorded. Only H.264 or HEVC video, with AAC audio or none, is remuxed, which every HLS player decodes in fragmented MP4; anything else goes as it is. The upload carries the index (`x-allthing-hls`: size, codecs, the init's length and each segment's milliseconds and bytes); the Worker checks that it parses and adds up to the file, keeps it as the object's R2 custom metadata (2 KiB at most), and answers the playlist's URL, `/m//.m3u8`, which the cast embeds. From that metadata alone, it writes the master playlist (one rendition: `RESOLUTION`, `CODECS`, `BANDWIDTH`) and, at `.v.m3u8`, the media playlist, whose `EXT-X-MAP` and segments are byte ranges of `.mp4` (HLS version 7). Allthing's own player plays the MP4, which any browser plays by itself, in place of its own playlist (`media::progressive_of`). - **Not set up.** A Worker without the bucket's binding ([DEPLOY.md](DEPLOY.md#attachments-r2)) answers 404 to uploads, `GET /api/v1/media` and `/m/`, and `/api/v1/status` says `media.ready: false`. allthing.social has had the binding since 21 October 2026. - **Elsewhere.** Farcaster apps show an image embed by its type, which the Worker gives truthfully to a `HEAD` or a `GET` (Neynar's crawler, for one, records an embed's `content_type`). The Farcaster app's documentation describes video embeds as HLS playlists from domains its team has enabled, so there a plain MP4, MOV or WebM link may show as a link. Allthing plays MP4 and WebM itself. - **Tested end to end.** `e2e/media-upload.fixture.spec.ts` runs a Worker of its own from `wrangler.toml` and its R2 binding, with a local R2 and an empty D1. It uploads an image and a video and reads them back byte for byte, in ranges, with their headers; refuses the wrong type, sizes, session, origin and pace; holds a reader to 60 files a day, everyone to 2,000 uploads a day and 9 GiB, with D1 seeded; opens an HTML file declared as a PNG in a browser, which shows it as a broken image and runs none of it; and checks that a Worker from `wrangler.toml` without the binding answers 404. diff --git a/docs/BUDGET.md b/docs/BUDGET.md index aac77ef5..7990eb6c 100644 --- a/docs/BUDGET.md +++ b/docs/BUDGET.md @@ -57,7 +57,7 @@ The browser app is static. A page view runs the Worker for its one API call and | After posting a cast, a reply or a quote, or deleting one | 1 to refresh its pages (`/api/v1/refresh`) and 1 to read each again past the browser's cache: the reader's profile, and for a reply its cast and that cast's replies, the post page it was written on, and for a cast in a channel the channel. A page that doesn't have it yet is refreshed and read again after 4 and 10 s: 2 to 5 requests, 15 at most | none | | A profile, for a reader who can follow | **0** more: the browser asks Hypersnap whether the reader follows the account (`/v1/linkById`, one request per profile view) | none | | Following, unfollowing, or deleting one's own cast | **0**: signed and handed to Hypersnap by the browser, and read back from it (`/v1/linkById`, `/v1/castById`), like a like | none | -| Attaching an image or a video to a cast | 1 per file (`POST /api/v1/media`: D1 1–2 and R2 1, no upstream call), and 1 as the composer opens for a reader signed in, to ask whether it may attach (`GET /api/v1/media`: no subrequest, kept 5 minutes by the app and the browser; none for a reader signed out) ([§12](#12-attachments-r2)) | none | +| Attaching an image or a video to a cast | A small file: 1 `POST /api/v1/media`, backed by three bounded R2 multipart operations so cancellation can fence even a very slow body. A large video: create + one POST per 8 MiB part + complete (34 requests at 250 MiB); Retry resends only the incomplete part, and replaying a recorded part performs no R2 write. The composer also asks `GET /api/v1/media` once per 5 minutes. No upstream call ([§12](#12-attachments-r2)) | none | | Showing a reader's attachment, here or in any other app | 1 per file a browser doesn't have (`/m/…`, an R2 read; each kept a year, as immutable); a video as many as the ranges its player asks for, usually 1 to 3; in an HLS player (the Farcaster app), 2 for its playlists, then 1 for the init and 1 per segment (4 s or more) it plays | none | | Voting in Allthing's poll | 1 (`POST /api/v1/poll`: one D1 batch, no cache call, no upstream call), once a browser: a later visit shows the results it kept, and the counts come with the front page's payload ([§14](#13-allthings-poll)) | none | | Messages `/messages` ([Messages on SpacetimeDB](ARCHITECTURE.md#messages-on-spacetimedb)) | **1** for a signed-in reader as it connects (`POST /api/v1/auth/attest`: an Ed25519 signature, no subrequest). Everything else goes from the browser to SpacetimeDB over one WebSocket, and to Hypersnap's `user/bulk` for names and pictures, never through the Worker | none | @@ -168,7 +168,7 @@ Each invocation gets a budget of 45 subrequests, under the cap of 50. Every `fet | sign-out (`auth/signout`) | D1 1 to remove authenticated public presence when enabled (+ the schema once per isolate); otherwise 0 | | scheduled casts: the list, scheduling, cancelling | D1 1 (a cancel's is one batch: what it finds, and its delete) (+ the schema once per isolate); no upstream calls; never cached | | refresh (after a reader's write) | per page (≤ 5): cache read 1 + cache write 1, of an entry there is (≤ 10); no upstream calls; never cached | -| an attachment's upload (`POST /api/v1/media`) | D1 1 (the reader's day, the day's uploads and the total) + the schema once per isolate + R2 put 1 + D1 1 (the file, the total and the day's count, in one batch) + R2 delete 1, only for a file that can't be recorded (≤ 5); no upstream calls; never cached | +| an attachment's upload (`POST /api/v1/media`) | Atomic D1 reservation + three daily work debits + R2 multipart create, streamed part and complete + transactional D1 completion (10 calls, plus schema once per isolate); failure cleanup fences the row, deletes the object and removes its reservation. Video control and parts use at most 16 calls per request including failure cleanup (the common part path: session read + lease + operation debit + R2 part + state save). No upstream calls; never cached | | an attachment (`/m/…`) | R2 1 (a read of all or a range, or of its metadata for `HEAD` and `If-None-Match`; a range past the end 2); a remuxed video's playlist (`.m3u8`), R2 1 (`head`: written from its metadata, about 2 KB of text at most); a CORS preflight, none | | a presence beat (`POST /api/v1/presence`) | cache read 1 (the location's answer) + D1 1 (one batch; + the schema once per isolate) + on a recount (regular public beats at most twice a minute per location; visibility changes may refresh sooner): `user/bulk` 1 (only when someone shows themselves by name) + cache write 1 (≤ 4); never cached by browsers ([§14](#14-presence)) | | `/api/v1/media` | 0 | @@ -215,7 +215,7 @@ Profile cards (`notes.users`) come from objects a build has already parsed. For Endorsing a reader's key signs one EIP-712 digest with secp256k1 (two with `ALLTHING_SPONSOR`): **0.074 ms** a signature natively at `opt-level = "z"` (`k256` without its precomputed tables, as the Worker builds it), so about 1 ms or less as WebAssembly. The Keccak-256 hashes around it take microseconds. -An attachment's upload costs the same CPU whatever its size: the Worker hands the request's own body stream to R2 and never reads it, so none of it passes through WebAssembly. Measured on the real Worker under `wrangler dev`, its WebAssembly memory stayed at 1.31 MiB through two 45 MiB uploads, where a body read into Rust would have grown it by 45 MiB at least, and for good. The work around the stream, natively at `opt-level = "z"`: **0.0012 ms** an upload (the session's HMAC, the type and size, the quota, the name, URL and answer) and **0.0007 ms** a range served; the rest is a few runtime calls and D1's two queries. A sampling profiler attached to the local runtime isn't a measure of it: it charges the runtime's own receiving of a large body to whichever JavaScript frame ran last, about 230 ms for a 45 MiB body refused before anything touched it. +An attachment's streaming costs the same CPU whatever its size: the Worker hands the request's own body stream to R2 and never reads it, so none of it passes through WebAssembly. Measured on the earlier single-put implementation under `wrangler dev`, its WebAssembly memory stayed at 1.31 MiB through two 45 MiB uploads, where a body read into Rust would have grown it by 45 MiB at least, and for good. The work around the stream, natively at `opt-level = "z"`: **0.0012 ms** an upload (the session's HMAC, the type and size, the quota, the name, URL and answer) and **0.0007 ms** a range served; the rest is a few runtime calls and D1's two queries. A sampling profiler attached to the local runtime isn't a measure of it: it charges the runtime's own receiving of a large body to whichever JavaScript frame ran last, about 230 ms for a 45 MiB body refused before anything touched it. A signed-in reader's front page (`/api/v1/following`) parses one or two of Hypersnap's `feed/following` pages of 100 casts (about 200 KB each, recorded for @dwr on 22 October 2026: 201 KB, 22 of them top-level) each as heavy as the trending feed above (0.74 ms natively): about **0.8 to 1.5 ms** natively, so about 2 to 4 ms as WebAssembly in the worst case of two pages, which only a reader following few active accounts reaches. It no longer reads a trending page to top up a sparse feed. The replies are normalized and dropped before the mini app check and the annotations, which then see 20 to 60 casts, fewer than home's 100. These are estimates from the measurements above, not measured on the build itself. It is per reader, so the edge cache serves only that reader's repeat visits within a minute (10 minutes stale): Hypersnap sees at most one or two `feed/following` reads per signed-in reader a minute while they browse the front page. @@ -254,7 +254,7 @@ Allthing's goal is that the number of viewers barely changes upstream volume. - **Per-client limit**: the `API_LIMITER` rate-limiting binding allows 120 API calls a minute per IP per Cloudflare location, and answers 429 with `Retry-After: 60` beyond that. It is a backstop; normal browsing uses a few calls a minute. - **Batches**: one `/api/v1/engagement` request can cost up to 18 Hypersnap reads (and 6 link pages), but only while the isolate's Hypersnap bucket holds more than 16 tokens, and it parses no more than its share of CPU whatever conversations it is asked for. A flood of batches for made-up combinations of casts gets 503s once it reaches that floor, and page builds keep the rest. -- **Reads only**: anything but GET or HEAD on `/api/*` gets 405, except the sign-in endpoints and the endorsement of a key (`/api/v1/auth/*`), scheduled casts and the upload of an attachment, which take only POSTs whose `Origin` is the site's own and are under the same per-IP limit. An upload also needs a session, and is held to a reader's 20 a minute, 60 files and 300 MiB a day, 2,000 a day from everyone and 9 GiB in all ([§12](#12-attachments-r2)). An endorsement needs a session, or a sign-in under way (the signed nonce cookie), makes no upstream call and costs one or two signatures. The Worker submits nothing to Farcaster: a reader's likes and recasts go from their browser to Hypersnap, under Hypersnap's own limits. A sign-in's checks run only for a browser holding the signed nonce cookie its `start` set, a `verify` costs at most 3 upstream calls under the OP Mainnet RPC's own bucket (burst 10, 2 a second), and a `key` 2, under Hypersnap's. The API sends no CORS headers, so other sites' scripts cannot read its answers, which leaves little reason to call it from elsewhere. +- **Reads only**: anything but GET or HEAD on `/api/*` gets 405, except the sign-in endpoints and the endorsement of a key (`/api/v1/auth/*`), scheduled casts and the upload of an attachment, which take only POSTs whose `Origin` is the site's own and are under the same per-IP limit. An upload also needs a session, and is held to a reader's 20 starts a minute, two incomplete files, 60 files and 300 MiB a day, 2,000 a day from everyone and 9 GiB in all; all R2 write attempts share a 1,000/day work budget ([§12](#12-attachments-r2)). An endorsement needs a session, or a sign-in under way (the signed nonce cookie), makes no upstream call and costs one or two signatures. The Worker submits nothing to Farcaster: a reader's likes and recasts go from their browser to Hypersnap, under Hypersnap's own limits. A sign-in's checks run only for a browser holding the signed nonce cookie its `start` set, a `verify` costs at most 3 upstream calls under the OP Mainnet RPC's own bucket (burst 10, 2 a second), and a `key` 2, under Hypersnap's. The API sends no CORS headers, so other sites' scripts cannot read its answers, which leaves little reason to call it from elsewhere. - **The browser app** polls the Worker only for a signed-in reader: the bell, once every 3 minutes while the page is in view (a read under a minute old is reused); nothing while the page is hidden. Otherwise it makes one API call per page, caches answers for 5 minutes, prefetches within the caps above, and shows a "busy, try again" panel on 429 or 503 instead of retrying in a loop. - **When 100,000 requests/day is reached**: requests that would run the Worker (`/api/*`, `/wasm/*`) get 429 until 00:00 UTC. Static assets keep being served free, and `boot.js` then loads the static copy of the module from `/assets/`, so the site still loads and shows the error panel. Nothing is billed: the Free plan has no overage. - **Optional, set up in the dashboard**: a WAF rate-limiting rule on `/api/` and Bot Fight Mode stop abusive traffic *before* it reaches the Worker, so it doesn't count against the daily allowance. @@ -329,19 +329,24 @@ Readers' images and videos ([ARCHITECTURE.md](ARCHITECTURE.md#attachments)) are | Limit | Free | Attachments | |---|---|---| | R2 storage | 10 GB-month, Standard class | 9 GiB at most (9.66 GB; R2's free 10 GB is 9.31 GiB): an upload that would take the total stored past it is refused (507), so no month's average can pass it. The total is a row in D1 (`media_total`). The Worker deletes no file, as casts embed them for good, so once 9 GiB are kept, attaching stops until files are removed by hand ([DEPLOY.md](DEPLOY.md#attachments-r2)); at an average of 1 MB a file, that is about 9,600 files | -| R2 Class A operations (writes) | 1 million a month | One `put` an upload. At most 2,000 uploads a UTC day from everyone together: 62,000 a month at most. A reader may make 60 a day and 20 a minute | +| R2 Class A operations (writes) | 1 million a month | Before every new reservation and multipart create, part or complete attempt, D1 atomically debits a global 1,000/day work budget, including failures and retries (31,000 attempts in 31 days at most). Unknown-id cancellation tombstones and foreground cleanup attempts also debit this budget; a cancellation flag itself remains possible when the budget is exhausted and queues cleanup. Small legacy uploads take three work units. The existing 2,000-file ceiling therefore remains an upper limit, not a throughput promise. Recorded-part and completed-upload replays do no R2 write. A video up to 250 MiB takes at most 32 parts + create + complete. At most 2,000 completed/pending files a UTC day; per reader 60/day, two incomplete files and 20 starts/minute | | R2 Class B operations (reads) | 10 million a month | One read a request to `/m/` (two for a range past the end). Each is a Worker request too, so the 100,000 a day bound them: 3.1 million a month at most | | R2 egress | free | Free, whatever is read | -| Worker requests | 100,000/day | An upload is 1. A file shown is 1 per browser (or app's server) that doesn't have it yet, as each is cached for a year; a video is as many as the ranges its player asks for, usually 1 to 3. A file viewed often costs a request per viewer, wherever it is shown: a popular video in other Farcaster apps could take a large share of the day's 100,000. If it ever does, the bucket can be given a custom domain of its own (`media.` on the zone), which Cloudflare's cache serves with no Worker request, and new uploads' URLs pointed at it | -| Request body | 100 MB (the zone's plan) | 50 MiB at most, a video; 10 MiB an image | +| Worker requests | 100,000/day | A small upload is 1. A 250 MiB video uses 34 requests (create, 32 parts, complete); cancel adds 1, and failed parts may be retried individually. A file shown is 1 per browser (or app's server) that doesn't have it yet, as each is cached for a year; a video is as many as the ranges its player asks for, usually 1 to 3. A file viewed often costs a request per viewer, wherever it is shown: a popular video in other Farcaster apps could take a large share of the day's 100,000. If it ever does, the bucket can be given a custom domain of its own (`media.` on the zone), which Cloudflare's cache serves with no Worker request, and new uploads' URLs pointed at it | +| Request body | 100 MB (the zone's plan) | 8 MiB per video part, except the smaller last part; control JSON at most 24 KiB. The legacy endpoint remains at 50 MiB per video and 10 MiB per image. Whole videos may be up to 250 MiB | | CPU time | 10 ms per invocation | The same at any size: the body goes from the client to R2 inside the runtime ([§4](#4-cpu-time)) | -| Subrequests | 50 per invocation | An upload 3 to 5, a file 1 or 2 ([§3](#3-subrequests-per-invocation)) | -| D1 rows written | 100,000/day | 4 an upload (its row and its index entry, the total and the day's count): 8,000 a day at the 2,000 uploads | -| D1 rows read | 5,000,000/day | An upload reads the reader's index entries of the past 24 hours (60 at most) and two rows of totals | -| D1 storage | 5 GB | About 100 bytes a file | +| Subrequests | 50 per invocation | Upload/control requests at most 16 including failure cleanup and first-isolate schema; normal legacy 10, normal part 5; a file 1 or 2 ([§3](#3-subrequests-per-invocation)) | +| D1 rows written | 100,000/day | Reservations and leases add bounded rows/updates. A successful 32-part video uses roughly 250 row/index writes, versus 4 previously; the 1,000/day work budget bounds foreground writes (conservatively about 19 indexed row writes per work unit, including later cleanup). One background cleanup attempt every two minutes adds at most about 6,500 lease/index writes/day. These are feature estimates sharing the same database with the indexer, presence, activity, snapshots and scheduled casts, not an absolute account-wide guarantee. Cleanup prunes completed/cancellation state after 24 hours and old operation counters after two days | +| D1 rows read | 5,000,000/day | Atomic admission reads the reader's completed index entries (60 at most), pending reservations (two per reader), completed totals and active reservations. The global pending pool is bounded to 128 files and 9 GiB; every part reads only its own session | +| D1 storage | 5 GB | About 100 bytes per published file, plus bounded replay/session rows (at most 32 opaque part tags of 512 bytes each). Incomplete rows are capped at 128; completed replay and cancellation rows are pruned after 24 hours | **Not set up**, as until R2 is enabled and the bucket bound, the upload and `/m/` answer 404 at once, with no subrequest. +**Pending storage counts immediately.** One conditional D1 insert reserves the entire declared file before R2 accepts bytes. All incomplete states, including expired uploads and cleanup in progress, count against the same 300 MiB reader limit and 9 GiB total as completed files. Expiry never frees storage by itself: cleanup must confirm R2 abort/deletion before removing the reservation. Finalization moves the reservation into the existing file/totals rows in one transaction, exactly once. A cancellation that arrives before create leaves an account-bound 24-hour tombstone; a cancellation during create/part/complete prevents later publication. Completed uploads remain public when their draft attachment is removed. + +**Cleanup uses the existing index cron**, at most one expired upload every other minute, with no new trigger. Its D1 pruning/read, one fenced claim and R2 abort/head/delete paths are bounded to 16 subrequests including the schema batch. That index invocation gives the indexer and scheduled work 29 of the existing 45 guarded subrequests, retaining the five-call margin under Cloudflare's 50. Latest-cast and activity crons keep their original budget. A failed cleanup retains its storage reservation for another sweep; R2's seven-day automatic multipart abort is a final platform backstop, not permission to forget pending bytes early. Failed/unknown cleanup is reported, not silently counted as released. + + ## 13. Allthing's poll One question on the front page, one vote per network ([ARCHITECTURE.md](ARCHITECTURE.md#allthings-poll)), counted in D1. Against the Free plan: diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md index 16b5eac8..9b82ad5f 100644 --- a/docs/DEPLOY.md +++ b/docs/DEPLOY.md @@ -110,7 +110,9 @@ If a step fails, delete the secret: writes go back to the Farcaster app at once. Readers' images and videos ([ARCHITECTURE.md](ARCHITECTURE.md#attachments)) are kept in a Cloudflare R2 bucket, `allthing-media`, which the Worker reaches through its `MEDIA` binding. The production binding is enabled in `wrangler.toml`. A fork must create its own bucket before deploying with that binding, or comment it out: `wrangler deploy` fails when a bound bucket does not exist. Without the binding, uploads answer 404, the composer shows no attach button, and `/api/v1/status` says `"media": {"ready": false}`. -**What it costs.** R2 is free, each month, up to 10 GB-month of storage, 1 million Class A operations (an upload is one) and 10 million Class B operations (reading a file is one), and it has no egress fees ([R2 pricing](https://developers.cloudflare.com/r2/pricing/)). The free tier is for the Standard storage class only. The Worker never lets the files stored pass 9 GiB in all, under 10 GB however a gigabyte is counted, takes 2,000 uploads a day at most from everyone together (62,000 writes a month), and holds each reader to 300 MiB and 60 files a day ([BUDGET.md](BUDGET.md#12-attachments-r2)). R2 is priced apart from Workers, which the Workers Paid plan doesn't cover either, so the Workers plan stays Free ([Workers pricing](https://developers.cloudflare.com/workers/platform/pricing/)). Enabling R2 is a checkout that adds an R2 subscription to the account ([R2: get started](https://developers.cloudflare.com/r2/get-started/)), and Cloudflare wants a valid payment method on file before it enables a subscription ([billing policy](https://developers.cloudflare.com/billing/understand/billing-policy/)). Usage past the free tier would be billed to it; the caps keep storage under it, and operations far under it. +Videos up to 250 MiB use 8 MiB multipart requests above the small-file threshold. No paid transcoding service, extra credentials or new cron trigger is required. The existing index cron reclaims stale upload sessions with a reserved subrequest allowance; deploy the upload-session schema migration alongside the Worker before testing larger files. Browser preparation preserves supported video and audio samples rather than changing their codec or quality. + +**What it costs.** R2 is free, each month, up to 10 GB-month of storage, 1 million Class A operations (uploads use multipart create, part and complete operations; larger videos require one operation per part) and 10 million Class B operations (reading a file is one), and it has no egress fees ([R2 pricing](https://developers.cloudflare.com/r2/pricing/)). The free tier is for the Standard storage class only. The Worker never lets the files stored pass 9 GiB in all, under 10 GB however a gigabyte is counted, takes 2,000 uploads a day at most from everyone together and caps upload-work attempts at 1,000 a day (31,000 in a 31-day month), and holds each reader to 300 MiB and 60 files a day ([BUDGET.md](BUDGET.md#12-attachments-r2)). R2 is priced apart from Workers, which the Workers Paid plan doesn't cover either, so the Workers plan stays Free ([Workers pricing](https://developers.cloudflare.com/workers/platform/pricing/)). Enabling R2 is a checkout that adds an R2 subscription to the account ([R2: get started](https://developers.cloudflare.com/r2/get-started/)), and Cloudflare wants a valid payment method on file before it enables a subscription ([billing policy](https://developers.cloudflare.com/billing/understand/billing-policy/)). Usage past the free tier would be billed to it; the caps keep storage under it, and operations far under it. Once, in the Cloudflare dashboard for account `c647739b4535010b0b13e6fc296e2c4e`: @@ -125,7 +127,7 @@ Once, in the Cloudflare dashboard for account `c647739b4535010b0b13e6fc296e2c4e` ``` Workers Builds then deploys it. Its token already has what an R2 binding needs: **Workers R2 Storage: Edit** is among the permissions it is created with ([Workers Builds configuration](https://developers.cloudflare.com/workers/ci-cd/builds/configuration/)). -4. **Check.** `https://allthing.social/api/v1/status` shows `"media": {"ready": true}`. Signed in, `https://allthing.social/api/v1/media` answers the limits: `{"max_image_bytes":10485760,"max_video_bytes":52428800,"max_embeds":2}`. +4. **Check.** `https://allthing.social/api/v1/status` shows `"media": {"ready": true}`. Signed in, `https://allthing.social/api/v1/media` answers the limits: `{"max_image_bytes":10485760,"max_video_bytes":262144000,"max_embeds":2}`. Afterwards: diff --git a/e2e/composer-attachments.fixture.spec.ts b/e2e/composer-attachments.fixture.spec.ts index 56e03043..b8e8e024 100644 --- a/e2e/composer-attachments.fixture.spec.ts +++ b/e2e/composer-attachments.fixture.spec.ts @@ -1,5 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -import { readFileSync } from "node:fs"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; import path from "node:path"; import { expect, test, type Browser, type JSHandle, type Locator, type Page, type TestInfo } from "@playwright/test"; import { expectDraft } from "./helpers/composer"; @@ -31,7 +32,7 @@ const NODE = "https://haatz.quilibrium.com/v1"; const KLIPY = "https://api.klipy.com/api/v1/fixture-key/gifs"; const cors = { "access-control-allow-origin": "*", "access-control-allow-headers": "content-type" }; const MB = 1024 * 1024; -const LIMITS = { max_image_bytes: 10 * MB, max_video_bytes: 50 * MB, max_embeds: 2 }; +const LIMITS = { max_image_bytes: 10 * MB, max_video_bytes: 250 * MB, max_embeds: 2 }; const FULL = "A cast takes two embeds: remove one to add another."; const ACCEPT = "image/png,image/jpeg,image/gif,image/webp,video/mp4,video/quicktime,video/webm"; const EXT: Record = { @@ -412,7 +413,7 @@ test("at 1280, files attach from the button and a paste, show their uploads' pro const button = dialog.getByRole("button", { name: "Attach images or videos" }); await expect(button).toBeVisible(); expect(media.asked).toBe(1); - await expect(button).toHaveAccessibleDescription("Images up to 10 MB, videos up to 50 MB"); + await expect(button).toHaveAccessibleDescription("Images up to 10 MB, videos up to 250 MB"); const input = dialog.locator("input.attach-input"); await expect(input).toHaveAttribute("type", "file"); await expect(input).toHaveAttribute("accept", ACCEPT); @@ -921,3 +922,174 @@ test("at 390, where Allthing doesn't post, the bar still fits with Post in Farca expect(new URL((await link.getAttribute("href"))!).searchParams.getAll("embeds[]")).toEqual(media.uploads.map((u) => u.url)); await context.close(); }); + + +/** The multipart endpoint, holding completion so its processing state is observable. */ +async function chunkedMedia(page: Page, failPartOnce = 0, failureStatus = 503, loseCreate = false, loseComplete = false) { + const chunks: { number: number; body: Buffer }[] = []; + const attempts: number[] = []; + let creates = 0; + const createIds: string[] = []; + let completes = 0; + const tag = (part: number) => `part-${part}-${"opaque".repeat(28)}`; + let aborted = false; + let finish: (() => void) | undefined; + let id = ""; + await page.route("**/api/v1/media?*", async (route) => { + const url = new URL(route.request().url()); + const action = url.searchParams.get("upload"); + if (action === "create") { + creates += 1; + id = route.request().postDataJSON().id; + createIds.push(id); + expect(id).toMatch(/^[a-f0-9]{32}$/); + expect(route.request().postDataJSON()).toMatchObject({ type: "video/webm", bytes: 16 * MB + 17 }); + if (loseCreate) { + loseCreate = false; + return route.fulfill({ status: 503, json: { error: "Create response lost; Retry." } }); + } + return route.fulfill({ json: { id, part_bytes: 8 * MB } }); + } + expect(url.searchParams.get("id")).toBe(id); + if (action === "part") { + const number = Number(url.searchParams.get("part")); + attempts.push(number); + if (number === failPartOnce) { + failPartOnce = 0; + return route.fulfill({ status: failureStatus, json: { error: "This part did not arrive. Retry to continue." } }); + } + chunks.push({ number, body: route.request().postDataBuffer()! }); + return route.fulfill({ json: { part_number: number, etag: tag(number) } }); + } + if (action === "complete") { + completes += 1; + expect(route.request().postDataJSON()).toEqual({ parts: [1, 2, 3].map((part_number) => ({ part_number, etag: tag(part_number) })) }); + if (loseComplete) { + loseComplete = false; + return route.fulfill({ status: 503, json: { error: "Completion response lost; Retry." } }); + } + await new Promise((resolve) => { finish = resolve; }); + return route.fulfill({ json: { url: `${new URL(route.request().url()).origin}/m/1234/${id}.webm`, type: "video/webm", bytes: 16 * MB + 17 } }).catch(() => {}); + } + if (action === "abort") { + aborted = true; + return route.fulfill({ status: 204 }); + } + throw new Error(`Unexpected multipart action ${action}`); + }); + return { chunks, attempts, createIds, completes: () => completes, creates: () => creates, aborted: () => aborted, readyToFinish: () => !!finish, finish: () => finish?.() }; +} + +for (const width of [1280, 390]) { + test(`a large video uploads in chunks, resumes the failed chunk, and waits for completion at ${width}`, async ({ page }) => { + await page.setViewportSize({ width, height: 844 }); + await ready(page, new Media()); + const media = await chunkedMedia(page, 2); + await page.getByRole("button", { name: "Write a cast" }).click(); + const dialog = page.getByRole("dialog", { name: "New cast" }); + const bytes = Buffer.alloc(16 * MB + 17, 0x5a); + await attach(page, dialog, [{ name: "large.webm", mimeType: "video/webm", buffer: bytes }]); + const error = dialog.getByRole("alert").filter({ hasText: "large.webm" }); + await expect(error).toContainText("Retry to continue"); + expect(media.attempts).toEqual([1, 2]); + await error.getByRole("button", { name: "Retry" }).click(); + await expect.poll(media.readyToFinish).toBe(true); + await expect(dialog.getByRole("status").filter({ hasText: "Finishing upload" })).toBeVisible(); + await expect(dialog.locator(".compose-post")).toBeDisabled(); + expect(media.creates()).toBe(1); + expect(media.attempts).toEqual([1, 2, 2, 3]); + expect(media.chunks.map((part) => part.body.length)).toEqual([8 * MB, 8 * MB, 17]); + expect(Buffer.concat(media.chunks.map((part) => part.body)).equals(bytes)).toBe(true); + media.finish(); + await expect(dialog.getByRole("status").filter({ hasText: "Finishing upload" })).toHaveCount(0); + await expect(tiles(dialog).first()).not.toHaveClass(/sending|failed/); + await expect(dialog.getByRole("button", { name: "Cast", exact: true })).toBeEnabled(); + }); +} + +test("removing a large video cancels its multipart reservation", async ({ page }) => { + await ready(page, new Media()); + const media = await chunkedMedia(page, 2); + await page.getByRole("button", { name: "Write a cast" }).click(); + const dialog = page.getByRole("dialog", { name: "New cast" }); + await attach(page, dialog, [{ name: "large.webm", mimeType: "video/webm", buffer: Buffer.alloc(16 * MB + 17, 0x5a) }]); + await expect(dialog.getByRole("alert")).toContainText("Retry to continue"); + await dialog.getByRole("button", { name: "Remove large.webm", exact: true }).click(); + await expect.poll(media.aborted).toBe(true); + await expect(tiles(dialog)).toHaveCount(0); + expect(media.creates()).toBe(1); +}); + +test("a video above 50 MB reads metadata only before lossless HLS preparation", async ({ page }) => { + const media = new Media(); + await ready(page, media); + await page.evaluate(() => { + const original = Blob.prototype.arrayBuffer; + (window as unknown as { metadataReads: number[] }).metadataReads = []; + Blob.prototype.arrayBuffer = function () { + (window as unknown as { metadataReads: number[] }).metadataReads.push(this.size); + if (this.size > 8 * 1024 * 1024) throw new Error("Video payload must not be read into Wasm"); + return original.call(this); + }; + }); + const original = readFileSync(path.join(__dirname, "../crates/allthing-core/tests/fixtures/video-h264-aac.mp4")); + const padding = Buffer.alloc(51 * MB); + padding.writeUInt32BE(padding.length, 0); + padding.write("free", 4, "latin1"); + await page.getByRole("button", { name: "Write a cast" }).click(); + const dialog = page.getByRole("dialog", { name: "New cast" }); + const folder = mkdtempSync(path.join(tmpdir(), "allthing-video-fixture-")); + const file = path.join(folder, "large.mp4"); + writeFileSync(file, Buffer.concat([original, padding])); + try { + const chooser = page.waitForEvent("filechooser"); + await dialog.getByRole("button", { name: "Attach images or videos" }).click(); + await (await chooser).setFiles(file); + await expect.poll(() => media.uploads.length).toBe(1); + expect(media.uploads[0].hls).toMatch(/^1;/); + expect(media.uploads[0].body.length).toBeLessThan(original.length + 8192); + const reads = await page.evaluate(() => (window as unknown as { metadataReads: number[] }).metadataReads); + expect(reads.length).toBeGreaterThan(0); + expect(Math.max(...reads)).toBeLessThan(8 * MB); + } finally { + rmSync(folder, { recursive: true, force: true }); + } +}); + + +test("Retry starts a fresh session after the server reclaimed an expired video", async ({ page }) => { + await ready(page, new Media()); + const media = await chunkedMedia(page, 2, 404); + await page.getByRole("button", { name: "Write a cast" }).click(); + const dialog = page.getByRole("dialog", { name: "New cast" }); + await attach(page, dialog, [{ name: "large.webm", mimeType: "video/webm", buffer: Buffer.alloc(16 * MB + 17, 0x5a) }]); + const error = dialog.getByRole("alert").filter({ hasText: "large.webm" }); + await expect(error).toContainText("Retry to continue"); + await error.getByRole("button", { name: "Retry" }).click(); + await expect.poll(media.readyToFinish).toBe(true); + expect(media.creates()).toBe(2); + expect(media.attempts).toEqual([1, 2, 1, 2, 3]); + media.finish(); + await expect(dialog.locator(".compose-post")).toBeEnabled(); +}); + + +for (const lost of ["create", "complete"]) { + test(`a lost ${lost} response retries the same video session without repeating acknowledged parts`, async ({ page }) => { + await ready(page, new Media()); + const media = await chunkedMedia(page, 0, 503, lost === "create", lost === "complete"); + await page.getByRole("button", { name: "Write a cast" }).click(); + const dialog = page.getByRole("dialog", { name: "New cast" }); + await attach(page, dialog, [{ name: "large.webm", mimeType: "video/webm", buffer: Buffer.alloc(16 * MB + 17, 0x5a) }]); + const error = dialog.getByRole("alert").filter({ hasText: "large.webm" }); + await expect(error).toContainText("response lost"); + await error.getByRole("button", { name: "Retry" }).click(); + await expect.poll(media.readyToFinish).toBe(true); + expect(new Set(media.createIds).size).toBe(1); + expect(media.creates()).toBe(lost === "create" ? 2 : 1); + expect(media.completes()).toBe(lost === "complete" ? 2 : 1); + expect(media.attempts).toEqual([1, 2, 3]); + media.finish(); + await expect(dialog.locator(".compose-post")).toBeEnabled(); + }); +} diff --git a/e2e/media-upload.fixture.spec.ts b/e2e/media-upload.fixture.spec.ts index 3cec7ff2..aff80c78 100644 --- a/e2e/media-upload.fixture.spec.ts +++ b/e2e/media-upload.fixture.spec.ts @@ -31,6 +31,7 @@ const now = () => Math.floor(Date.now() / 1000); let worker: ChildProcess | undefined; let folder = ""; let config = ""; +let lastUploadRequestAt = 0; /** A session cookie as the Worker's sign-in sets it: `..`. */ function session(fid: number) { @@ -112,6 +113,7 @@ async function upload(body: Buffer | string, type: string | null, fid: number | if (fid !== null) headers.cookie = session(fid); if (origin !== null) headers.origin = origin; const res = await fetch(`${ORIGIN}/api/v1/media`, { method: "POST", headers, body }); + lastUploadRequestAt = Date.now(); return { status: res.status, json: await res.json(), headers: res.headers }; } @@ -145,7 +147,7 @@ test("a reader uploads an image and a video, and anyone gets them back byte for const asked = await fetch(`${ORIGIN}/api/v1/media`, { headers: { cookie: session(1234) } }); expect(asked.status).toBe(200); expect(asked.headers.get("cache-control")).toBe("private, max-age=300"); - expect(await asked.json()).toEqual({ max_image_bytes: 10 * MIB, max_video_bytes: 50 * MIB, max_embeds: 2 }); + expect(await asked.json()).toEqual({ max_image_bytes: 10 * MIB, max_video_bytes: 250 * MIB, max_embeds: 2 }); expect((await fetch(`${ORIGIN}/api/v1/media`)).status).toBe(401); // The URL is on the origin the Worker was asked at (wrangler dev presents @@ -183,7 +185,7 @@ test("a reader uploads an image and a video, and anyone gets them back byte for "access-control-allow-origin": "*", }); expect(headers["content-security-policy"]).toMatch(/^default-src 'none'; .*; sandbox$/); - expect(headers.etag).toMatch(/^"[0-9a-f]+"$/); + expect(headers.etag).toMatch(/^"[0-9a-f]+(?:-[0-9]+)?"$/); // HEAD: the same, without the body. const head = await fetch(local(url), { method: "HEAD" }); @@ -262,7 +264,7 @@ test("a video remuxed for HLS is embedded as its playlist, which the Worker writ "access-control-allow-origin": "*", "x-content-type-options": "nosniff", }); - expect(headers.etag).toMatch(/^"[0-9a-f]+-m3u8"$/); + expect(headers.etag).toMatch(/^"[0-9a-f]+(?:-[0-9]+)?-m3u8"$/); expect(headers["accept-ranges"]).toBeUndefined(); const head = await fetch(local(url), { method: "HEAD" }); expect(head.status).toBe(200); @@ -365,7 +367,7 @@ test("uploads are refused clearly: their type, size, session, origin and pace", await refused(upload(image, null, 7), 400, /^Attach a PNG/); await refused(upload(Buffer.alloc(0), "image/png", 7), 400, /^That file is empty$/); await refused(upload(Buffer.alloc(10 * MIB + 1), "image/jpeg", 7), 413, /^Images can be up to 10 MB$/); - await refused(upload(Buffer.alloc(50 * MIB + 1), "video/webm", 7), 413, /^Videos can be up to 50 MB$/); + await refused(upload(Buffer.alloc(50 * MIB + 1), "video/webm", 7), 413, /^Large videos use a chunked upload:/); await refused(upload(image, "image/png", null), 401, /^Sign in to attach images and videos$/); await refused(upload(image, "image/png", 7, "https://elsewhere.example"), 403, /^Uploads must come from Allthing itself$/); await refused(upload(image, "image/png", 7, null), 403, /^Uploads must come from Allthing itself$/); @@ -419,6 +421,172 @@ test("quotas: 60 files a reader a day, 2,000 uploads a day in all, and 9 GiB sto d1(`UPDATE media_total SET bytes = ${bytes} WHERE name = 'all'`); }); +type Part = { part_number: number; etag: string }; +const chunkBytes = 8 * MIB; +const chunkId = (value: number) => value.toString(16).padStart(32, "0"); + +async function videoControl(action: string, fid: number | null, body?: object | Buffer, id?: string, part?: number, origin: string | null = ORIGIN) { + const query = new URLSearchParams({ upload: action }); + if (id) query.set("id", id); + if (part !== undefined) query.set("part", String(part)); + const headers: Record = {}; + if (fid !== null) headers.cookie = session(fid); + if (origin !== null) headers.origin = origin; + headers["content-type"] = Buffer.isBuffer(body) ? "video/mp4" : "application/json"; + const res = await fetch(`${ORIGIN}/api/v1/media?${query}`, { + method: "POST", headers, body: body === undefined ? undefined : Buffer.isBuffer(body) ? body : JSON.stringify(body), + }); + lastUploadRequestAt = Date.now(); + return { status: res.status, json: res.status === 204 ? null : await res.json(), headers: res.headers }; +} + +// Real local R2/D1, not an intercepted upload: request chunks are streamed, +// then assembled, served and accounted exactly once. +test("a large video uses bounded parts, resumes a lost response, and completes exactly once", async ({}, info) => { + const fid = 501; + const id = chunkId(501); + const bytes = Buffer.alloc(chunkBytes + 43, 0x62); + bytes.fill(0x31, chunkBytes - 12, chunkBytes + 12); + const asked = { id, type: "video/mp4", bytes: bytes.length, hls: `1;64x36;avc1.64000a;100;4000:${bytes.length - 100}` }; + const created = await videoControl("create", fid, asked); + expect(created.status, JSON.stringify(created.json)).toBe(200); + expect(created.json).toEqual({ id, part_bytes: chunkBytes }); + // Losing the create response doesn't reserve or create another upload. + expect((await videoControl("create", fid, asked)).json).toEqual(created.json); + expect(d1(`SELECT COUNT(*) AS n FROM media_uploads WHERE fid = ${fid} AND state <> 'done'`)).toEqual([{ n: 1 }]); + const first = await videoControl("part", fid, bytes.subarray(0, chunkBytes), id, 1); + expect(first.status, JSON.stringify(first.json)).toBe(200); + const day = Math.floor(now() / 86_400); + const before = d1(`SELECT operations FROM media_operations WHERE day = ${day}`); + const replay = await videoControl("part", fid, bytes.subarray(0, chunkBytes), id, 1); + expect(replay.json).toEqual(first.json); + expect(d1(`SELECT operations FROM media_operations WHERE day = ${day}`)).toEqual(before); + const last = await videoControl("part", fid, bytes.subarray(chunkBytes), id, 2); + expect(last.status, JSON.stringify(last.json)).toBe(200); + const parts: Part[] = [first.json, last.json]; + await info.attach("multipart-manifest-diagnostic", { body: JSON.stringify({ parts, stored: d1(`SELECT bytes,parts FROM media_uploads WHERE fid = ${fid}`) }), contentType: "application/json" }); + const complete = await videoControl("complete", fid, { parts }, id); + expect(complete.status, JSON.stringify(complete.json)).toBe(200); + expect(complete.json.url).toMatch(/\.m3u8$/); + expect((await videoControl("complete", fid, { parts }, id)).json).toEqual(complete.json); + expect(d1(`SELECT COUNT(*) AS n, SUM(bytes) AS bytes FROM media WHERE fid = ${fid}`)).toEqual([{ n: 1, bytes: bytes.length }]); + const mp4 = local(complete.json.url.replace(/\.m3u8$/, ".mp4")); + expect(Buffer.from(await (await fetch(mp4)).arrayBuffer()).equals(bytes)).toBe(true); + const range = await fetch(mp4, { headers: { range: `bytes=${chunkBytes - 12}-${chunkBytes + 11}` } }); + expect(range.status).toBe(206); + expect(Buffer.from(await range.arrayBuffer()).equals(bytes.subarray(chunkBytes - 12, chunkBytes + 12))).toBe(true); + expect(await (await fetch(local(complete.json.url))).text()).toContain('CODECS="avc1.64000a"'); + // Removing a completed draft attachment preserves its public URL. + expect((await videoControl("abort", fid, undefined, id)).status).toBe(204); + expect((await fetch(mp4, { method: "HEAD" })).status).toBe(200); + // Once short-lived replay state/tombstones expire, its public immutable + // key still cannot be reused to overwrite a cast's existing attachment. + d1(`DELETE FROM media_uploads WHERE id = '${id}'; DELETE FROM media_upload_cancellations WHERE id = '${id}'`); + expect((await videoControl("create", fid, asked)).status).toBe(409); + expect(Buffer.from(await (await fetch(mp4)).arrayBuffer()).equals(bytes)).toBe(true); +}); + +test("multipart control verifies ownership, descriptors, exact part lengths and manifests", async () => { + const fid = 502, id = chunkId(502), asked = { id, type: "video/mp4", bytes: chunkBytes + 9 }; + expect((await videoControl("create", null, asked)).status).toBe(401); + expect((await videoControl("create", fid, asked, undefined, undefined, "https://elsewhere.example")).status).toBe(403); + expect((await videoControl("create", fid, { ...asked, type: "image/png" })).status).toBe(400); + expect((await videoControl("create", fid, { ...asked, bytes: 250 * MIB + 1 })).status).toBe(413); + expect((await videoControl("create", fid, { ...asked, hls: "nonsense" })).status).toBe(400); + expect((await videoControl("create", fid, { ...asked, id: "../bad" })).status).toBe(400); + expect((await videoControl("create", fid, asked)).status).toBe(200); + expect((await videoControl("create", fid, { ...asked, bytes: asked.bytes + 1 })).status).toBe(409); + expect((await videoControl("part", fid + 10, Buffer.alloc(chunkBytes), id, 1)).status).toBe(404); + expect((await videoControl("part", fid, Buffer.alloc(9), id, 2)).status).toBe(400); + expect((await videoControl("part", fid, Buffer.alloc(chunkBytes - 1), id, 1)).status).toBe(400); + expect((await videoControl("part", fid, Buffer.alloc(1), id, 0)).status).toBe(400); + expect((await videoControl("complete", fid, { parts: [] }, id)).status).toBe(400); + const first = await videoControl("part", fid, Buffer.alloc(chunkBytes), id, 1); + expect(first.status, JSON.stringify(first.json)).toBe(200); + expect((await videoControl("complete", fid, { parts: [{ part_number: 1, etag: "invented" }] }, id)).status).toBe(400); + expect((await videoControl("abort", fid + 10, undefined, id)).status).toBe(204); + expect(d1(`SELECT COUNT(*) AS n FROM media_uploads WHERE id = '${id}' AND fid = ${fid}`)).toEqual([{ n: 1 }]); + expect((await videoControl("abort", fid, undefined, id)).status).toBe(204); + expect(d1(`SELECT COUNT(*) AS n FROM media_uploads WHERE id = '${id}'`)).toEqual([{ n: 0 }]); + expect((await videoControl("abort", fid, undefined, id)).status).toBe(204); + expect((await videoControl("create", fid, asked)).status).toBe(410); +}); + +test("pending videos reserve quotas atomically, including legacy uploads, until R2 is cancelled", async () => { + const fid = 503; + const a = { id: chunkId(5031), type: "video/mp4", bytes: 200 * MIB }; + const b = { ...a, id: chunkId(5032) }; + const results = await Promise.all([videoControl("create", fid, a), videoControl("create", fid, b)]); + expect(results.map(r => r.status).sort()).toEqual([200, 507]); + expect(d1(`SELECT SUM(bytes) AS bytes FROM media_uploads WHERE fid = ${fid} AND state <> 'done'`)).toEqual([{ bytes: 200 * MIB }]); + // An expired session still reserves its bytes while it has R2 parts. + const id = results[0].status === 200 ? a.id : b.id; + d1(`UPDATE media_uploads SET expires_at = ${now() - 1} WHERE id = '${id}'`); + expect((await videoControl("part", fid, Buffer.alloc(chunkBytes), id, 1)).status).toBe(410); + expect((await videoControl("create", fid, { ...a, id: chunkId(5033), bytes: 101 * MIB })).status).toBe(507); + expect((await videoControl("abort", fid, undefined, id)).status).toBe(204); + const next = { ...a, id: chunkId(5034), bytes: 250 * MIB }; + expect((await videoControl("create", fid, next)).status).toBe(200); + expect((await upload(Buffer.alloc(50 * MIB), "video/mp4", fid)).status).toBe(200); + expect((await upload(png(1), "image/png", fid)).status).toBe(507); + expect((await videoControl("abort", fid, undefined, next.id)).status).toBe(204); + expect((await upload(png(1), "image/png", fid)).status).toBe(200); +}); + +test("removal before create stays cancelled, and every R2 attempt has a daily work budget", async () => { + const fid = 504, id = chunkId(504), asked = { id, type: "video/mp4", bytes: 250 * MIB }; + expect((await videoControl("abort", fid, undefined, id)).status).toBe(204); + expect((await videoControl("create", fid, asked)).status).toBe(410); + const openId = chunkId(5041); + expect((await videoControl("create", fid, { ...asked, id: openId })).status).toBe(200); + const day = Math.floor(now() / 86_400); + const [{ operations }] = d1(`SELECT operations FROM media_operations WHERE day = ${day}`); + d1(`UPDATE media_operations SET operations = 1000 WHERE day = ${day}`); + try { + expect((await videoControl("part", fid, Buffer.alloc(chunkBytes), openId, 1)).status).toBe(503); + expect((await videoControl("create", fid + 1, { ...asked, id: chunkId(5042) })).status).toBe(503); + expect(d1(`SELECT COUNT(*) AS n FROM media_uploads WHERE fid = ${fid + 1}`)).toEqual([{ n: 0 }]); + // Aborting a known upload is never blocked by exhausted write work. + expect((await videoControl("abort", fid, undefined, openId)).status).toBe(204); + expect(d1(`SELECT cancel_requested FROM media_uploads WHERE id = '${openId}'`)).toEqual([{ cancel_requested: 1 }]); + } finally { + d1(`UPDATE media_operations SET operations = ${operations} WHERE day = ${day}`); + } + expect((await videoControl("abort", fid, undefined, openId)).status).toBe(204); + expect(d1(`SELECT COUNT(*) AS n FROM media_uploads WHERE id = '${openId}'`)).toEqual([{ n: 0 }]); +}); + +test("cancelling an in-flight lease fences completion, and the pending pool is bounded", async () => { + const fid = 506, id = chunkId(506), body = Buffer.alloc(chunkBytes); + expect((await videoControl("create", fid, { id, type: "video/mp4", bytes: body.length })).status).toBe(200); + const part = await videoControl("part", fid, body, id, 1); + expect(part.status, JSON.stringify(part.json)).toBe(200); + // A request still receiving bytes holds a lease. Removal marks it now, + // while the lease keeps R2 cleanup from racing its active I/O. + d1(`UPDATE media_uploads SET lock_id = 'held-request', lock_until = ${now() + 600} WHERE id = '${id}'`); + expect((await videoControl("abort", fid, undefined, id)).status).toBe(409); + expect(d1(`SELECT cancel_requested FROM media_uploads WHERE id = '${id}'`)).toEqual([{ cancel_requested: 1 }]); + expect((await videoControl("complete", fid, { parts: [part.json] }, id)).status).toBe(410); + expect((await videoControl("part", fid, body, id, 1)).status).toBe(410); + d1(`UPDATE media_uploads SET lock_until = 0 WHERE id = '${id}'`); + expect((await videoControl("abort", fid, undefined, id)).status).toBe(204); + expect(d1(`SELECT COUNT(*) AS n FROM media_uploads WHERE id = '${id}'`)).toEqual([{ n: 0 }]); + + // Seed only reservations: no R2 object or part is fabricated. Aggregate + // quota reads cannot grow with arbitrarily many tiny pending uploads. + const seed = Array.from({ length: 128 }, (_, i) => { + const name = chunkId(800000 + i); + return `('${name}', '700/${name}.mp4', 700, 1, 'video/mp4', 'single', ${now()}, ${now() + 3600})`; + }); + d1(`INSERT INTO media_uploads (id,key,fid,bytes,type,mode,created_at,expires_at) VALUES ${seed.join(",")}`); + try { + expect((await videoControl("create", 507, { id: chunkId(507), type: "video/mp4", bytes: 1 })).status).toBe(409); + expect(d1("SELECT COUNT(*) AS n FROM media_uploads WHERE state <> 'done'")).toEqual([{ n: 128 }]); + } finally { + d1("DELETE FROM media_uploads WHERE fid = 700"); + } +}); + test("without the R2 binding, uploads and /m/ answer 404", async () => { test.setTimeout(150_000); // A Worker of its own, from wrangler.toml without its R2 block. @@ -448,3 +616,47 @@ test("without the R2 binding, uploads and /m/ answer 404", async () => { if (server.pid) process.kill(-server.pid, "SIGTERM"); } }); + +test("a 250 MiB video completes all 32 parts with bounded buffers and exact accounting", async () => { + // The real API allows 120 requests/minute. Keep this 34-request boundary + // transfer in a fresh window after the ordinary upload/security cases, + // rather than weakening the production limiter to fit a fixture burst. + const rest = Math.max(0, lastUploadRequestAt + 60_250 - Date.now()); + if (rest) await new Promise(resolve => setTimeout(resolve, rest)); + const fid = 508, id = chunkId(508), total = 250 * MIB; + const created = await videoControl("create", fid, { id, type: "video/mp4", bytes: total }); + expect(created.status, JSON.stringify(created.json)).toBe(200); + expect(created.json).toEqual({ id, part_bytes: chunkBytes }); + expect((await videoControl("create", fid, { id: chunkId(5081), type: "video/mp4", bytes: total + 1 })).status).toBe(413); + const reusable = Buffer.alloc(chunkBytes); + const parts: Part[] = []; + for (let part = 1; part <= 32; part += 1) { + reusable.fill(part); + const length = part === 32 ? 2 * MIB : chunkBytes; + const sent = await videoControl("part", fid, reusable.subarray(0, length), id, part); + expect(sent.status, `part${part}: ${JSON.stringify(sent.json)}`).toBe(200); + expect(sent.json.part_number).toBe(part); + parts.push(sent.json); + } + const completed = await videoControl("complete", fid, { parts }, id); + expect(completed.status, JSON.stringify(completed.json)).toBe(200); + expect(completed.json.bytes).toBe(total); + const url = local(completed.json.url); + const head = await fetch(url, { method: "HEAD" }); + expect(head.status).toBe(200); + expect(Number(head.headers.get("content-length"))).toBe(total); + const ranges: [string, number[]][] = [ + ["bytes=0-2", [1, 1, 1]], + [`bytes=${chunkBytes - 2}-${chunkBytes + 2}`, [1, 1, 2, 2, 2]], + [`bytes=${total - 3}-${total - 1}`, [32, 32, 32]], + ]; + for (const [range, expected] of ranges) { + const got = await fetch(url, { headers: { range } }); + expect(got.status).toBe(206); + expect(Array.from(new Uint8Array(await got.arrayBuffer()))).toEqual(expected); + } + expect(d1(`SELECT COUNT(*) AS n, SUM(bytes) AS bytes FROM media WHERE fid = ${fid}`)).toEqual([{ n: 1, bytes: total }]); + expect(d1(`SELECT COUNT(*) AS n FROM media_uploads WHERE fid = ${fid} AND state <> 'done'`)).toEqual([{ n: 0 }]); + expect((await videoControl("complete", fid, { parts }, id)).json).toEqual(completed.json); + expect(d1(`SELECT COUNT(*) AS n FROM media WHERE fid = ${fid}`)).toEqual([{ n: 1 }]); +}); diff --git a/e2e/mention-autocomplete.fixture.spec.ts b/e2e/mention-autocomplete.fixture.spec.ts index 027dcfe3..9a97730a 100644 --- a/e2e/mention-autocomplete.fixture.spec.ts +++ b/e2e/mention-autocomplete.fixture.spec.ts @@ -5,6 +5,7 @@ import { expectDraft } from "./helpers/composer"; import { approvedKey, cast as castOf, signIn } from "./helpers/farcaster"; import { standIn } from "./helpers/node"; import { NOW } from "./fixture-time"; +import { showSeenPosts } from "./helpers/app"; /* * The composer's "@" suggestions (components/mention.rs): typing "@al" @@ -51,9 +52,13 @@ async function ready(page: Page) { : route.fulfill({ status: 400, headers: cors, json: { error: "Failed to get username proof", error_detail: "status: NotFound" } }); }); await page.goto("/"); + await showSeenPosts(page); await expect(page.locator(".cast-card:not(.skeleton)").first()).toBeVisible(); key = await approvedKey(page, me.fid, 3_600_000); await page.reload(); + // The first visit may have recorded the fixture's only cast as seen. + // Recover it through the reader's control, after the real app arrives. + await showSeenPosts(page); await expect(page.locator(".cast-card:not(.skeleton)").first()).toBeVisible(); return { node, searched }; } diff --git a/e2e/profile-facts-room.fixture.spec.ts b/e2e/profile-facts-room.fixture.spec.ts index 55055292..3eef3014 100644 --- a/e2e/profile-facts-room.fixture.spec.ts +++ b/e2e/profile-facts-room.fixture.spec.ts @@ -8,8 +8,9 @@ import { NOW } from "./fixture-time"; * there from the start, and the wallets' block is one height whatever it * says, like a profile card's. An account with no verified wallets, which * the profile itself says, shows the line that says so at once, and keeps - * no room for a chart. Here the vote comes 1.2 s after it's asked for, and - * the wallets 1.5 s; nothing under the header moves when they do. + * no room for a chart. Their responses wait until the initial loading + * geometry is measured, then retain the vote's 1.2 s and wallets' 1.5 s + * delays; nothing under the header moves when they come. */ test.skip(Boolean(process.env.E2E_PAGES), "uses the Worker API contract"); @@ -36,26 +37,35 @@ const chains = [ ]; const later = (ms: number) => new Promise((done) => setTimeout(done, ms)); -/** The profile at once, its vote 1.2 s and its wallets 1.5 s after they're asked for. */ +/** The profile at once; release the delayed facts after measuring its loading geometry. */ async function serve(page: Page, user: { eth_addresses?: string[] }, vote: "stay" | "fails" = "stay") { const asked: string[] = []; + let release!: () => void; + const pending = new Promise((resolve) => { release = resolve; }); + page.once("close", release); + const afterRelease = async (ms: number) => { + await pending; + if (page.isClosed()) return false; + await later(ms); + return !page.isClosed(); + }; await page.route("**/api/v1/**", (route) => route.fulfill({ status: 404, json: { error: "Fixture endpoint" } })); const pool = Array.from({ length: 8 }, (_, i) => ({ hash: hex(i + 1), author: base, text: `Cast ${i} by Alice`, ts: ts - i * 60 })); await page.route("**/api/v1/user?*", (route) => route.fulfill({ json: { user, pool, notes: {}, meta } })); await page.route("**/api/v1/vote?*", async (route) => { asked.push("vote"); - await later(1200); + if (!await afterRelease(1200)) return; return vote === "stay" ? route.fulfill({ json: { fid: 42, onchain: "stay", meta } }) : route.fulfill({ status: 503, json: { error: "Upstream unavailable" } }); }); await page.route("**/api/v1/wallets?*", async (route) => { asked.push("wallets"); - await later(1500); + if (!await afterRelease(1500)) return; // What the Worker would say: the account's addresses, and what they did. if (!user.eth_addresses) return route.fulfill({ json: { fid: 42, addresses: [], chains: [], total: 0, sources: [], fetched_at: Date.now() } }); const addresses = [...wallets.eth_addresses.map((address) => ({ address, kind: "evm" })), { address: wallets.sol_addresses[0], kind: "solana" }]; return route.fulfill({ json: { fid: 42, addresses, chains, total: 1000, sources: [], fetched_at: Date.now() } }); }); - return asked; + return { asked, release }; } /** Records layout shifts from the start, and whether each moved only the footer. */ @@ -109,7 +119,7 @@ for (const width of [1280, 390]) { }); test("an account with wallets keeps its header's height as its vote and wallet activity come", async ({ page }) => { - await serve(page, { ...base, ...wallets }); + const { release } = await serve(page, { ...base, ...wallets }); await page.goto("/u/alice"); const header = page.locator("section.profile"); await expect(page.locator(".cast-list .cast-card[data-hash]").first()).toBeVisible(); @@ -119,6 +129,7 @@ for (const width of [1280, 390]) { await expect(header.locator(".share-bar.loading")).toHaveCount(1); const before = await below(page); const from = await shiftCount(page); + release(); await expect(header.locator(".vote-yes")).toBeVisible({ timeout: 10_000 }); await expect(header.locator(".share-bar:not(.loading)")).toBeVisible({ timeout: 10_000 }); await settled(page); @@ -145,7 +156,7 @@ for (const width of [1280, 390]) { }); test("an account with no verified wallets says so at once, and keeps no room for a chart", async ({ page }) => { - const asked = await serve(page, base); + const { asked, release } = await serve(page, base); await page.goto("/u/alice"); await expect(page.locator("section.profile .profile-name")).toHaveText("Alice"); const section = page.getByRole("region", { name: "Wallet activity by chain", exact: true }); @@ -157,6 +168,7 @@ for (const width of [1280, 390]) { expect(box.height).toBeLessThanOrEqual(24); const before = await below(page); const from = await shiftCount(page); + release(); await expect(page.locator("section.profile .vote-yes")).toBeVisible({ timeout: 10_000 }); await settled(page); expect(await below(page)).toEqual(before); @@ -166,12 +178,13 @@ for (const width of [1280, 390]) { }); test("a vote that can't be read keeps its row, and says so", async ({ page }) => { - await serve(page, { ...base, ...wallets }, "fails"); + const { release } = await serve(page, { ...base, ...wallets }, "fails"); await page.goto("/u/alice"); await expect(page.locator(".cast-list .cast-card[data-hash]").first()).toBeVisible(); await settled(page); const before = await below(page); const vote = page.locator("section.profile .vote-fact"); + release(); await expect(vote.locator(".vote-unknown")).toHaveText("Unknown", { timeout: 10_000 }); await expect(page.locator("section.profile .share-bar:not(.loading)")).toBeVisible({ timeout: 10_000 }); await settled(page); diff --git a/migrations/0010_media_uploads.sql b/migrations/0010_media_uploads.sql new file mode 100644 index 00000000..4ef521b1 --- /dev/null +++ b/migrations/0010_media_uploads.sql @@ -0,0 +1,34 @@ +-- SPDX-License-Identifier: AGPL-3.0-or-later +-- Incomplete files reserve their full eventual size before R2 accepts bytes. +-- Expiry only makes a row eligible for cleanup: it NEVER releases storage. +CREATE TABLE IF NOT EXISTS media_uploads ( + id TEXT PRIMARY KEY, + key TEXT NOT NULL UNIQUE, + fid INTEGER NOT NULL, + bytes INTEGER NOT NULL, + type TEXT NOT NULL, + hls TEXT, + upload_id TEXT, + mode TEXT NOT NULL, + state TEXT NOT NULL DEFAULT 'active', + parts TEXT NOT NULL DEFAULT '[]', + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL, + lock_id TEXT NOT NULL DEFAULT '', + lock_until INTEGER NOT NULL DEFAULT 0, + cancel_requested INTEGER NOT NULL DEFAULT 0 +) WITHOUT ROWID; +CREATE INDEX IF NOT EXISTS media_uploads_by_reader ON media_uploads (fid, state, created_at); +CREATE INDEX IF NOT EXISTS media_uploads_expiry ON media_uploads (state, expires_at, lock_until); +-- Charged before every R2 Class A attempt, even one that fails or is retried. +CREATE TABLE IF NOT EXISTS media_operations ( + day INTEGER PRIMARY KEY, + operations INTEGER NOT NULL +); +-- A removal that beats its create request must still cancel that request. +CREATE TABLE IF NOT EXISTS media_upload_cancellations ( + id TEXT NOT NULL, + fid INTEGER NOT NULL, + expires_at INTEGER NOT NULL, + PRIMARY KEY (id, fid) +) WITHOUT ROWID; diff --git a/style/allthing.css b/style/allthing.css index aecd2612..14b15308 100644 --- a/style/allthing.css +++ b/style/allthing.css @@ -6616,6 +6616,20 @@ p.jumbo .emoji img { transition: width 0.2s ease; } +/* Video preparation and server completion remain visible after upload. */ +.compose-processing { + position: absolute; + left: 6px; + right: 6px; + bottom: 6px; + padding: 5px; + border-radius: 5px; + background: rgba(20, 24, 38, 0.8); + color: #fff; + font: 600 11px/1.3 var(--font-ui); + text-align: center; +} + /* One that didn't go: why is said under the tiles. */ .compose-tile-failed { position: absolute;