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
+2
View File
@@ -106,6 +106,8 @@ flate2 = "1.0"
# TOTP 2FA
totp-rs = { version = "5.7", features = ["otpauth", "gen_secret"] }
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"
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.
pub enum PreviewResult {
/// Full content (free/peers-only items — redirect to normal serve).
/// Full publicly shared free content.
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),
/// 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),
/// 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
@@ -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)
/// - Other: not available
/// 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> {
let catalog = load_catalog(data_dir).await?;
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),
};
// Check availability — don't preview hidden items
if matches!(item.availability, Availability::Nobody) {
// This endpoint is anonymous. It must not become an alternate download
// route around peer-only or specific-recipient access checks.
if !matches!(item.availability, Availability::AllPeers)
|| matches!(item.access, AccessControl::PeersOnly)
{
return Ok(PreviewResult::NotFound);
}
@@ -633,16 +683,10 @@ pub async fn serve_content_preview(data_dir: &Path, id: &str) -> Result<PreviewR
AccessControl::Paid { .. } => {
let mime = &item.mime_type;
if mime.starts_with("image/") {
// Serve full image — frontend applies CSS blur
let bytes = fs::read(&file_path)
.await
.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()))
match blurred_image_preview(file_path).await {
Ok(bytes) => Ok(PreviewResult::BlurPreview(bytes, "image/jpeg".into())),
Err(_) => Ok(PreviewResult::PreviewUnavailable),
}
} 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
@@ -664,12 +708,16 @@ pub async fn serve_content_preview(data_dir: &Path, id: &str) -> Result<PreviewR
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)
.await
.context("Failed to read file metadata")?;
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;
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")
);
}
}