Generate paid previews server-side and enforce sharing boundaries
This commit is contained in:
Generated
+58
@@ -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",
|
||||||
|
]
|
||||||
|
|||||||
@@ -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"] }
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user