Compare commits

..
2 Commits
Author SHA1 Message Date
Anaïs Saraiva 9b12838083 strip metadata 2026-08-25 12:57:54 +02:00
Anaïs Saraiva 1079025ec2 removed original name 2026-08-25 12:44:03 +02:00
18 changed files with 1025 additions and 41 deletions
+5
View File
@@ -40,3 +40,8 @@ RATE_LIMIT_WINDOW_MS=60000
# X-Forwarded-For $proxy_add_x_forwarded_for). Never enable this when
# clients can reach the server directly - they could spoof the header.
TRUST_PROXY=0
# Strip embedded metadata (EXIF/GPS/author/comments, video moov/udta, EBML
# Tags, etc.) from uploaded images and videos on the server. Best-effort:
# unrecognized or unparseable formats are stored untouched. Set 0 to disable.
STRIP_METADATA=1
+4
View File
@@ -255,3 +255,7 @@ Files in `storage/` are untouched by updates and keep working.
(`DELETE /api/files/:id` with `X-Delete-Key`).
- Uploads are rejected with `503 try again later` when the storage filesystem is
more than 50% full (configurable via `MAX_STORAGE_USAGE`).
- Embedded metadata (EXIF/GPS/author/comments, video `moov`/`udta`, EBML
`Tags`, AVI `INFO`) is stripped server-side by default for recognized image
and video formats. Unparseable or unrecognized files are stored untouched
(never corrupted). Disable with `STRIP_METADATA=0`.
+1
View File
@@ -43,6 +43,7 @@ Copy `backend/.env.example` to `.env` and edit it.
| `RATE_LIMIT_MAX` | `300` | Max requests per window. |
| `RATE_LIMIT_WINDOW_MS` | `60000` | Window in milliseconds. |
| `TRUST_PROXY` | `0` | Enable behind a proxy (`X-Forwarded-For`). |
| `STRIP_METADATA` | `1` | Strip embedded EXIF/GPS/author/comments and video metadata server-side. |
## API
+5
View File
@@ -34,3 +34,8 @@ RATE_LIMIT_WINDOW_MS=60000
# Set 1 only behind a proxy that sets X-Forwarded-For.
TRUST_PROXY=0
# Strip embedded metadata (EXIF/GPS/author/comments, video moov/udta, EBML
# Tags, etc.) from uploaded images and videos on the server. Best-effort:
# unrecognized or unparseable formats are stored untouched. Set 0 to disable.
STRIP_METADATA=1
+3
View File
@@ -17,6 +17,7 @@ pub struct Config {
pub rate_limit_max: usize,
pub rate_limit_window_ms: u64,
pub trust_proxy: bool,
pub strip_metadata: bool,
}
pub fn parse_size(value: &str) -> Result<u64, String> {
@@ -117,6 +118,7 @@ pub fn load() -> Result<Config, String> {
let trust_proxy = bool_from_env(env("TRUST_PROXY").as_deref(), false);
let rate_limit_enabled = bool_from_env(env("RATE_LIMIT_ENABLED").as_deref(), true);
let strip_metadata = bool_from_env(env("STRIP_METADATA").as_deref(), true);
let public_base_url = match env("PUBLIC_BASE_URL") {
Some(raw) => {
@@ -147,5 +149,6 @@ pub fn load() -> Result<Config, String> {
rate_limit_max,
rate_limit_window_ms,
trust_proxy,
strip_metadata,
})
}
+1
View File
@@ -4,6 +4,7 @@ mod ratelimit;
mod routes;
mod security;
mod storage;
mod strip;
use std::net::SocketAddr;
use std::sync::Arc;
+1 -1
View File
@@ -37,6 +37,6 @@ pub async fn delete(
}
state.storage.delete(&id).await;
tracing::info!("deleted id={id} name={:?}", meta.original_name);
tracing::info!("deleted id={id} name={:?}", meta.download_name);
json(&serde_json::json!({ "ok": true }), StatusCode::OK)
}
+2 -2
View File
@@ -43,7 +43,7 @@ pub async fn handle_download(
.query()
.map(|q| q.split('&').any(|p| p == "e=1"))
.unwrap_or(false);
return render_password_form(&key, &meta.original_name, error);
return render_password_form(&key, &meta.download_name, error);
}
let file_path = state.storage.file_path(&key);
@@ -65,7 +65,7 @@ pub async fn handle_download(
} else {
"inline"
};
let filename = security::download_file_name(&key, &meta.original_name);
let filename = security::renamed_file_name(&key, &meta.mime_type);
let builder = Response::builder()
.status(StatusCode::OK)
+19 -7
View File
@@ -48,7 +48,6 @@ pub async fn upload(
let mut multipart =
multer::Multipart::with_constraints(body.into_data_stream(), boundary, constraints);
let mut filename = String::new();
let mut mime = "application/octet-stream".to_string();
let mut size: u64 = 0;
let mut has_file = false;
@@ -68,7 +67,6 @@ pub async fn upload(
if field.file_name().is_some() {
if !has_file {
has_file = true;
filename = field.file_name().unwrap_or("").to_string();
let ct = field
.content_type()
.map(|m| m.as_ref().to_string())
@@ -116,6 +114,20 @@ pub async fn upload(
outcome = Outcome::Internal;
break 'fields;
}
// Strip embedded metadata (EXIF/GPS/author/comments) when
// enabled. Best-effort: on unknown format or parse failure
// the original file is kept untouched.
if state.config.strip_metadata && size <= state.storage.strip_limit() {
match state.storage.strip_file(&id).await {
Ok(Some(new_size)) => size = new_size,
Ok(None) => {}
Err(_) => {
outcome = Outcome::Internal;
break 'fields;
}
}
}
}
} else {
while let Ok(Some(_)) = field.chunk().await {}
@@ -172,7 +184,8 @@ pub async fn upload(
return json_error(StatusCode::BAD_REQUEST, "no file part in the upload");
}
let original_name = security::sanitize_original_name(&filename);
// The original client filename is discarded. The file's identity is the
// server-generated id plus a mime-derived extension.
let password_hash = if !password.trim().is_empty() {
security::hash_password(&password)
} else {
@@ -180,11 +193,11 @@ pub async fn upload(
};
let now = chrono::Utc::now();
let download_name = security::download_file_name(&id, &original_name);
let download_name = security::renamed_file_name(&id, &mime);
let meta = FileMeta {
id: id.clone(),
original_name: original_name.clone(),
download_name: download_name.clone(),
size,
mime_type: mime.clone(),
download_key_hash: security::hash_secret(&id),
@@ -206,14 +219,13 @@ pub async fn upload(
None => format!("/{download_name}"),
};
tracing::info!("upload ok id={id} size={size} name={original_name:?}");
tracing::info!("upload ok id={id} size={size} name={download_name:?}");
json(
&serde_json::json!({
"id": id,
"key": id,
"url": url,
"originalName": original_name,
"downloadName": download_name,
"size": size,
"mimeType": mime,
+39 -21
View File
@@ -177,21 +177,6 @@ pub fn parse_auth_token(token: &str, secret: &str) -> Option<String> {
Some(key.to_string())
}
pub fn sanitize_original_name(name: &str) -> String {
let base = name.replace('\\', "/");
let base = base.rsplit('/').next().unwrap_or("");
let cleaned: String = base
.chars()
.map(|c| if (c as u32) < 0x20 || c == '\u{7f}' { '_' } else { c })
.collect();
let cleaned = cleaned.trim();
if cleaned.is_empty() {
"file".to_string()
} else {
cleaned.to_string()
}
}
pub fn sanitize_mime(value: &str) -> String {
let cleaned: String = value
.chars()
@@ -235,15 +220,48 @@ pub fn content_disposition(name: &str, disposition: &str) -> String {
)
}
pub fn download_file_name(key: &str, original_name: &str) -> String {
match original_name.rfind('.') {
Some(dot) if dot > 0 && dot < original_name.len() - 1 => {
format!("{key}.{}", &original_name[dot + 1..])
}
_ => key.to_string(),
/// Derive a short, safe file extension from a MIME type. Used to build the
/// public download name from the server-generated id, so the client-provided
/// original filename is never relied upon (or preserved).
pub fn extension_from_mime(mime_type: &str) -> &'static str {
let mime = mime_type.split(';').next().unwrap_or("").trim().to_ascii_lowercase();
match mime.as_str() {
"image/jpeg" => "jpg",
"image/png" => "png",
"image/gif" => "gif",
"image/webp" => "webp",
"image/bmp" => "bmp",
"image/tiff" => "tiff",
"image/svg+xml" => "svg",
"video/mp4" => "mp4",
"video/webm" => "webm",
"video/quicktime" => "mov",
"video/x-msvideo" => "avi",
"audio/mpeg" => "mp3",
"audio/ogg" => "ogg",
"audio/wav" => "wav",
"audio/aac" => "aac",
"audio/flac" => "flac",
"application/pdf" => "pdf",
"application/zip" => "zip",
"application/gzip" => "gz",
"application/x-tar" => "tar",
"application/json" => "json",
"text/plain" => "txt",
"text/csv" => "csv",
"text/html" => "html",
_ => "",
}
}
/// Build the public/on-disk download name from the server-generated id plus a
/// mime-derived extension. This is the renamed identity of the file — the
/// original client filename is deliberately discarded.
pub fn renamed_file_name(key: &str, mime_type: &str) -> String {
let ext = extension_from_mime(mime_type);
if ext.is_empty() { key.to_string() } else { format!("{key}.{ext}") }
}
/// Whether a file should render inline in the browser rather than be forced as
/// a download. Restricted to inert media types so the XSS protection is kept.
pub fn should_inline(mime_type: &str) -> bool {
+33 -1
View File
@@ -10,7 +10,7 @@ use crate::security::is_valid_key;
#[serde(rename_all = "camelCase")]
pub struct FileMeta {
pub id: String,
pub original_name: String,
pub download_name: String,
pub size: u64,
pub mime_type: String,
pub download_key_hash: String,
@@ -90,6 +90,38 @@ impl Storage {
self.files_dir.join(format!("{id}.part"))
}
/// Files above this size are never buffered into memory for metadata
/// stripping (too large). They pass through untouched.
pub fn strip_limit(&self) -> u64 {
256 * 1024 * 1024
}
/// Best-effort in-band metadata stripping. Reads the stored file, produces
/// a metadata-free copy via `crate::strip::strip`, and atomically replaces
/// the original only when stripping actually succeeds and shrinks the file.
///
/// Returns `Ok(Some(new_size))` on success, `Ok(None)` when the file was
/// left untouched (unknown format / unparseable / no metadata), or `Err` on
/// an I/O failure that the caller should treat as a fatal upload error.
pub async fn strip_file(&self, id: &str) -> std::io::Result<Option<u64>> {
let path = self.file_path(id);
let data = tokio::fs::read(&path).await?;
let Some(stripped) = crate::strip::strip(&data) else {
return Ok(None);
};
// Never write a larger or empty result; the original is authoritative.
if stripped.is_empty() || stripped.len() >= data.len() {
return Ok(None);
}
let tmp = self.files_dir.join(format!("{id}.strip"));
tokio::fs::write(&tmp, &stripped).await?;
tokio::fs::rename(&tmp, &path).await?;
Ok(Some(stripped.len() as u64))
}
fn meta_path(&self, id: &str) -> PathBuf {
self.metadata_dir.join(format!("{id}.json"))
}
+886
View File
@@ -0,0 +1,886 @@
//! Best-effort in-band metadata stripper.
//!
//! Removes embedded metadata (EXIF, XMP, GPS, comments, ICC, timestamps, video
//! `moov/udta`, EBML `Tags`, AVI `LIST INFO`, ...) from common media formats.
//!
//! The policy is **safe**: if a format is not recognized, or a structure cannot
//! be parsed reliably, the original bytes are returned untouched. We never risk
//! corrupting a file by half-parsing it.
/// Returns `Some(stripped_bytes)` if `data` was recognized and successfully
/// rewritten without metadata, or `None` if the format is unknown / parsing
/// failed (caller keeps the original).
pub fn strip(data: &[u8]) -> Option<Vec<u8>> {
if data.is_empty() {
return None;
}
detect_and_strip(data)
}
fn detect_and_strip(data: &[u8]) -> Option<Vec<u8>> {
if data.len() >= 3 && &data[0..3] == b"\xff\xd8\xff" {
return jpeg::strip(data);
}
if data.len() >= 8 && &data[0..8] == b"\x89PNG\r\n\x1a\n" {
return png::strip(data);
}
if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
return webp::strip(data);
}
if data.len() >= 6 && (&data[0..6] == b"GIF87a" || &data[0..6] == b"GIF89a") {
return gif::strip(data);
}
if data.len() >= 4 && &data[0..4] == b"\x1a\x45\xdf\xa3" {
return ebml::strip(data);
}
if data.len() >= 12 && &data[4..8] == b"ftyp" {
return mp4::strip(data);
}
if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"AVI " {
return avi::strip(data);
}
None
}
// ---------------------------------------------------------------------------
// JPEG — remove EXIF / XMP / IPTC / ICC / comment segments.
// ---------------------------------------------------------------------------
mod jpeg {
use super::*;
pub fn strip(data: &[u8]) -> Option<Vec<u8>> {
let mut out = Vec::with_capacity(data.len());
let mut i = 2usize; // skip SOI (0xFFD8)
// Copy SOI marker.
out.extend_from_slice(&data[0..2]);
let mut saw_sos = false;
while i + 1 < data.len() {
// In scan data (after SOS) we copy verbatim until EOI.
if saw_sos {
out.extend_from_slice(&data[i..]);
break;
}
if data[i] != 0xFF {
return None;
}
// Skip fill bytes.
let marker = data[i + 1];
if marker == 0xFF {
i += 1;
continue;
}
// Standalone markers (no length field).
if matches!(marker, 0x01) || (0xD0..=0xD7).contains(&marker) {
out.extend_from_slice(&data[i..i + 2]);
i += 2;
continue;
}
// SOS: copy marker + length, then everything after is scan data.
if marker == 0xDA {
if i + 4 > data.len() {
return None;
}
let len = read_u16(data, i + 2)? as usize;
if i + 2 + len > data.len() {
return None;
}
out.extend_from_slice(&data[i..i + 2 + len]);
i += 2 + len;
saw_sos = true;
continue;
}
if marker == 0xD9 {
// EOI
out.extend_from_slice(&data[i..i + 2]);
break;
}
// Any other marker with a length field.
if i + 4 > data.len() {
return None;
}
let len = read_u16(data, i + 2)? as usize;
if len < 2 || i + 2 + len > data.len() {
return None;
}
// Segments to drop: APP1 (EXIF/XMP), APP2 (ICC/FPXR), APP13 (IPTC),
// APP4 (FPX), APP5/6/7/10, APP11 (JPEG-HDR), APP12 (PictureInfo),
// COM (comment).
let drop = match marker {
0xE0 => false, // APP0 — JFIF / potentially AVIF, keep neutral JFIF
0xE1 | 0xE2 | 0xED | 0xEE | 0xE4 | 0xE5 | 0xE6 | 0xE7 | 0xEA
| 0xEB | 0xEC | 0xFE => true,
_ => false,
};
if !drop {
out.extend_from_slice(&data[i..i + 2 + len]);
}
i += 2 + len;
}
// Ensure we ended with EOI; otherwise bail (keep original).
if out.len() < 2 || out[out.len() - 2] != 0xFF || out[out.len() - 1] != 0xD9 {
return None;
}
Some(out)
}
}
// ---------------------------------------------------------------------------
// PNG — remove text / time / eXIf chunks.
// ---------------------------------------------------------------------------
mod png {
use super::*;
const DROP: &[&[u8; 4]] = &[
b"tEXt", b"zTXt", b"iTXt", b"tIME", b"eXIf", b"iCCP", b"sRGB", b"gAMA", b"cHRM",
];
fn is_drop(ty: &[u8; 4]) -> bool {
DROP.iter().any(|d| *d == ty)
}
pub fn strip(data: &[u8]) -> Option<Vec<u8>> {
let mut out = Vec::with_capacity(data.len());
out.extend_from_slice(&data[0..8]); // signature
let mut i = 8usize;
while i + 8 <= data.len() {
let len = read_u32(data, i)? as usize;
let ty: &[u8; 4] = data[i + 4..i + 8].try_into().ok()?;
// 12 bytes header (len + type + crc) + len data
if i + 12 + len > data.len() {
return None;
}
let total = 12 + len;
if is_drop(ty) {
i += total;
continue;
}
out.extend_from_slice(&data[i..i + total]);
i += total;
if ty == b"IEND" {
break;
}
}
// Require IEND to have been reached.
if out.len() < 12 || &out[out.len() - 8..out.len() - 4] != b"IEND" {
return None;
}
Some(out)
}
}
// ---------------------------------------------------------------------------
// WebP — remove EXIF / XMP chunks.
// ---------------------------------------------------------------------------
mod webp {
use super::*;
pub fn strip(data: &[u8]) -> Option<Vec<u8>> {
let mut out = Vec::with_capacity(data.len());
out.extend_from_slice(&data[0..12]); // RIFF header + WEBP
let mut i = 12usize;
// Flags we clear from the VP8X header when dropping metadata chunks.
// 0x20 = ICC, 0x08 = EXIF, 0x04 = XMP.
const META_FLAGS: u8 = 0x20 | 0x08 | 0x04;
while i + 8 <= data.len() {
let tag = &data[i..i + 4];
let size = read_u32_le(data, i + 4)? as usize;
if i + 8 + size > data.len() {
return None;
}
let padded = size + (size & 1);
let chunk_size = 8 + padded;
let drop = tag == b"EXIF" || tag == b"XMP " || tag == b"ICCP";
if tag == b"VP8X" {
// Keep VP8X but clear the metadata flags.
if size < 1 {
return None;
}
out.extend_from_slice(&data[i..i + 8]);
let mut flags = data[i + 8];
flags &= !META_FLAGS;
out.push(flags);
out.extend_from_slice(&data[i + 9..i + 8 + size]);
} else if !drop {
out.extend_from_slice(&data[i..i + chunk_size]);
}
i += chunk_size;
}
// Patch the RIFF total size field.
let riff_size = (out.len() - 8) as u32;
out[4..8].copy_from_slice(&riff_size.to_le_bytes());
Some(out)
}
}
// ---------------------------------------------------------------------------
// GIF — remove comment & application extensions.
// ---------------------------------------------------------------------------
mod gif {
pub fn strip(data: &[u8]) -> Option<Vec<u8>> {
let mut out = Vec::with_capacity(data.len());
out.extend_from_slice(&data[0..6]); // header
let mut i = 6usize;
// Logical screen descriptor (7 bytes).
if i + 7 > data.len() {
return None;
}
let packed = data[i + 4];
out.extend_from_slice(&data[i..i + 7]);
i += 7;
// Global color table (GCT), if present. GCT is NOT metadata; keep it.
if packed & 0x80 != 0 {
let gct_len = 3 * (1 << ((packed & 0x07) + 1));
if i + gct_len > data.len() {
return None;
}
out.extend_from_slice(&data[i..i + gct_len]);
i += gct_len;
}
while i < data.len() {
match data[i] {
0x3B => {
// Trailer
out.push(data[i]);
break;
}
0x21 => {
// Extension introducer
if i + 1 >= data.len() {
return None;
}
let label = data[i + 1];
// Drop comment (0xFE) and application (0xFF) extension blocks.
if label == 0xFE || label == 0xFF {
i += 2;
i = skip_gif_subblocks(data, i)?;
continue;
}
// Other extensions (graphic control 0xF9, plain text 0x01):
// copy the whole block verbatim.
i += 2;
while i < data.len() {
let n = data[i] as usize;
out.push(data[i]);
i += 1;
if n == 0 {
break;
}
if i + n > data.len() {
return None;
}
out.extend_from_slice(&data[i..i + n]);
i += n;
}
}
0x2C => {
// Image descriptor (10 bytes) + LZW data. Copy verbatim;
// it does not carry identity metadata.
if i + 10 > data.len() {
return None;
}
let img_packed = data[i + 9];
out.extend_from_slice(&data[i..i + 10]);
i += 10;
// Local color table (LCT), if present.
if img_packed & 0x80 != 0 {
let lct_len = 3 * (1 << ((img_packed & 0x07) + 1));
if i + lct_len > data.len() {
return None;
}
out.extend_from_slice(&data[i..i + lct_len]);
i += lct_len;
}
// LZW minimum code size.
if i >= data.len() {
return None;
}
out.push(data[i]);
i += 1;
// Image data sub-blocks.
while i < data.len() {
let n = data[i] as usize;
out.push(data[i]);
i += 1;
if n == 0 {
break;
}
if i + n > data.len() {
return None;
}
out.extend_from_slice(&data[i..i + n]);
i += n;
}
}
_ => {
// Unknown descriptor; bail and keep original.
return None;
}
}
}
Some(out)
}
fn skip_gif_subblocks(data: &[u8], mut i: usize) -> Option<usize> {
while i < data.len() {
let n = data[i] as usize;
i += 1;
if n == 0 {
return Some(i);
}
if i + n > data.len() {
return None;
}
i += n;
}
None
}
}
// ---------------------------------------------------------------------------
// EBML / Matroska / WebM — drop Tags / Metadata-mapped elements.
// ---------------------------------------------------------------------------
mod ebml {
use super::*;
pub fn strip(data: &[u8]) -> Option<Vec<u8>> {
// The `Tags` element (0x1254C367) is in the top-level (Segment) scope.
// We do a conservative top-level scan: copy the EBML header and the
// Segment but drop any top-level `Tags` element. Top-level children are
// SeekHead, Info, Tracks, Chapters, Cluster, Cues, Attachments, Tags.
// We rely on element IDs being present; on any parse ambiguity, bail.
let mut out = Vec::with_capacity(data.len());
let mut i = 0usize;
// EBML header element (ID 0x1A45DFA3).
let (id, size, hdr_len) = read_ebml_element(data, i)?;
if id != 0x1A45DFA3 {
return None;
}
let header_end = hdr_len + (size as usize);
if header_end > data.len() {
return None;
}
out.extend_from_slice(&data[0..header_end]);
i = header_end;
// Segment element (ID 0x18538067).
let (id, size, _hdr_len) = read_ebml_element(data, i)?;
if id != 0x18538067 {
return None;
}
let seg_id_len = ebml_id_len(data, i)?;
let seg_size_len = ebml_size_len(data, i + seg_id_len)?;
let seg_hdr_len = seg_id_len + seg_size_len;
let seg_data_start = i + seg_hdr_len;
let seg_data_end = if size == u64::MAX {
data.len()
} else {
(seg_data_start + size as usize).min(data.len())
};
if seg_data_end > data.len() {
return None;
}
// Copy segment ID, store size-field width, then rebuild children.
let seg_size_encoded = size;
let seg_id_bytes = &data[i..i + seg_id_len];
out.extend_from_slice(seg_id_bytes);
// Build children body (drop Tags).
let mut body = Vec::new();
let mut j = seg_data_start;
// Element IDs for Tag-bearing metadata children of Segment.
// Tags = 0x1254C367, Chapters = 0x1043A770, Attachments = 0x1941A469.
while j < seg_data_end {
let (id2, size2, hdr_len2) = read_ebml_element(data, j)?;
if size2 == u64::MAX {
// Nested unknown-size child: too ambiguous, keep original.
return None;
}
let elem_end = hdr_len2 + (size2 as usize);
if elem_end > seg_data_end {
// Malformed; keep original.
return None;
}
if id2 == 0x1254C367 || id2 == 0x1043A770 || id2 == 0x1941A469 {
j = elem_end;
continue;
}
body.extend_from_slice(&data[j..elem_end]);
j = elem_end;
}
// Re-encode segment length field with the same width as the source.
if seg_size_encoded == u64::MAX {
// Unknown size -> single-byte 0x01 (all-ones style).
out.push(0x01);
} else {
out.extend_from_slice(&encode_ebml_size(body.len() as u64, seg_size_len)?);
}
out.extend_from_slice(&body);
Some(out)
}
}
// ---------------------------------------------------------------------------
// ISO BMFF (MP4/MOV/M4V) — drop moov/udta/meta (and ilst) boxes.
// ---------------------------------------------------------------------------
mod mp4 {
use super::*;
pub fn strip(data: &[u8]) -> Option<Vec<u8>> {
// Recursively rebuild the box list at top level, dropping metadata
// boxes inside `moov`. We edit in-place structurally, preserving the
// relative media data untouched (mdat is kept verbatim).
let mut out = Vec::with_capacity(data.len());
let mut i = 0usize;
while i + 8 <= data.len() {
let (size, hdr_len, ty) = read_box(data, i)?;
let box_end = if size == 0 {
data.len()
} else if size == 1 {
let largesize = read_u64(data, i + 8)?;
(i + largesize as usize).min(data.len())
} else {
(i + size as usize).min(data.len())
};
if box_end > data.len() || box_end < i {
return None;
}
if ty == *b"moov" {
let body = &data[i + hdr_len..box_end];
let new_body = strip_moov(body)?;
// Rebuild moov box with new size.
out.extend_from_slice(&build_box(b"moov", &new_body));
} else {
out.extend_from_slice(&data[i..box_end]);
}
i = box_end;
}
Some(out)
}
fn strip_moov(body: &[u8]) -> Option<Vec<u8>> {
let mut out = Vec::with_capacity(body.len());
let mut i = 0usize;
while i + 8 <= body.len() {
let (size, hdr_len, ty) = read_box(body, i)?;
let box_end = if size == 0 {
body.len()
} else if size == 1 {
let largesize = read_u64(body, i + 8)?;
(i + largesize as usize).min(body.len())
} else {
(i + size as usize).min(body.len())
};
if box_end > body.len() || box_end < i {
return None;
}
// Drop metadata-bearing boxes.
if ty == *b"udta" || ty == *b"meta" {
i = box_end;
continue;
}
// `trak` contains `udta` too: recurse into it.
if ty == *b"trak" {
let inner = &body[i + hdr_len..box_end];
let new_inner = strip_trak(inner)?;
out.extend_from_slice(&build_box(b"trak", &new_inner));
i = box_end;
continue;
}
out.extend_from_slice(&body[i..box_end]);
i = box_end;
}
Some(out)
}
fn strip_trak(body: &[u8]) -> Option<Vec<u8>> {
let mut out = Vec::with_capacity(body.len());
let mut i = 0usize;
while i + 8 <= body.len() {
let (size, _hdr_len, ty) = read_box(body, i)?;
let box_end = if size == 0 {
body.len()
} else if size == 1 {
let largesize = read_u64(body, i + 8)?;
(i + largesize as usize).min(body.len())
} else {
(i + size as usize).min(body.len())
};
if box_end > body.len() || box_end < i {
return None;
}
if ty == *b"udta" || ty == *b"meta" {
i = box_end;
continue;
}
out.extend_from_slice(&body[i..box_end]);
i = box_end;
}
Some(out)
}
}
// ---------------------------------------------------------------------------
// AVI (RIFF/Audio-Video Interleave) — drop LIST INFO / JUNK / IDIT.
// ---------------------------------------------------------------------------
mod avi {
use super::*;
pub fn strip(data: &[u8]) -> Option<Vec<u8>> {
let mut out = Vec::with_capacity(data.len());
out.extend_from_slice(&data[0..12]); // RIFF + size + AVI
let mut i = 12usize;
while i + 8 <= data.len() {
let id = &data[i..i + 4];
let size = read_u32_le(data, i + 4)? as usize;
let padded = size + (size & 1);
if i + 8 + padded > data.len() {
return None;
}
if id == b"LIST" {
if i + 12 <= data.len() {
let list_type = &data[i + 8..i + 12];
if list_type == b"INFO" || list_type == b"JUNK" {
i += 8 + padded;
continue;
}
}
}
// `IDIT` chunk (encrypted digital interface time) is metadata.
if id == b"IDIT" {
i += 8 + padded;
continue;
}
out.extend_from_slice(&data[i..i + 8 + padded]);
i += 8 + padded;
}
// Patch RIFF size in header.
let new_size = (out.len() - 8) as u32;
out[4..8].copy_from_slice(&new_size.to_le_bytes());
Some(out)
}
}
// ---------------------------------------------------------------------------
// Common byte readers / writers.
// ---------------------------------------------------------------------------
fn read_u16(data: &[u8], i: usize) -> Option<u16> {
Some(u16::from_be_bytes([*data.get(i)?, *data.get(i + 1)?]))
}
fn read_u32(data: &[u8], i: usize) -> Option<u32> {
Some(u32::from_be_bytes([
*data.get(i)?,
*data.get(i + 1)?,
*data.get(i + 2)?,
*data.get(i + 3)?,
]))
}
fn read_u32_le(data: &[u8], i: usize) -> Option<u32> {
Some(u32::from_le_bytes([
*data.get(i)?,
*data.get(i + 1)?,
*data.get(i + 2)?,
*data.get(i + 3)?,
]))
}
fn read_u64(data: &[u8], i: usize) -> Option<u64> {
Some(u64::from_be_bytes([
*data.get(i)?,
*data.get(i + 1)?,
*data.get(i + 2)?,
*data.get(i + 3)?,
*data.get(i + 4)?,
*data.get(i + 5)?,
*data.get(i + 6)?,
*data.get(i + 7)?,
]))
}
// --- ISO BMFF box ----------------------------------------------------------
fn read_box(data: &[u8], i: usize) -> Option<(u64, usize, [u8; 4])> {
if i + 8 > data.len() {
return None;
}
let size32 = read_u32(data, i)? as u64;
let mut ty = [0u8; 4];
ty.copy_from_slice(&data[i + 4..i + 8]);
match size32 {
0 => Some((0, 8, ty)),
1 => {
if i + 16 > data.len() {
return None;
}
let largesize = read_u64(data, i + 8)?;
Some((largesize, 16, ty))
}
n if n < 8 => None,
n => Some((n, 8, ty)),
}
}
fn build_box(ty: &[u8; 4], body: &[u8]) -> Vec<u8> {
let mut out = Vec::with_capacity(8 + body.len());
out.extend_from_slice(&((8 + body.len()) as u32).to_be_bytes());
out.extend_from_slice(ty);
out.extend_from_slice(body);
out
}
// --- EBML -------------------------------------------------------------------
fn ebml_id_len(data: &[u8], i: usize) -> Option<usize> {
let first = *data.get(i)?;
let mut len = 1usize;
let mut mask = 0x80u8;
while mask != 0 && first & mask == 0 {
len += 1;
mask >>= 1;
}
if len > 4 {
return None;
}
Some(len)
}
fn ebml_size_len(data: &[u8], i: usize) -> Option<usize> {
let first = *data.get(i)?;
let mut len = 1usize;
let mut mask = 0x80u8;
while mask != 0 && first & mask == 0 {
len += 1;
mask >>= 1;
}
if len > 8 {
return None;
}
Some(len)
}
/// Returns (element_id, size, header_len). `size` is the payload length after
/// the header, or `u64::MAX` for unknown-size elements. The element id is
/// decoded with its leading length-marker bit cleared.
fn read_ebml_element(data: &[u8], i: usize) -> Option<(u64, u64, usize)> {
let id_len = ebml_id_len(data, i)?;
let id_bytes = &data.get(i..i + id_len)?;
// Clear the leading length-marker bit in the first byte before decoding.
let mut id = 0u64;
for (idx, b) in id_bytes.iter().enumerate() {
let mut v = *b;
if idx == 0 {
v &= 0x7F >> (id_len - 1);
}
id = (id << 8) | v as u64;
}
let si = i + id_len;
let size_len = ebml_size_len(data, si)?;
let size_bytes = &data.get(si..si + size_len)?;
// Unknown-size: first byte is 0x01 followed by all-1s (e.g. 0x01 0xFF...).
// The first byte after clearing the marker is zero and the rest are all 1s.
let mut size = 0u64;
for (idx, b) in size_bytes.iter().enumerate() {
let mut v = *b;
if idx == 0 {
v &= 0x7F >> (size_len - 1);
}
size = (size << 8) | v as u64;
}
// Detect all-ones payload (unknown size).
let unknown = size_bytes
.iter()
.enumerate()
.all(|(idx, b)| {
if idx == 0 {
*b & (0x7F >> (size_len - 1)) == 0
} else {
*b == 0xFF
}
});
let size = if unknown { u64::MAX } else { size };
Some((id, size, id_len + size_len))
}
fn encode_ebml_size(value: u64, width: usize) -> Option<Vec<u8>> {
if width == 0 || width > 8 {
return None;
}
let mut out = vec![0u8; width];
for k in 0..width {
out[width - 1 - k] = (value >> (8 * k)) as u8;
}
// Set the leading marker bit (most-significant bit of first byte).
out[0] |= 0x80 >> (width - 1);
Some(out)
}
#[cfg(test)]
mod tests {
use super::*;
fn crc32(data: &[u8]) -> u32 {
let mut crc: u32 = 0xFFFF_FFFF;
for &b in data {
crc ^= b as u32;
for _ in 0..8 {
crc = if crc & 1 != 0 { (crc >> 1) ^ 0xEDB8_8320 } else { crc >> 1 };
}
}
!crc
}
fn png_chunk(ty: &[u8; 4], payload: &[u8]) -> Vec<u8> {
let mut out = Vec::new();
out.extend_from_slice(&(payload.len() as u32).to_be_bytes());
out.extend_from_slice(ty);
out.extend_from_slice(payload);
let mut crc_input = Vec::with_capacity(4 + payload.len());
crc_input.extend_from_slice(ty);
crc_input.extend_from_slice(payload);
out.extend_from_slice(&crc32(&crc_input).to_be_bytes());
out
}
#[test]
fn png_strips_text_chunk() {
let mut data = b"\x89PNG\r\n\x1a\n".to_vec();
data.extend_from_slice(&png_chunk(b"IHDR", &[0; 13]));
data.extend_from_slice(&png_chunk(b"tEXt", b"Comment\0secret-author"));
data.extend_from_slice(&png_chunk(b"IDAT", &[1, 2, 3, 4]));
data.extend_from_slice(&png_chunk(b"IEND", &[]));
let stripped = strip(&data).expect("should strip");
assert!(!stripped.windows(4).any(|w| w == b"tEXt"));
assert!(stripped.windows(4).any(|w| w == b"IEND"));
assert!(stripped.len() < data.len());
}
#[test]
fn unknown_format_returns_none() {
assert!(strip(b"hello world this is random data").is_none());
assert!(strip(b"").is_none());
}
#[test]
fn gif_strips_comment_and_application_extensions() {
// GIF89a header + LSD (no GCT) + comment ext + application ext + trailer
let mut data = b"GIF89a".to_vec();
data.extend_from_slice(&[1, 0, 1, 0, 0, 0, 0]); // LSD w/o GCT
// Comment extension (0x21 0xFE ...)
data.push(0x21);
data.push(0xFE);
data.push(6);
data.extend_from_slice(b"secret");
data.push(0);
// Application extension (0x21 0xFF ...)
data.push(0x21);
data.push(0xFF);
data.push(11);
data.extend_from_slice(b"NETSCAPE2.0");
data.push(3);
data.extend_from_slice(&[1, 0, 0]);
data.push(0);
data.push(0x3B); // trailer
let stripped = strip(&data).expect("should strip");
assert!(!stripped.windows(2).any(|w| w == [0x21, 0xFE]));
assert!(!stripped.windows(2).any(|w| w == [0x21, 0xFF]));
assert_eq!(stripped.last(), Some(&0x3B));
}
#[test]
fn jpeg_strips_app1_exif() {
// SOI + APP1(EXIF) + EOI
let mut data = b"\xff\xd8\xff".to_vec();
data.push(0xE1); // APP1
data.extend_from_slice(&8u16.to_be_bytes()); // length = 2 + 6 payload
data.extend_from_slice(b"Exif\0\0");
data.extend_from_slice(&[0xFF, 0xD9]); // EOI
let stripped = strip(&data).expect("should strip");
assert_eq!(&stripped, b"\xff\xd8\xff\xd9");
}
#[test]
fn webp_strips_exif_xmp() {
// RIFF + WEBP + VP8X chunk + EXIF chunk + XMP chunk
let mut data = b"RIFF".to_vec();
data.extend_from_slice(&0u32.to_le_bytes()); // size patched later
data.extend_from_slice(b"WEBP");
// VP8X header (flags = ICC|EXIF|XMP|alpha)
let mut vp8x = Vec::new();
vp8x.push(0x20 | 0x08 | 0x04 | 0x10); // flags
vp8x.extend_from_slice(&[0, 0, 0, 0, 0, 0, 0]); // rest of 10-byte payload
data.extend_from_slice(b"VP8X");
data.extend_from_slice(&(vp8x.len() as u32).to_le_bytes());
data.extend_from_slice(&vp8x);
// EXIF chunk
data.extend_from_slice(b"EXIF");
data.extend_from_slice(&6u32.to_le_bytes());
data.extend_from_slice(b"Exif\0\0");
// XMP chunk
data.extend_from_slice(b"XMP ");
data.extend_from_slice(&4u32.to_le_bytes());
data.extend_from_slice(b"xml!"); // 4-byte payload
// Patch RIFF size
let riff = (data.len() - 8) as u32;
data[4..8].copy_from_slice(&riff.to_le_bytes());
let stripped = strip(&data).expect("should strip");
assert!(!stripped.windows(4).any(|w| w == b"EXIF"));
assert!(!stripped.windows(4).any(|w| w == b"XMP "));
assert!(stripped.windows(4).any(|w| w == b"VP8X"));
// VP8X flags should have metadata bits cleared.
let vp8x_pos = stripped.windows(4).position(|w| w == b"VP8X").unwrap();
assert_eq!(stripped[vp8x_pos + 8] & 0x2C, 0);
}
// Real-media smoke test. Only runs when pre-generated files exist under
// /tmp/meta_test; otherwise it silently skips (CI-safe).
#[test]
fn strip_real_media_when_present() {
for name in ["test.jpg", "test.png", "test.mp4"] {
let path = std::path::Path::new("/tmp/meta_test").join(name);
let Ok(data) = std::fs::read(&path) else {
continue;
};
let Some(stripped) = strip(&data) else {
eprintln!("{name}: not stripped (unknown/unparseable format) — kept original");
continue;
};
// Stripping must never grow the file, and must still be valid enough
// to be non-empty.
assert!(!stripped.is_empty());
assert!(stripped.len() <= data.len());
let out_path = std::path::Path::new("/tmp/meta_test").join(format!("{name}.stripped"));
std::fs::write(&out_path, &stripped).ok();
eprintln!("{name}: {} -> {} bytes", data.len(), stripped.len());
}
}
}
+1 -2
View File
@@ -53,8 +53,7 @@
id: data.id,
key: data.key,
url: data.url,
originalName: data.originalName,
downloadName: data.downloadName || data.originalName,
downloadName: data.downloadName || '',
mimeType: data.mimeType || '',
size: data.size,
createdAt: new Date().toISOString(),
+1 -1
View File
@@ -8,7 +8,7 @@
async function onDelete(item) {
const ok = window.confirm(
`Delete "${item.originalName || item.downloadName}" permanently? This cannot be undone.`
`Delete "${item.downloadName || 'file'}" permanently? This cannot be undone.`
);
if (!ok) return;
try {
+1 -1
View File
@@ -14,7 +14,7 @@
let copied = false;
$: name = item.originalName || item.downloadName || 'file';
$: name = item.downloadName || 'file';
async function copy() {
await copyText(downloadUrl(item));
+1 -1
View File
@@ -8,7 +8,7 @@
let copied = false;
$: url = result ? downloadUrl(result) : '';
$: name = result ? result.originalName || result.downloadName || 'file' : '';
$: name = result ? result.downloadName || 'file' : '';
async function copy() {
await copyText(url);
+19 -1
View File
@@ -1,9 +1,27 @@
// Generate a short, unguessable lowercase-alphanumeric name (client side),
// keeping the original extension so the backend can derive a cosmetic name.
function randomRename(name) {
const charset = 'abcdefghijklmnopqrstuvwxyz0123456789';
let id = '';
const bytes = crypto.getRandomValues(new Uint8Array(8));
for (let i = 0; i < 8; i += 1) id += charset[bytes[i] % charset.length];
const dot = name.lastIndexOf('.');
const ext = dot > 0 && dot < name.length - 1 ? name.slice(dot) : '';
return `${id}${ext}`;
}
export function uploadFile(file, password, onProgress) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
const form = new FormData();
// Rename the file client side before uploading: guard against the
// original name leaking and collisions. The backend re-asserts its own
// random id as the authoritative stored name regardless.
const renamed = new File([file], randomRename(file.name), { type: file.type });
if (password) form.append('password', password);
form.append('file', file);
form.append('file', renamed);
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable && onProgress) onProgress(e.loaded, e.total);
+2 -2
View File
@@ -9,7 +9,7 @@ export function forcedDownloadUrl(item) {
}
export function displayName(item) {
return item.downloadName || item.originalName || 'file';
return item.downloadName || 'file';
}
export function fileExtension(item) {
@@ -20,7 +20,7 @@ export function fileExtension(item) {
}
export function linkLabel(item) {
return item.originalName || item.downloadName || 'file';
return item.downloadName || 'file';
}
export function buildLinkFormats(item) {