Compare commits
2
Commits
1b3b45b348
...
9b12838083
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b12838083 | ||
|
|
1079025ec2 |
@@ -40,3 +40,8 @@ RATE_LIMIT_WINDOW_MS=60000
|
|||||||
# X-Forwarded-For $proxy_add_x_forwarded_for). Never enable this when
|
# X-Forwarded-For $proxy_add_x_forwarded_for). Never enable this when
|
||||||
# clients can reach the server directly - they could spoof the header.
|
# clients can reach the server directly - they could spoof the header.
|
||||||
TRUST_PROXY=0
|
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
|
||||||
|
|||||||
+5
-1
@@ -254,4 +254,8 @@ Files in `storage/` are untouched by updates and keep working.
|
|||||||
at 48h) and are swept hourly. Deletion also requires possession of the key
|
at 48h) and are swept hourly. Deletion also requires possession of the key
|
||||||
(`DELETE /api/files/:id` with `X-Delete-Key`).
|
(`DELETE /api/files/:id` with `X-Delete-Key`).
|
||||||
- Uploads are rejected with `503 try again later` when the storage filesystem is
|
- Uploads are rejected with `503 try again later` when the storage filesystem is
|
||||||
more than 50% full (configurable via `MAX_STORAGE_USAGE`).
|
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`.
|
||||||
@@ -43,6 +43,7 @@ Copy `backend/.env.example` to `.env` and edit it.
|
|||||||
| `RATE_LIMIT_MAX` | `300` | Max requests per window. |
|
| `RATE_LIMIT_MAX` | `300` | Max requests per window. |
|
||||||
| `RATE_LIMIT_WINDOW_MS` | `60000` | Window in milliseconds. |
|
| `RATE_LIMIT_WINDOW_MS` | `60000` | Window in milliseconds. |
|
||||||
| `TRUST_PROXY` | `0` | Enable behind a proxy (`X-Forwarded-For`). |
|
| `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
|
## API
|
||||||
|
|
||||||
|
|||||||
@@ -34,3 +34,8 @@ RATE_LIMIT_WINDOW_MS=60000
|
|||||||
|
|
||||||
# Set 1 only behind a proxy that sets X-Forwarded-For.
|
# Set 1 only behind a proxy that sets X-Forwarded-For.
|
||||||
TRUST_PROXY=0
|
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
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ pub struct Config {
|
|||||||
pub rate_limit_max: usize,
|
pub rate_limit_max: usize,
|
||||||
pub rate_limit_window_ms: u64,
|
pub rate_limit_window_ms: u64,
|
||||||
pub trust_proxy: bool,
|
pub trust_proxy: bool,
|
||||||
|
pub strip_metadata: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn parse_size(value: &str) -> Result<u64, String> {
|
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 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 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") {
|
let public_base_url = match env("PUBLIC_BASE_URL") {
|
||||||
Some(raw) => {
|
Some(raw) => {
|
||||||
@@ -147,5 +149,6 @@ pub fn load() -> Result<Config, String> {
|
|||||||
rate_limit_max,
|
rate_limit_max,
|
||||||
rate_limit_window_ms,
|
rate_limit_window_ms,
|
||||||
trust_proxy,
|
trust_proxy,
|
||||||
|
strip_metadata,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ mod ratelimit;
|
|||||||
mod routes;
|
mod routes;
|
||||||
mod security;
|
mod security;
|
||||||
mod storage;
|
mod storage;
|
||||||
|
mod strip;
|
||||||
|
|
||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|||||||
@@ -37,6 +37,6 @@ pub async fn delete(
|
|||||||
}
|
}
|
||||||
|
|
||||||
state.storage.delete(&id).await;
|
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)
|
json(&serde_json::json!({ "ok": true }), StatusCode::OK)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ pub async fn handle_download(
|
|||||||
.query()
|
.query()
|
||||||
.map(|q| q.split('&').any(|p| p == "e=1"))
|
.map(|q| q.split('&').any(|p| p == "e=1"))
|
||||||
.unwrap_or(false);
|
.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);
|
let file_path = state.storage.file_path(&key);
|
||||||
@@ -65,7 +65,7 @@ pub async fn handle_download(
|
|||||||
} else {
|
} else {
|
||||||
"inline"
|
"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()
|
let builder = Response::builder()
|
||||||
.status(StatusCode::OK)
|
.status(StatusCode::OK)
|
||||||
|
|||||||
@@ -48,7 +48,6 @@ pub async fn upload(
|
|||||||
let mut multipart =
|
let mut multipart =
|
||||||
multer::Multipart::with_constraints(body.into_data_stream(), boundary, constraints);
|
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 mime = "application/octet-stream".to_string();
|
||||||
let mut size: u64 = 0;
|
let mut size: u64 = 0;
|
||||||
let mut has_file = false;
|
let mut has_file = false;
|
||||||
@@ -68,7 +67,6 @@ pub async fn upload(
|
|||||||
if field.file_name().is_some() {
|
if field.file_name().is_some() {
|
||||||
if !has_file {
|
if !has_file {
|
||||||
has_file = true;
|
has_file = true;
|
||||||
filename = field.file_name().unwrap_or("").to_string();
|
|
||||||
let ct = field
|
let ct = field
|
||||||
.content_type()
|
.content_type()
|
||||||
.map(|m| m.as_ref().to_string())
|
.map(|m| m.as_ref().to_string())
|
||||||
@@ -116,6 +114,20 @@ pub async fn upload(
|
|||||||
outcome = Outcome::Internal;
|
outcome = Outcome::Internal;
|
||||||
break 'fields;
|
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 {
|
} else {
|
||||||
while let Ok(Some(_)) = field.chunk().await {}
|
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");
|
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() {
|
let password_hash = if !password.trim().is_empty() {
|
||||||
security::hash_password(&password)
|
security::hash_password(&password)
|
||||||
} else {
|
} else {
|
||||||
@@ -180,11 +193,11 @@ pub async fn upload(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let now = chrono::Utc::now();
|
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 {
|
let meta = FileMeta {
|
||||||
id: id.clone(),
|
id: id.clone(),
|
||||||
original_name: original_name.clone(),
|
download_name: download_name.clone(),
|
||||||
size,
|
size,
|
||||||
mime_type: mime.clone(),
|
mime_type: mime.clone(),
|
||||||
download_key_hash: security::hash_secret(&id),
|
download_key_hash: security::hash_secret(&id),
|
||||||
@@ -206,14 +219,13 @@ pub async fn upload(
|
|||||||
None => format!("/{download_name}"),
|
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(
|
json(
|
||||||
&serde_json::json!({
|
&serde_json::json!({
|
||||||
"id": id,
|
"id": id,
|
||||||
"key": id,
|
"key": id,
|
||||||
"url": url,
|
"url": url,
|
||||||
"originalName": original_name,
|
|
||||||
"downloadName": download_name,
|
"downloadName": download_name,
|
||||||
"size": size,
|
"size": size,
|
||||||
"mimeType": mime,
|
"mimeType": mime,
|
||||||
|
|||||||
+39
-21
@@ -177,21 +177,6 @@ pub fn parse_auth_token(token: &str, secret: &str) -> Option<String> {
|
|||||||
Some(key.to_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 {
|
pub fn sanitize_mime(value: &str) -> String {
|
||||||
let cleaned: String = value
|
let cleaned: String = value
|
||||||
.chars()
|
.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 {
|
/// Derive a short, safe file extension from a MIME type. Used to build the
|
||||||
match original_name.rfind('.') {
|
/// public download name from the server-generated id, so the client-provided
|
||||||
Some(dot) if dot > 0 && dot < original_name.len() - 1 => {
|
/// original filename is never relied upon (or preserved).
|
||||||
format!("{key}.{}", &original_name[dot + 1..])
|
pub fn extension_from_mime(mime_type: &str) -> &'static str {
|
||||||
}
|
let mime = mime_type.split(';').next().unwrap_or("").trim().to_ascii_lowercase();
|
||||||
_ => key.to_string(),
|
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
|
/// 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.
|
/// a download. Restricted to inert media types so the XSS protection is kept.
|
||||||
pub fn should_inline(mime_type: &str) -> bool {
|
pub fn should_inline(mime_type: &str) -> bool {
|
||||||
|
|||||||
+33
-1
@@ -10,7 +10,7 @@ use crate::security::is_valid_key;
|
|||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct FileMeta {
|
pub struct FileMeta {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub original_name: String,
|
pub download_name: String,
|
||||||
pub size: u64,
|
pub size: u64,
|
||||||
pub mime_type: String,
|
pub mime_type: String,
|
||||||
pub download_key_hash: String,
|
pub download_key_hash: String,
|
||||||
@@ -90,6 +90,38 @@ impl Storage {
|
|||||||
self.files_dir.join(format!("{id}.part"))
|
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 {
|
fn meta_path(&self, id: &str) -> PathBuf {
|
||||||
self.metadata_dir.join(format!("{id}.json"))
|
self.metadata_dir.join(format!("{id}.json"))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -53,8 +53,7 @@
|
|||||||
id: data.id,
|
id: data.id,
|
||||||
key: data.key,
|
key: data.key,
|
||||||
url: data.url,
|
url: data.url,
|
||||||
originalName: data.originalName,
|
downloadName: data.downloadName || '',
|
||||||
downloadName: data.downloadName || data.originalName,
|
|
||||||
mimeType: data.mimeType || '',
|
mimeType: data.mimeType || '',
|
||||||
size: data.size,
|
size: data.size,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
async function onDelete(item) {
|
async function onDelete(item) {
|
||||||
const ok = window.confirm(
|
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;
|
if (!ok) return;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
let copied = false;
|
let copied = false;
|
||||||
|
|
||||||
$: name = item.originalName || item.downloadName || 'file';
|
$: name = item.downloadName || 'file';
|
||||||
|
|
||||||
async function copy() {
|
async function copy() {
|
||||||
await copyText(downloadUrl(item));
|
await copyText(downloadUrl(item));
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
let copied = false;
|
let copied = false;
|
||||||
|
|
||||||
$: url = result ? downloadUrl(result) : '';
|
$: url = result ? downloadUrl(result) : '';
|
||||||
$: name = result ? result.originalName || result.downloadName || 'file' : '';
|
$: name = result ? result.downloadName || 'file' : '';
|
||||||
|
|
||||||
async function copy() {
|
async function copy() {
|
||||||
await copyText(url);
|
await copyText(url);
|
||||||
|
|||||||
+19
-1
@@ -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) {
|
export function uploadFile(file, password, onProgress) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const xhr = new XMLHttpRequest();
|
const xhr = new XMLHttpRequest();
|
||||||
const form = new FormData();
|
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);
|
if (password) form.append('password', password);
|
||||||
form.append('file', file);
|
form.append('file', renamed);
|
||||||
|
|
||||||
xhr.upload.addEventListener('progress', (e) => {
|
xhr.upload.addEventListener('progress', (e) => {
|
||||||
if (e.lengthComputable && onProgress) onProgress(e.loaded, e.total);
|
if (e.lengthComputable && onProgress) onProgress(e.loaded, e.total);
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export function forcedDownloadUrl(item) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function displayName(item) {
|
export function displayName(item) {
|
||||||
return item.downloadName || item.originalName || 'file';
|
return item.downloadName || 'file';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fileExtension(item) {
|
export function fileExtension(item) {
|
||||||
@@ -20,7 +20,7 @@ export function fileExtension(item) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function linkLabel(item) {
|
export function linkLabel(item) {
|
||||||
return item.originalName || item.downloadName || 'file';
|
return item.downloadName || 'file';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildLinkFormats(item) {
|
export function buildLinkFormats(item) {
|
||||||
|
|||||||
Reference in New Issue
Block a user