feat(wallet,content,seed): Fedimint dual-ecash, paid content streaming, seed ceremony
- Fedimint ecash alongside Cashu: fedimint-clientd (fmcd) HTTP bridge, fedimint_client, fedimint RPC, wallet wiring - Paid peer content: content invoices + streaming content server + content RPCs - Seed-phrase ceremony/reveal RPCs and CLI ceremony tool - LND wallet, mesh status/messaging, app-stack (netbird HTTPS), and decoupled-update wiring; Fedimint Client core app in catalog Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
c10f2ac22e
commit
bd567cd165
@@ -198,6 +198,7 @@ pub async fn serve_content(
|
||||
data_dir: &Path,
|
||||
id: &str,
|
||||
payment_token: Option<&str>,
|
||||
invoice_hash: Option<&str>,
|
||||
peer_did: Option<&str>,
|
||||
range: Option<ByteRange>,
|
||||
) -> Result<ServeResult> {
|
||||
@@ -236,12 +237,24 @@ pub async fn serve_content(
|
||||
// Check access control
|
||||
match &item.access {
|
||||
AccessControl::Paid { price_sats } => {
|
||||
// Verify payment token
|
||||
// Two ways to satisfy payment:
|
||||
// (a) a valid ecash token (the local-wallet fast path), or
|
||||
// (b) a Lightning-invoice payment hash this node issued and has
|
||||
// since confirmed settled (the "pay from any wallet" path, #46).
|
||||
let mut authorized = false;
|
||||
if let Some(token) = payment_token {
|
||||
if !verify_payment_token(data_dir, token, *price_sats).await {
|
||||
return Ok(ServeResult::PaymentRequired(*price_sats));
|
||||
if verify_payment_token(data_dir, token, *price_sats).await {
|
||||
authorized = true;
|
||||
}
|
||||
} else {
|
||||
}
|
||||
if !authorized {
|
||||
if let Some(hash) = invoice_hash {
|
||||
if crate::content_invoice::is_paid_for(hash, id).await {
|
||||
authorized = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if !authorized {
|
||||
return Ok(ServeResult::PaymentRequired(*price_sats));
|
||||
}
|
||||
}
|
||||
@@ -317,10 +330,63 @@ pub enum PreviewResult {
|
||||
BlurPreview(Vec<u8>, String),
|
||||
/// Truncated preview for paid video (first ~2% of bytes).
|
||||
TruncatedPreview(Vec<u8>, String, u64),
|
||||
/// A preview can't be produced for this media without re-encoding (e.g. a
|
||||
/// non-faststart MP4 whose moov atom is at the end, so a byte prefix won't
|
||||
/// play). The UI shows its "preview unavailable" overlay instead of a
|
||||
/// broken player. (#35)
|
||||
PreviewUnavailable,
|
||||
/// Content not found.
|
||||
NotFound,
|
||||
}
|
||||
|
||||
/// Scan an MP4's top-level boxes and report whether `moov` appears before
|
||||
/// `mdat` ("faststart"). Returns `Some(true)` if faststart (a byte prefix is
|
||||
/// playable), `Some(false)` if the media data precedes the index (a prefix
|
||||
/// will NOT play), or `None` if neither box is found / the file isn't parseable
|
||||
/// as ISO-BMFF (caller falls back to the legacy prefix behavior).
|
||||
async fn mp4_is_faststart(path: &std::path::Path) -> Option<bool> {
|
||||
use tokio::io::{AsyncReadExt, AsyncSeekExt, SeekFrom};
|
||||
let mut f = tokio::fs::File::open(path).await.ok()?;
|
||||
let file_len = f.metadata().await.ok()?.len();
|
||||
let mut pos: u64 = 0;
|
||||
// Bound the walk so a malformed file can't spin forever.
|
||||
for _ in 0..1024 {
|
||||
if pos.saturating_add(8) > file_len {
|
||||
return None;
|
||||
}
|
||||
f.seek(SeekFrom::Start(pos)).await.ok()?;
|
||||
let mut hdr = [0u8; 8];
|
||||
if f.read_exact(&mut hdr).await.is_err() {
|
||||
return None;
|
||||
}
|
||||
let mut size = u32::from_be_bytes([hdr[0], hdr[1], hdr[2], hdr[3]]) as u64;
|
||||
let btype = &hdr[4..8];
|
||||
let mut header_len = 8u64;
|
||||
if size == 1 {
|
||||
// 64-bit extended size.
|
||||
let mut ext = [0u8; 8];
|
||||
if f.read_exact(&mut ext).await.is_err() {
|
||||
return None;
|
||||
}
|
||||
size = u64::from_be_bytes(ext);
|
||||
header_len = 16;
|
||||
} else if size == 0 {
|
||||
// Box runs to EOF — it's the last one.
|
||||
size = file_len.saturating_sub(pos);
|
||||
}
|
||||
match btype {
|
||||
b"moov" => return Some(true), // index before media → faststart
|
||||
b"mdat" => return Some(false), // media before index → not faststart
|
||||
_ => {}
|
||||
}
|
||||
if size < header_len {
|
||||
return None; // malformed
|
||||
}
|
||||
pos = pos.checked_add(size)?;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Serve a preview of content by ID. For paid content, returns degraded previews:
|
||||
/// - Images: full file with X-Content-Preview: blur (frontend applies CSS blur)
|
||||
/// - Videos: first 2% of file bytes (minimum 512KB for codec headers)
|
||||
@@ -358,6 +424,26 @@ pub async fn serve_content_preview(data_dir: &Path, id: &str) -> Result<PreviewR
|
||||
);
|
||||
Ok(PreviewResult::BlurPreview(bytes, item.mime_type.clone()))
|
||||
} else if mime.starts_with("video/") || mime.starts_with("audio/") {
|
||||
// A byte-prefix preview only plays if the container's index is at
|
||||
// the front. For MP4/MOV that means the `moov` atom must precede
|
||||
// `mdat` (faststart). Non-faststart files have moov at the end, so
|
||||
// a 10% prefix is an unplayable truncated MP4 (#35) — report it as
|
||||
// unavailable rather than streaming bytes that hang the player.
|
||||
let is_isobmff = mime == "video/mp4"
|
||||
|| mime == "video/quicktime"
|
||||
|| matches!(
|
||||
file_path.extension().and_then(|e| e.to_str()),
|
||||
Some("mp4") | Some("m4v") | Some("mov") | Some("m4a")
|
||||
);
|
||||
if is_isobmff && mp4_is_faststart(&file_path).await == Some(false) {
|
||||
debug!(
|
||||
"Paid {} '{}' is a non-faststart MP4 (moov after mdat) — no playable prefix preview",
|
||||
if mime.starts_with("video/") { "video" } else { "audio" },
|
||||
id
|
||||
);
|
||||
return Ok(PreviewResult::PreviewUnavailable);
|
||||
}
|
||||
|
||||
// Serve first 10% of video/audio, minimum 512KB for codec headers
|
||||
let metadata = fs::metadata(&file_path)
|
||||
.await
|
||||
@@ -431,3 +517,41 @@ async fn verify_payment_token(data_dir: &Path, token: &str, required_sats: u64)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod faststart_tests {
|
||||
use super::*;
|
||||
|
||||
fn box_hdr(size: u32, typ: &[u8; 4]) -> Vec<u8> {
|
||||
let mut v = size.to_be_bytes().to_vec();
|
||||
v.extend_from_slice(typ);
|
||||
v
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn detects_faststart_moov_before_mdat() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let p = dir.path().join("fast.mp4");
|
||||
let mut data = Vec::new();
|
||||
data.extend(box_hdr(16, b"ftyp"));
|
||||
data.extend([0u8; 8]);
|
||||
data.extend(box_hdr(8, b"moov"));
|
||||
data.extend(box_hdr(8, b"mdat"));
|
||||
tokio::fs::write(&p, &data).await.unwrap();
|
||||
assert_eq!(mp4_is_faststart(&p).await, Some(true));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn detects_non_faststart_mdat_before_moov() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let p = dir.path().join("slow.mp4");
|
||||
let mut data = Vec::new();
|
||||
data.extend(box_hdr(16, b"ftyp"));
|
||||
data.extend([0u8; 8]);
|
||||
data.extend(box_hdr(16, b"mdat"));
|
||||
data.extend([0u8; 8]);
|
||||
data.extend(box_hdr(8, b"moov"));
|
||||
tokio::fs::write(&p, &data).await.unwrap();
|
||||
assert_eq!(mp4_is_faststart(&p).await, Some(false));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user