Generate paid previews server-side and enforce sharing boundaries

This commit is contained in:
archipelago
2026-10-06 05:18:53 -04:00
parent 11f016a944
commit 051dc7e3df
4 changed files with 343 additions and 17 deletions
+58
View File
@@ -137,6 +137,7 @@ dependencies = [
"hyper 0.14.32", "hyper 0.14.32",
"hyper-util", "hyper-util",
"hyper-ws-listener", "hyper-ws-listener",
"image",
"iroh", "iroh",
"iroh-blobs", "iroh-blobs",
"libc", "libc",
@@ -1567,6 +1568,15 @@ version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
[[package]]
name = "fdeflate"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
dependencies = [
"simd-adler32",
]
[[package]] [[package]]
name = "fiat-crypto" name = "fiat-crypto"
version = "0.2.9" version = "0.2.9"
@@ -2503,8 +2513,22 @@ checksum = "e6506c6c10786659413faa717ceebcb8f70731c0a60cbae39795fdf114519c1a"
dependencies = [ dependencies = [
"bytemuck", "bytemuck",
"byteorder-lite", "byteorder-lite",
"image-webp",
"moxcms", "moxcms",
"num-traits", "num-traits",
"png",
"zune-core",
"zune-jpeg",
]
[[package]]
name = "image-webp"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3"
dependencies = [
"byteorder-lite",
"quick-error",
] ]
[[package]] [[package]]
@@ -4142,6 +4166,19 @@ dependencies = [
"time", "time",
] ]
[[package]]
name = "png"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
dependencies = [
"bitflags 2.13.0",
"crc32fast",
"fdeflate",
"flate2",
"miniz_oxide",
]
[[package]] [[package]]
name = "poly1305" name = "poly1305"
version = "0.8.0" version = "0.8.0"
@@ -4366,6 +4403,12 @@ dependencies = [
"image", "image",
] ]
[[package]]
name = "quick-error"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
[[package]] [[package]]
name = "quick-xml" name = "quick-xml"
version = "0.39.4" version = "0.39.4"
@@ -7322,3 +7365,18 @@ dependencies = [
"log", "log",
"simd-adler32", "simd-adler32",
] ]
[[package]]
name = "zune-core"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9"
[[package]]
name = "zune-jpeg"
version = "0.5.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"
dependencies = [
"zune-core",
]
+2
View File
@@ -106,6 +106,8 @@ flate2 = "1.0"
# TOTP 2FA # TOTP 2FA
totp-rs = { version = "5.7", features = ["otpauth", "gen_secret"] } totp-rs = { version = "5.7", features = ["otpauth", "gen_secret"] }
qrcode = "0.14" qrcode = "0.14"
# Paid image previews must be degraded on the server, never by browser CSS.
image = { version = "0.25.9", default-features = false, features = ["jpeg", "png", "webp"] }
data-encoding = "2.6" data-encoding = "2.6"
zeroize = { version = "1.8.2", features = ["derive"] } zeroize = { version = "1.8.2", features = ["derive"] }
+261 -17
View File
@@ -544,11 +544,11 @@ async fn read_filebrowser_via_userns(data_dir: &Path, path: &Path) -> Result<Vec
/// Result of attempting to serve a preview. /// Result of attempting to serve a preview.
pub enum PreviewResult { pub enum PreviewResult {
/// Full content (free/peers-only items — redirect to normal serve). /// Full publicly shared free content.
FullContent(Vec<u8>, String), FullContent(Vec<u8>, String),
/// Blurred preview for paid image (full bytes, frontend applies blur). /// Small, server-blurred JPEG for a paid image; never the original bytes.
BlurPreview(Vec<u8>, String), BlurPreview(Vec<u8>, String),
/// Truncated preview for paid video (first ~2% of bytes). /// Bounded preview for paid video/audio (at most 10% of bytes).
TruncatedPreview(Vec<u8>, String, u64), TruncatedPreview(Vec<u8>, String, u64),
/// A preview can't be produced for this media without re-encoding (e.g. a /// 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 /// non-faststart MP4 whose moov atom is at the end, so a byte prefix won't
@@ -612,6 +612,53 @@ async fn mp4_is_faststart(path: &std::path::Path) -> Option<bool> {
/// - Videos: first 2% of file bytes (minimum 512KB for codec headers) /// - Videos: first 2% of file bytes (minimum 512KB for codec headers)
/// - Other: not available /// - Other: not available
/// For free/peers-only content, returns the full file. /// For free/peers-only content, returns the full file.
/// Decode only bounded raster inputs, discard original metadata, then reduce
/// and blur pixels before encoding a new image. Browser styling is not a gate.
async fn blurred_image_preview(path: PathBuf) -> Result<Vec<u8>> {
static WORKERS: tokio::sync::Semaphore = tokio::sync::Semaphore::const_new(2);
let permit = WORKERS.try_acquire().context("Preview workers busy")?;
tokio::task::spawn_blocking(move || {
use std::io::{Cursor, Read};
use std::os::unix::fs::OpenOptionsExt;
let _permit = permit;
const MAX_INPUT: u64 = 16 * 1024 * 1024;
let file = std::fs::OpenOptions::new()
.read(true)
.custom_flags(libc::O_NONBLOCK)
.open(path)?;
let meta = file.metadata()?;
anyhow::ensure!(
meta.is_file() && meta.len() <= MAX_INPUT,
"Invalid preview source"
);
let mut encoded = Vec::new();
file.take(MAX_INPUT + 1).read_to_end(&mut encoded)?;
anyhow::ensure!(
encoded.len() as u64 <= MAX_INPUT,
"Preview source grew too large"
);
let mut reader = image::ImageReader::new(Cursor::new(encoded)).with_guessed_format()?;
anyhow::ensure!(
matches!(
reader.format(),
Some(image::ImageFormat::Jpeg | image::ImageFormat::Png | image::ImageFormat::WebP)
),
"Unsupported preview image"
);
let mut limits = image::Limits::default();
limits.max_image_width = Some(4096);
limits.max_image_height = Some(4096);
limits.max_alloc = Some(32 * 1024 * 1024);
reader.limits(limits);
let reduced = reader.decode()?.thumbnail(160, 160).blur(8.0).to_rgb8();
let mut output = Cursor::new(Vec::new());
image::DynamicImage::ImageRgb8(reduced).write_to(&mut output, image::ImageFormat::Jpeg)?;
Ok(output.into_inner())
})
.await
.context("Preview worker failed")?
}
pub async fn serve_content_preview(data_dir: &Path, id: &str) -> Result<PreviewResult> { pub async fn serve_content_preview(data_dir: &Path, id: &str) -> Result<PreviewResult> {
let catalog = load_catalog(data_dir).await?; let catalog = load_catalog(data_dir).await?;
let item = match catalog.items.iter().find(|i| i.id == id) { let item = match catalog.items.iter().find(|i| i.id == id) {
@@ -619,8 +666,11 @@ pub async fn serve_content_preview(data_dir: &Path, id: &str) -> Result<PreviewR
None => return Ok(PreviewResult::NotFound), None => return Ok(PreviewResult::NotFound),
}; };
// Check availability — don't preview hidden items // This endpoint is anonymous. It must not become an alternate download
if matches!(item.availability, Availability::Nobody) { // route around peer-only or specific-recipient access checks.
if !matches!(item.availability, Availability::AllPeers)
|| matches!(item.access, AccessControl::PeersOnly)
{
return Ok(PreviewResult::NotFound); return Ok(PreviewResult::NotFound);
} }
@@ -633,16 +683,10 @@ pub async fn serve_content_preview(data_dir: &Path, id: &str) -> Result<PreviewR
AccessControl::Paid { .. } => { AccessControl::Paid { .. } => {
let mime = &item.mime_type; let mime = &item.mime_type;
if mime.starts_with("image/") { if mime.starts_with("image/") {
// Serve full image — frontend applies CSS blur match blurred_image_preview(file_path).await {
let bytes = fs::read(&file_path) Ok(bytes) => Ok(PreviewResult::BlurPreview(bytes, "image/jpeg".into())),
.await Err(_) => Ok(PreviewResult::PreviewUnavailable),
.context("Failed to read preview file")?; }
debug!(
"Serving blur preview for paid image '{}' ({} bytes)",
id,
bytes.len()
);
Ok(PreviewResult::BlurPreview(bytes, item.mime_type.clone()))
} else if mime.starts_with("video/") || mime.starts_with("audio/") { } else if mime.starts_with("video/") || mime.starts_with("audio/") {
// A byte-prefix preview only plays if the container's index is at // 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 // the front. For MP4/MOV that means the `moov` atom must precede
@@ -664,12 +708,16 @@ pub async fn serve_content_preview(data_dir: &Path, id: &str) -> Result<PreviewR
return Ok(PreviewResult::PreviewUnavailable); return Ok(PreviewResult::PreviewUnavailable);
} }
// Serve first 10% of video/audio, minimum 512KB for codec headers // Never return the whole paid file just to reach a minimum
// header size. Bound allocation even for very large videos.
let metadata = fs::metadata(&file_path) let metadata = fs::metadata(&file_path)
.await .await
.context("Failed to read file metadata")?; .context("Failed to read file metadata")?;
let total_size = metadata.len(); let total_size = metadata.len();
let preview_bytes = ((total_size * 10) / 100).max(512 * 1024).min(total_size); let preview_bytes = (total_size / 10).min(8 * 1024 * 1024);
if preview_bytes == 0 {
return Ok(PreviewResult::PreviewUnavailable);
}
use tokio::io::AsyncReadExt; use tokio::io::AsyncReadExt;
let mut file = tokio::fs::File::open(&file_path) let mut file = tokio::fs::File::open(&file_path)
@@ -1160,3 +1208,199 @@ mod paid_read_order_tests {
); );
} }
} }
#[cfg(test)]
mod preview_boundary_tests {
use super::*;
async fn fixture(
bytes: &[u8],
mime: &str,
access: AccessControl,
availability: Availability,
) -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap();
fs::create_dir_all(dir.path().join(CONTENT_DIR))
.await
.unwrap();
fs::write(dir.path().join(CONTENT_DIR).join("preview.bin"), bytes)
.await
.unwrap();
save_catalog(
dir.path(),
&ContentCatalog {
items: vec![ContentItem {
id: "preview-test".into(),
filename: "preview.bin".into(),
mime_type: mime.into(),
size_bytes: bytes.len() as u64,
description: String::new(),
added_at: String::new(),
access,
availability,
}],
},
)
.await
.unwrap();
dir
}
fn paid() -> AccessControl {
AccessControl::Paid {
price_sats: 10,
accepted: vec!["ecash".into()],
}
}
#[tokio::test]
async fn anonymous_preview_never_bypasses_restricted_sharing() {
for (access, availability) in [
(AccessControl::Free, Availability::Nobody),
(paid(), Availability::Nobody),
(
AccessControl::Free,
Availability::Specific {
peers: vec!["did:key:allowed".into()],
},
),
(
paid(),
Availability::Specific {
peers: vec!["did:key:allowed".into()],
},
),
(AccessControl::PeersOnly, Availability::AllPeers),
] {
let dir = fixture(b"PRIVATE", "image/png", access, availability).await;
assert!(matches!(
serve_content_preview(dir.path(), "preview-test")
.await
.unwrap(),
PreviewResult::NotFound
));
}
}
#[tokio::test]
async fn paid_image_preview_is_degraded_and_drops_original_metadata() {
use std::io::Cursor;
let source = image::RgbImage::from_fn(640, 320, |x, y| {
let c = if (x / 8 + y / 8) % 2 == 0 { 0 } else { 255 };
image::Rgb([c, c, c])
});
let original = image::DynamicImage::ImageRgb8(source);
let mut encoded = Cursor::new(Vec::new());
original
.write_to(&mut encoded, image::ImageFormat::Png)
.unwrap();
let mut bytes = encoded.into_inner();
const PRIVATE: &[u8] = b"PRIVATE-ORIGINAL-METADATA";
bytes.extend_from_slice(PRIVATE);
let dir = fixture(&bytes, "image/png", paid(), Availability::AllPeers).await;
let PreviewResult::BlurPreview(preview, mime) =
serve_content_preview(dir.path(), "preview-test")
.await
.unwrap()
else {
panic!("No degraded preview")
};
assert_eq!(mime, "image/jpeg");
assert_ne!(preview, bytes);
assert!(!preview.windows(PRIVATE.len()).any(|w| w == PRIVATE));
let decoded = image::load_from_memory(&preview).unwrap().to_rgb8();
assert!(decoded.width() <= 160 && decoded.height() <= 160);
assert!(
decoded.pixels().all(|p| p[0] > 30 && p[0] < 225),
"High-contrast original detail must be blurred"
);
assert_eq!(
fs::read(dir.path().join(CONTENT_DIR).join("preview.bin"))
.await
.unwrap(),
bytes
);
// Malformed/unsupported and oversized inputs never fall back to the paid original.
fs::write(
dir.path().join(CONTENT_DIR).join("preview.bin"),
b"<svg>private source</svg>",
)
.await
.unwrap();
assert!(matches!(
serve_content_preview(dir.path(), "preview-test")
.await
.unwrap(),
PreviewResult::PreviewUnavailable
));
let file = fs::OpenOptions::new()
.write(true)
.open(dir.path().join(CONTENT_DIR).join("preview.bin"))
.await
.unwrap();
file.set_len(17 * 1024 * 1024).await.unwrap();
assert!(matches!(
serve_content_preview(dir.path(), "preview-test")
.await
.unwrap(),
PreviewResult::PreviewUnavailable
));
}
#[tokio::test]
async fn paid_audio_preview_does_not_release_small_files_or_allocate_a_whole_film() {
let dir = fixture(
&vec![42; 1000],
"audio/mpeg",
paid(),
Availability::AllPeers,
)
.await;
let PreviewResult::TruncatedPreview(bytes, _, total) =
serve_content_preview(dir.path(), "preview-test")
.await
.unwrap()
else {
panic!("No audio preview")
};
assert_eq!(total, 1000);
assert_eq!(bytes, vec![42; 100]);
let file = fs::OpenOptions::new()
.write(true)
.open(dir.path().join(CONTENT_DIR).join("preview.bin"))
.await
.unwrap();
file.set_len(100 * 1024 * 1024).await.unwrap();
let PreviewResult::TruncatedPreview(bytes, _, total) =
serve_content_preview(dir.path(), "preview-test")
.await
.unwrap()
else {
panic!("No bounded preview")
};
assert_eq!(total, 100 * 1024 * 1024);
assert_eq!(bytes.len(), 8 * 1024 * 1024);
file.set_len(0).await.unwrap();
assert!(matches!(
serve_content_preview(dir.path(), "preview-test")
.await
.unwrap(),
PreviewResult::PreviewUnavailable
));
}
#[tokio::test]
async fn public_free_preview_remains_available() {
let dir = fixture(
b"public",
"text/plain",
AccessControl::Free,
Availability::AllPeers,
)
.await;
assert!(
matches!(serve_content_preview(dir.path(), "preview-test").await.unwrap(), PreviewResult::FullContent(bytes, _) if bytes == b"public")
);
}
}
+22
View File
@@ -246,3 +246,25 @@ same iframe and Stop. Logs: `/tmp/archy-integrated-v4v-browser-origin-4.log`
production notification suppression were used. Production UI rebuild including production notification suppression were used. Production UI rebuild including
the new card footers is running; signed-install and physical companion gates the new card footers is running; signed-install and physical companion gates
remain open. remain open.
### Paid-preview access boundary (new finding during FIPS work)
Source review found that the anonymous preview route returned full paid image
bytes and relied on browser CSS blur. It also served previews for restricted
shares without checking a recipient, and the minimum byte-prefix size could
return a small paid audio/video file in full.
The candidate now produces a fresh, small blurred JPEG on the server, drops
original metadata, bounds raster input/dimensions/decoder concurrency, and fails
closed on unsupported images. Anonymous previews reject specific-recipient and
peer-only content. Audio/video prefixes are at most 10% and 8 MiB, with no
minimum that can reveal the full original. Four isolated regression cases are
compiling; no deployed fix or full preview acceptance is claimed yet.
The preceding integrated backend suite completed: 1,718 passed, zero failures,
five listed ignores. The ignores cover opt-in real AI providers, RNode hardware,
Reticulum daemons, live Minibits and the subprocess permission helper (which its
parent test executes separately). A production build of the earlier integration
is running from detached `11f016a9`; it does not include this new preview fix and
must not be described as the final release candidate.