Compare commits

...
7 Commits
Author SHA1 Message Date
Anaïs Saraiva f4c8f89018 Update download.nix 2026-08-25 15:28:57 +02:00
Anaïs Saraiva 47cd18e8c6 fix 2026-08-25 15:18:53 +02:00
Anaïs Saraiva 35aa44a59d Update download.nix 2026-08-25 13:39:12 +02:00
Anaïs Saraiva 6549ae0eed Update download.nix 2026-08-25 13:11:32 +02:00
Anaïs Saraiva d212f87e00 updated nix 2026-08-25 13:09:51 +02:00
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
20 changed files with 3537 additions and 73 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 # 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
View File
@@ -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`.
+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_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
+5
View File
@@ -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
+3
View File
@@ -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,
}) })
} }
+1
View File
@@ -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;
+1 -1
View File
@@ -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)
} }
+2 -2
View File
@@ -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)
+19 -7
View File
@@ -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
View File
@@ -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
View File
@@ -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"))
} }
+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());
}
}
}
+134 -32
View File
@@ -6,20 +6,77 @@
}: }:
let let
cfg = config.services.download; cfg = config.services.download;
# ---- Backend: Rust binary built with nixpkgs cargo hooks (offline-safe). ----
filehostBackend = pkgs.rustPlatform.buildRustPackage {
pname = "filehost";
version = "1.0.0";
# The repository root (backend/ + frontend/). `cargoRoot` points the build
# at backend/Cargo.toml while keeping the rest of the tree available.
src = cfg.src;
cargoRoot = "backend";
# Build/test from the backend/ subdirectory. `cargoRoot` locates the
# Cargo.lock/vendor, `buildAndTestSubdir` points cargo at backend/Cargo.toml.
buildAndTestSubdir = "backend";
# Derive the dependency hash automatically from the checked-in
# backend/Cargo.lock (version 4; all deps are crates.io registry entries, so
# no extra outputHashes are needed). No manual cargoHash to maintain.
cargoLock.lockFile = "${cfg.src}/backend/Cargo.lock";
buildInputs = [ ];
nativeBuildInputs = with pkgs; [ ];
# We only need the binary; disable tests to keep the closure small.
doCheck = false;
};
# ---- Frontend: Svelte static assets built with node/npm. ----
filehostFrontend = pkgs.buildNpmPackage {
pname = "filehost-frontend";
version = "1.0.0";
# Build from the frontend/ subdirectory (contains package.json + lock).
src = "${cfg.src}/frontend";
# Hash of the npm dependency fetch (generated from frontend/package-lock.json).
npmDepsHash = "sha256-ec2rDPuTIGOMdx7oHq+At1Fez6WFCVnFaYydzVF+5Ws=";
# The default npm build script is `build` (vite build).
npmBuildScript = "build";
installPhase = ''
runHook preInstall
mkdir -p $out/share/filehost
cp -r dist $out/share/filehost/dist
runHook postInstall
'';
};
in in
{ {
options.services.download = { options.services.download = {
enable = lib.mkEnableOption "the File Drop private file host"; enable = lib.mkEnableOption "the File Drop private file host";
# The source tree (backend/ + frontend/) the package is built from. Provide
# a flake-local path (e.g. `src = ./.` or a `fetchFromGitHub` result) so the
# build stays pure and reproducible.
src = lib.mkOption {
type = lib.types.path;
description = "Path to the download source tree (backend/ + frontend/).";
};
user = lib.mkOption { user = lib.mkOption {
type = lib.types.str; type = lib.types.str;
default = "filehost";
description = "System user that runs the service."; description = "System user that runs the service.";
}; };
workingDir = lib.mkOption { group = lib.mkOption {
type = lib.types.str; type = lib.types.str;
default = "/srv/download"; default = "filehost";
description = "Directory containing the code (src/server.ts)."; description = "System group that runs the service.";
}; };
storageDir = lib.mkOption { storageDir = lib.mkOption {
@@ -59,9 +116,9 @@ in
}; };
keyLength = lib.mkOption { keyLength = lib.mkOption {
type = lib.types.ints.positive; type = lib.types.ints.between 4 64;
default = 8; default = 8;
description = "Length in characters of the upload/download key token."; description = "Length in characters of the upload/download key token (a-z0-9).";
}; };
publicBaseUrl = lib.mkOption { publicBaseUrl = lib.mkOption {
@@ -70,51 +127,95 @@ in
description = "Public domain of the site (e.g. https://files.example.com)."; description = "Public domain of the site (e.g. https://files.example.com).";
}; };
rateLimitEnabled = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Per-IP rate limiting on download/delete/auth endpoints.";
};
rateLimitMax = lib.mkOption {
type = lib.types.ints.positive;
default = 300;
description = "Max requests per rate-limit window.";
};
rateLimitWindowMs = lib.mkOption {
type = lib.types.ints.positive;
default = 60000;
description = "Rate-limit window in milliseconds.";
};
trustProxy = lib.mkEnableOption "trust of X-Forwarded-* headers (behind a reverse proxy)"; trustProxy = lib.mkEnableOption "trust of X-Forwarded-* headers (behind a reverse proxy)";
stripMetadata = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Strip embedded EXIF/GPS/author/comments and video metadata server-side.";
};
repoUrl = lib.mkOption { repoUrl = lib.mkOption {
type = lib.types.str; type = lib.types.str;
default = "https://github.com/Kisakay/download.git"; default = "https://github.com/Kisakay/download.git";
description = "Git repository to clone/pull from."; description = "Git repository to clone/pull from.";
}; };
workingDir = lib.mkOption {
type = lib.types.str;
default = "/srv/download";
description = "Directory containing the code.";
};
deployOnBoot = lib.mkOption { deployOnBoot = lib.mkOption {
type = lib.types.bool; type = lib.types.bool;
default = true; default = true;
description = "Pull the repo at boot (like the nightcord-deploy pattern)."; description = "Pull the repo at boot and rebuild (like the nightcord-deploy pattern).";
};
rebuildCommand = lib.mkOption {
type = lib.types.str;
default = "${config.system.build.nixos-rebuild}";
description = "Command (run as root) to apply the pulled code after a boot deploy. Defaults to the system's own rebuild script (switch without re-evaluating the flake).";
}; };
}; };
config = lib.mkIf cfg.enable { config = lib.mkIf cfg.enable {
systemd.tmpfiles.rules = [ systemd.tmpfiles.rules = [
"d ${cfg.storageDir} 0755 ${cfg.user} ${cfg.user} -" "d ${cfg.storageDir} 0755 ${cfg.user} ${cfg.user} -"
"d ${cfg.workingDir} 0755 ${cfg.user} ${cfg.user} -"
]; ];
systemd.services.download-deploy = { systemd.services.download-deploy = {
description = "Update download from git"; description = "Update download from git and rebuild";
after = [ "network-online.target" ]; after = [ "network-online.target" ];
wants = [ "network-online.target" ]; wants = [ "network-online.target" ];
wantedBy = lib.mkIf cfg.deployOnBoot [ "multi-user.target" ]; wantedBy = lib.mkIf cfg.deployOnBoot [ "multi-user.target" ];
path = with pkgs; [ git bun ]; path = with pkgs; [ git ];
serviceConfig = { serviceConfig = {
Type = "oneshot"; Type = "oneshot";
User = cfg.user; # Rebuild requires root (updates /run/current-system, generations, etc.).
WorkingDirectory = cfg.workingDir; WorkingDirectory = cfg.workingDir;
Environment = [ "HOME=/home/${cfg.user}" ];
ExecStartPost = "+${pkgs.systemd}/bin/systemctl restart download.service";
}; };
script = '' script = ''
set -euo pipefail set -euo pipefail
# The service runs as root but the checkout is owned by another user;
# allow git to operate on it without a persistent global config.
export GIT_CONFIG_COUNT=1
export GIT_CONFIG_KEY_0=safe.directory
export GIT_CONFIG_VALUE_0="${cfg.workingDir}"
if [ ! -d "${cfg.workingDir}/.git" ]; then if [ ! -d "${cfg.workingDir}/.git" ]; then
git clone ${cfg.repoUrl} ${cfg.workingDir} git clone ${cfg.repoUrl} ${cfg.workingDir}
else else
git -C ${cfg.workingDir} pull --ff-only git -C ${cfg.workingDir} pull --ff-only
fi fi
cd ${cfg.workingDir}
bun install # Apply the freshly-pulled code via a rebuild. The built binary comes
# from `services.download.src` (flake) and is rebuilt by this command.
${cfg.rebuildCommand}
''; '';
}; };
@@ -124,28 +225,29 @@ in
wants = [ "network-online.target" ]; wants = [ "network-online.target" ];
wantedBy = [ "multi-user.target" ]; wantedBy = [ "multi-user.target" ];
path = with pkgs; [ bun ];
serviceConfig = { serviceConfig = {
Type = "simple"; Type = "simple";
User = cfg.user; User = cfg.user;
Group = cfg.user; Group = cfg.group;
WorkingDirectory = cfg.workingDir; Environment = [
Environment = "HOST=${cfg.host}"
[ "PORT=${toString cfg.port}"
"HOME=/home/${cfg.user}" "STORAGE_DIR=${cfg.storageDir}"
"PORT=${toString cfg.port}" "STATIC_DIR=${filehostFrontend}/share/filehost/dist"
"HOST=${cfg.host}" "MAX_FILE_SIZE=${cfg.maxFileSize}"
"STORAGE_DIR=${cfg.storageDir}" "FILE_TTL_MS=${toString cfg.fileTtlMs}"
"MAX_FILE_SIZE=${cfg.maxFileSize}" "MAX_STORAGE_USAGE=${toString cfg.maxStorageUsage}"
"FILE_TTL_MS=${toString cfg.fileTtlMs}" "KEY_LENGTH=${toString cfg.keyLength}"
"MAX_STORAGE_USAGE=${toString cfg.maxStorageUsage}" "RATE_LIMIT_ENABLED=${if cfg.rateLimitEnabled then "1" else "0"}"
"KEY_LENGTH=${toString cfg.keyLength}" "RATE_LIMIT_MAX=${toString cfg.rateLimitMax}"
] "RATE_LIMIT_WINDOW_MS=${toString cfg.rateLimitWindowMs}"
++ lib.optionals (cfg.publicBaseUrl != null) [ "PUBLIC_BASE_URL=${cfg.publicBaseUrl}" ] "STRIP_METADATA=${if cfg.stripMetadata then "1" else "0"}"
++ lib.optionals cfg.trustProxy [ "TRUST_PROXY=1" ]; ]
++ lib.optionals (cfg.publicBaseUrl != null) [ "PUBLIC_BASE_URL=${cfg.publicBaseUrl}" ]
++ lib.optionals cfg.trustProxy [ "TRUST_PROXY=1" ];
ExecStart = "${filehostBackend}/bin/filehost";
ExecStart = "${pkgs.bun}/bin/bun src/server.ts";
Restart = "on-failure"; Restart = "on-failure";
RestartSec = 3; RestartSec = 3;
TimeoutStopSec = 30; TimeoutStopSec = 30;
@@ -158,4 +260,4 @@ in
}; };
}; };
}; };
} }
+2378
View File
File diff suppressed because it is too large Load Diff
+1 -2
View File
@@ -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(),
+1 -1
View File
@@ -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 {
+1 -1
View File
@@ -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));
+1 -1
View File
@@ -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
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) { 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);
+2 -2
View File
@@ -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) {