This commit is contained in:
Anaïs Saraiva
2026-08-25 12:20:31 +02:00
parent ccc29212cd
commit abf8736fdd
59 changed files with 4228 additions and 3008 deletions
+36
View File
@@ -0,0 +1,36 @@
# Filehost configuration (Rust backend).
# Copy to .env (at the project root) and edit as needed.
# Bind address and port.
HOST=0.0.0.0
PORT=3000
# Directory where files + metadata are stored.
STORAGE_DIR=./storage
# Directory containing the built Svelte frontend (vite build output).
STATIC_DIR=./frontend/dist
# Max upload size. Units: B, KB, MB, GB, TB (or plain bytes).
MAX_FILE_SIZE=10GB
# Max fraction (0.0-1.0) of the storage disk that may be used; uploads are
# rejected with 503 above this.
MAX_STORAGE_USAGE=0.5
# Milliseconds before an upload expires and is deleted (48 hours hard maximum).
FILE_TTL_MS=172800000
# Length in characters of the upload/download key token (a-z0-9).
KEY_LENGTH=8
# Optional public base URL used to build absolute share links.
# PUBLIC_BASE_URL=https://files.example.com
# Enable per-IP rate limiting on download/delete/auth endpoints.
RATE_LIMIT_ENABLED=1
RATE_LIMIT_MAX=300
RATE_LIMIT_WINDOW_MS=60000
# Set 1 only behind a proxy that sets X-Forwarded-For.
TRUST_PROXY=0
+1286
View File
File diff suppressed because it is too large Load Diff
+30
View File
@@ -0,0 +1,30 @@
[package]
name = "filehost"
version = "1.0.0"
edition = "2021"
description = "Minimal self-hosted private file hosting service (Rust + Axum)"
[dependencies]
axum = "0.8"
tokio = { version = "1", features = ["full"] }
tokio-util = { version = "0.7", features = ["io"] }
multer = "3"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sha2 = "0.10"
scrypt = "0.11"
hmac = "0.12"
subtle = "2"
rand = "0.8"
dotenvy = "0.15"
base64 = "0.22"
chrono = { version = "0.4", features = ["serde"] }
tower = "0.5"
tower-http = { version = "0.6", features = ["fs"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
nix = { version = "0.29", features = ["fs"] }
[profile.release]
lto = true
strip = true
+151
View File
@@ -0,0 +1,151 @@
use std::path::PathBuf;
const MAX_TTL_MS: i64 = 48 * 60 * 60 * 1000; // 48 hours hard maximum
#[derive(Clone, Debug)]
pub struct Config {
pub host: String,
pub port: u16,
pub storage_dir: PathBuf,
pub static_dir: PathBuf,
pub max_file_size: u64,
pub file_ttl_ms: i64,
pub max_storage_usage: f64,
pub key_length: usize,
pub public_base_url: Option<String>,
pub rate_limit_enabled: bool,
pub rate_limit_max: usize,
pub rate_limit_window_ms: u64,
pub trust_proxy: bool,
}
pub fn parse_size(value: &str) -> Result<u64, String> {
let s = value.trim();
let split = s.find(|c: char| !c.is_ascii_digit() && c != '.');
let (num_part, unit_part) = match split {
Some(i) => (&s[..i], &s[i..]),
None => (s, ""),
};
let num: f64 = num_part
.trim()
.parse()
.map_err(|_| format!("Invalid size value: \"{value}\""))?;
let unit = unit_part.trim().to_uppercase();
let factor: f64 = match unit.as_str() {
"B" | "" => 1.0,
"K" | "KB" | "KIB" => 1024.0,
"M" | "MB" | "MIB" => 1024.0f64.powi(2),
"G" | "GB" | "GIB" => 1024.0f64.powi(3),
"T" | "TB" | "TIB" => 1024.0f64.powi(4),
_ => return Err(format!("Invalid size unit in: \"{value}\"")),
};
Ok((num * factor) as u64)
}
fn env(name: &str) -> Option<String> {
std::env::var(name).ok().filter(|v| !v.is_empty())
}
fn bool_from_env(value: Option<&str>, fallback: bool) -> bool {
match value {
Some(v) => !["0", "false", "no", "off"].contains(&v.trim().to_lowercase().as_str()),
None => fallback,
}
}
pub fn load() -> Result<Config, String> {
let port = env("PORT")
.unwrap_or_else(|| "3000".to_string())
.parse::<u16>()
.map_err(|_| format!("Invalid PORT: {:?}", env("PORT")))?;
if port == 0 {
return Err("Invalid PORT".to_string());
}
let host = env("HOST").unwrap_or_else(|| "0.0.0.0".to_string());
let storage_dir = PathBuf::from(env("STORAGE_DIR").unwrap_or_else(|| "./storage".to_string()));
let static_dir = PathBuf::from(env("STATIC_DIR").unwrap_or_else(|| "./frontend/dist".to_string()));
let max_file_size = parse_size(
&env("MAX_FILE_SIZE").unwrap_or_else(|| "10GB".to_string()),
)?;
if max_file_size < 1024 {
return Err("MAX_FILE_SIZE must be at least 1KB".to_string());
}
let file_ttl_ms_raw = env("FILE_TTL_MS")
.unwrap_or_else(|| MAX_TTL_MS.to_string())
.parse::<i64>()
.map_err(|_| "Invalid FILE_TTL_MS".to_string())?;
if file_ttl_ms_raw < 1 {
return Err("Invalid FILE_TTL_MS".to_string());
}
let file_ttl_ms = file_ttl_ms_raw.min(MAX_TTL_MS);
let max_storage_usage = env("MAX_STORAGE_USAGE")
.unwrap_or_else(|| "0.5".to_string())
.parse::<f64>()
.map_err(|_| "Invalid MAX_STORAGE_USAGE".to_string())?;
if !(0.0..=1.0).contains(&max_storage_usage) {
return Err("Invalid MAX_STORAGE_USAGE".to_string());
}
let key_length = env("KEY_LENGTH")
.unwrap_or_else(|| "8".to_string())
.parse::<usize>()
.map_err(|_| "Invalid KEY_LENGTH".to_string())?;
if !(4..=64).contains(&key_length) {
return Err("Invalid KEY_LENGTH".to_string());
}
let rate_limit_max = env("RATE_LIMIT_MAX")
.unwrap_or_else(|| "300".to_string())
.parse::<usize>()
.map_err(|_| "Invalid RATE_LIMIT_MAX".to_string())?;
if rate_limit_max < 1 {
return Err("Invalid RATE_LIMIT_MAX".to_string());
}
let rate_limit_window_ms = env("RATE_LIMIT_WINDOW_MS")
.unwrap_or_else(|| "60000".to_string())
.parse::<u64>()
.map_err(|_| "Invalid RATE_LIMIT_WINDOW_MS".to_string())?;
if rate_limit_window_ms < 1000 {
return Err("Invalid RATE_LIMIT_WINDOW_MS".to_string());
}
let trust_proxy = bool_from_env(env("TRUST_PROXY").as_deref(), false);
let rate_limit_enabled = bool_from_env(env("RATE_LIMIT_ENABLED").as_deref(), true);
let public_base_url = match env("PUBLIC_BASE_URL") {
Some(raw) => {
let normalized = raw.trim_end_matches('/').to_string();
let valid = (normalized.starts_with("http://") || normalized.starts_with("https://"))
&& !normalized[normalized.find("://").map(|i| i + 3).unwrap_or(0)..]
.contains('/')
&& !normalized.chars().any(char::is_whitespace);
if !valid {
return Err(format!("Invalid PUBLIC_BASE_URL: \"{raw}\""));
}
Some(normalized)
}
None => None,
};
Ok(Config {
host,
port,
storage_dir,
static_dir,
max_file_size,
file_ttl_ms,
max_storage_usage,
key_length,
public_base_url,
rate_limit_enabled,
rate_limit_max,
rate_limit_window_ms,
trust_proxy,
})
}
+59
View File
@@ -0,0 +1,59 @@
use axum::body::Body;
use axum::http::{header, HeaderMap, StatusCode};
use axum::response::Response;
use serde::Serialize;
use std::net::SocketAddr;
pub fn json<T: Serialize>(data: &T, status: StatusCode) -> Response {
let body = match serde_json::to_string(data) {
Ok(s) => s,
Err(_) => "{}".to_string(),
};
Response::builder()
.status(status)
.header(header::CONTENT_TYPE, "application/json; charset=utf-8")
.header("x-content-type-options", "nosniff")
.header("referrer-policy", "no-referrer")
.body(Body::from(body))
.unwrap_or_else(|_| Response::new(Body::empty()))
}
pub fn json_error(status: StatusCode, message: &str) -> Response {
json(&serde_json::json!({ "error": message }), status)
}
pub fn not_found() -> Response {
json_error(StatusCode::NOT_FOUND, "not found")
}
pub fn redirect(location: &str) -> Response {
Response::builder()
.status(StatusCode::FOUND)
.header(header::LOCATION, location)
.body(Body::empty())
.unwrap_or_else(|_| Response::new(Body::empty()))
}
/// Resolves the client IP, honoring proxy headers only when trusted.
pub fn client_ip(trust_proxy: bool, addr: SocketAddr, headers: &HeaderMap) -> String {
if trust_proxy {
if let Some(xff) = headers
.get("x-forwarded-for")
.and_then(|v| v.to_str().ok())
{
if let Some(first) = xff.split(',').next() {
let first = first.trim();
if !first.is_empty() {
return first.to_string();
}
}
}
if let Some(cf) = headers
.get("cf-connecting-ip")
.and_then(|v| v.to_str().ok())
{
return cf.to_string();
}
}
addr.ip().to_string()
}
+150
View File
@@ -0,0 +1,150 @@
mod config;
mod http;
mod ratelimit;
mod routes;
mod security;
mod storage;
use std::net::SocketAddr;
use std::sync::Arc;
use std::time::{Duration, Instant};
use axum::routing::{delete, get, post};
use axum::Router;
use config::Config;
use ratelimit::RateLimiter;
use storage::Storage;
#[derive(Clone)]
pub struct AppState {
pub config: Config,
pub storage: Arc<Storage>,
pub secret: String,
pub limiter: Arc<RateLimiter>,
pub started_at: Instant,
}
fn build_router(state: AppState) -> Router {
Router::new()
.route("/api/health", get(routes::health))
.route("/api/upload", post(routes::upload::upload))
.route("/api/auth/{key}", post(routes::auth::auth))
.route("/api/files/{id}", delete(routes::delete::delete))
.fallback(routes::download::fallback)
.with_state(state)
}
#[tokio::main]
async fn main() {
dotenvy::dotenv().ok();
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "info".into()),
)
.init();
let config = match config::load() {
Ok(c) => c,
Err(e) => {
tracing::error!("configuration error: {e}");
std::process::exit(1);
}
};
security::set_key_length(config.key_length);
let storage = Arc::new(Storage::new(config.storage_dir.clone()));
if let Err(e) = storage.init().await {
tracing::error!("failed to initialize storage: {e}");
std::process::exit(1);
}
let secret = match storage.load_secret().await {
Ok(s) => s,
Err(e) => {
tracing::error!("failed to load server secret: {e}");
std::process::exit(1);
}
};
let sweep = storage.sweep().await;
if sweep.0 > 0 || sweep.1 > 0 {
tracing::info!(
"startup sweep removed {} orphaned file(s), {} orphaned metadata record(s)",
sweep.0,
sweep.1
);
}
let state = AppState {
limiter: Arc::new(RateLimiter::new(config.rate_limit_max, config.rate_limit_window_ms)),
started_at: Instant::now(),
config: config.clone(),
storage,
secret,
};
// Hourly expiration sweep.
let expiry_state = state.clone();
tokio::spawn(async move {
let mut interval = tokio::time::interval(Duration::from_secs(3600));
loop {
interval.tick().await;
let removed = expiry_state
.storage
.expire_expired(chrono::Utc::now().timestamp_millis())
.await;
if removed > 0 {
tracing::info!("expiry sweep removed {removed} file(s)");
}
}
});
let app = build_router(state);
let addr = format!("{}:{}", config.host, config.port);
let listener = match tokio::net::TcpListener::bind(&addr).await {
Ok(l) => l,
Err(e) => {
tracing::error!("failed to bind {addr}: {e}");
std::process::exit(1);
}
};
tracing::info!(
"listening on http://{} storage={:?} maxUpload={}",
addr,
config.storage_dir,
config.max_file_size
);
axum::serve(
listener,
app.into_make_service_with_connect_info::<SocketAddr>(),
)
.with_graceful_shutdown(shutdown_signal())
.await
.expect("server error");
}
async fn shutdown_signal() {
let ctrl_c = async {
let _ = tokio::signal::ctrl_c().await;
};
#[cfg(unix)]
let terminate = async {
let _ = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
.expect("failed to install SIGTERM handler")
.recv()
.await;
};
#[cfg(not(unix))]
let terminate = std::future::pending::<()>();
tokio::select! {
_ = ctrl_c => {},
_ = terminate => {},
}
tracing::info!("shutdown signal received");
}
+48
View File
@@ -0,0 +1,48 @@
use std::collections::HashMap;
use std::sync::Mutex;
use std::time::{Duration, Instant};
struct Bucket {
count: usize,
reset_at: Instant,
}
/// Simple per-IP fixed-window rate limiter (in-memory).
pub struct RateLimiter {
max: usize,
window: Duration,
buckets: Mutex<HashMap<String, Bucket>>,
}
impl RateLimiter {
pub fn new(max: usize, window_ms: u64) -> Self {
Self {
max,
window: Duration::from_millis(window_ms),
buckets: Mutex::new(HashMap::new()),
}
}
/// Returns `true` if the request is allowed, `false` if rate limited.
pub fn check(&self, ip: &str) -> bool {
let now = Instant::now();
let mut buckets = self.buckets.lock().unwrap();
if buckets.len() > 10_000 {
buckets.retain(|_, b| b.reset_at > now);
}
let window = self.window;
let max = self.max;
let bucket = buckets
.entry(ip.to_string())
.or_insert_with(|| Bucket {
count: 0,
reset_at: now + window,
});
if bucket.reset_at <= now {
bucket.count = 0;
bucket.reset_at = now + window;
}
bucket.count += 1;
bucket.count <= max
}
}
+189
View File
@@ -0,0 +1,189 @@
use std::net::SocketAddr;
use axum::extract::{ConnectInfo, Path, State};
use axum::http::{header, HeaderMap, HeaderValue, StatusCode};
use axum::response::Response;
use serde::Deserialize;
use crate::http::{client_ip, not_found, redirect};
use crate::{security, AppState};
pub const AUTH_COOKIE: &str = "fh_auth";
pub const AUTH_TTL_MS: i64 = 30 * 60 * 1000;
fn parse_cookie<'a>(header: Option<&'a HeaderValue>, name: &str) -> Option<&'a str> {
let header = header?.to_str().ok()?;
for part in header.split(';') {
let part = part.trim();
if let Some((k, v)) = part.split_once('=') {
if k.trim() == name {
return Some(v.trim());
}
}
}
None
}
pub fn has_valid_auth(headers: &HeaderMap, secret: &str, key: &str) -> bool {
match parse_cookie(headers.get(header::COOKIE), AUTH_COOKIE) {
Some(token) => security::parse_auth_token(token, secret)
.map(|k| k == key)
.unwrap_or(false),
None => false,
}
}
fn is_secure(trust_proxy: bool, headers: &HeaderMap) -> bool {
if trust_proxy {
return headers
.get("x-forwarded-proto")
.and_then(|v| v.to_str().ok())
== Some("https");
}
false
}
#[derive(Deserialize)]
pub struct AuthForm {
password: Option<String>,
}
pub async fn auth(
State(state): State<AppState>,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
Path(key): Path<String>,
headers: HeaderMap,
axum::Form(form): axum::Form<AuthForm>,
) -> Response {
let ip = client_ip(state.config.trust_proxy, addr, &headers);
if state.config.rate_limit_enabled && !state.limiter.check(&ip) {
return crate::http::json_error(StatusCode::TOO_MANY_REQUESTS, "too many requests");
}
if !security::is_valid_key(&key) {
return not_found();
}
let meta = match state.storage.read_metadata(&key).await {
Some(m) => m,
None => return not_found(),
};
if meta.password_hash.is_none() {
return redirect(&format!("/{key}"));
}
let password = form.password.unwrap_or_default();
let ok = match &meta.password_hash {
Some(hash) => security::password_matches(hash, &password),
None => false,
};
if !ok {
tracing::warn!("auth failed id={key}");
return redirect(&format!("/{key}?e=1"));
}
let token = security::make_auth_token(&key, &state.secret, AUTH_TTL_MS);
let secure = if is_secure(state.config.trust_proxy, &headers) {
"; Secure"
} else {
""
};
let cookie = format!(
"{AUTH_COOKIE}={token}; Path=/; HttpOnly; SameSite=Lax{secure}; Max-Age={}",
AUTH_TTL_MS / 1000
);
tracing::info!("auth ok id={key}");
Response::builder()
.status(StatusCode::FOUND)
.header(header::LOCATION, format!("/{key}"))
.header(header::SET_COOKIE, cookie)
.body(axum::body::Body::empty())
.unwrap_or_else(|_| redirect(&format!("/{key}")))
}
pub fn render_password_form(key: &str, filename: &str, error: bool) -> Response {
let message = if error {
"<p class=\"err\">Incorrect password. Please try again.</p>"
} else {
""
};
let safe_filename = escape_html(filename);
let html = format!(
r#"<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="referrer" content="no-referrer" />
<title>Protected file</title>
<style>
* {{ box-sizing: border-box; }}
body {{
margin: 0; font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
background: #0e1013; color: #e7eaf0; min-height: 100vh;
display: flex; align-items: center; justify-content: center; padding: 16px;
}}
.card {{
width: 100%; max-width: 400px; background: #15181d; border: 1px solid #262b33;
border-radius: 14px; padding: 28px; box-shadow: 0 10px 40px rgba(0,0,0,.4);
}}
.brand {{ display: flex; align-items: center; gap: 10px; margin-bottom: 18px; }}
.brand img {{ width: 34px; height: 34px; }}
.brand span {{ font-size: 15px; font-weight: 650; }}
h1 {{ margin: 0 0 6px; font-size: 20px; }}
p {{ margin: 0 0 18px; color: #98a1af; font-size: 14px; word-break: break-all; }}
.err {{ margin: 0 0 14px; color: #e05555; background: rgba(224,85,85,.08);
border: 1px solid rgba(224,85,85,.3); border-radius: 8px; padding: 10px 12px; }}
input[type="password"] {{
width: 100%; background: #0e1013; border: 1px solid #262b33; color: #e7eaf0;
border-radius: 8px; padding: 12px 13px; font-size: 14px; margin-bottom: 12px;
}}
input[type="password"]:focus {{ outline: none; border-color: #3f7bf6; }}
button {{ width: 100%; font: inherit; cursor: pointer; border: none; border-radius: 8px;
padding: 12px 14px; font-size: 14px; font-weight: 600; background: #3f7bf6; color: #fff; }}
button:hover {{ background: #2f63d8; }}
</style>
</head>
<body>
<main class="card">
<div class="brand">
<img src="/logo.svg" alt="logo" />
<span>File Drop</span>
</div>
<h1>Protected file</h1>
<p>Enter the password to download <strong>{safe_filename}</strong>.</p>
{message}
<form method="post" action="/api/auth/{key}">
<input type="password" name="password" placeholder="Password" autofocus autocomplete="off" />
<button type="submit">Download</button>
</form>
</main>
</body>
</html>"#
);
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "text/html; charset=utf-8")
.header("cache-control", "private, no-store")
.header("x-content-type-options", "nosniff")
.header("referrer-policy", "no-referrer")
.header(
"content-security-policy",
"default-src 'none'; style-src 'unsafe-inline'; img-src 'self'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'",
)
.body(axum::body::Body::from(html))
.unwrap_or_else(|_| not_found())
}
fn escape_html(value: &str) -> String {
value
.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&#39;")
}
+42
View File
@@ -0,0 +1,42 @@
use std::net::SocketAddr;
use axum::extract::{ConnectInfo, Path, State};
use axum::http::{HeaderMap, StatusCode};
use axum::response::Response;
use crate::http::{client_ip, json, json_error, not_found};
use crate::{security, AppState};
pub async fn delete(
State(state): State<AppState>,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
Path(id): Path<String>,
headers: HeaderMap,
) -> Response {
let ip = client_ip(state.config.trust_proxy, addr, &headers);
if state.config.rate_limit_enabled && !state.limiter.check(&ip) {
return json_error(StatusCode::TOO_MANY_REQUESTS, "too many requests");
}
if !security::is_valid_key(&id) {
return not_found();
}
let meta = match state.storage.read_metadata(&id).await {
Some(m) => m,
None => return not_found(),
};
let key = match headers.get("x-delete-key").and_then(|v| v.to_str().ok()) {
Some(k) if security::is_valid_key(k) => k,
_ => return not_found(),
};
if !security::secret_matches(&meta.download_key_hash, key) {
return not_found();
}
state.storage.delete(&id).await;
tracing::info!("deleted id={id} name={:?}", meta.original_name);
json(&serde_json::json!({ "ok": true }), StatusCode::OK)
}
+129
View File
@@ -0,0 +1,129 @@
use std::net::SocketAddr;
use axum::body::Body;
use axum::extract::{ConnectInfo, State};
use axum::http::{Method, Request, StatusCode};
use axum::response::Response;
use tower::ServiceExt;
use tower_http::services::ServeDir;
use crate::http::{client_ip, json_error, not_found};
use crate::routes::auth::{has_valid_auth, render_password_form};
use crate::{security, storage, AppState};
pub async fn handle_download(
state: AppState,
req: Request<Body>,
key: String,
ip: String,
) -> Response {
if state.config.rate_limit_enabled && !state.limiter.check(&ip) {
return json_error(StatusCode::TOO_MANY_REQUESTS, "too many requests");
}
let meta = match state.storage.read_metadata(&key).await {
Some(m) => m,
None => return not_found(),
};
let now_ms = chrono::Utc::now().timestamp_millis();
if storage::is_expired(&meta, now_ms) {
state.storage.delete(&key).await;
tracing::info!("download rejected id={key} expired");
return not_found();
}
if !security::secret_matches(&meta.download_key_hash, &key) {
return not_found();
}
if meta.password_hash.is_some() && !has_valid_auth(req.headers(), &state.secret, &key) {
let error = req
.uri()
.query()
.map(|q| q.split('&').any(|p| p == "e=1"))
.unwrap_or(false);
return render_password_form(&key, &meta.original_name, error);
}
let file_path = state.storage.file_path(&key);
let file_meta = match tokio::fs::symlink_metadata(&file_path).await {
Ok(m) => m,
Err(_) => return not_found(),
};
if !file_meta.file_type().is_file() {
return not_found();
}
let force_download = req
.uri()
.query()
.map(|q| q.split('&').any(|p| p == "dl=1"))
.unwrap_or(false);
let disposition = if force_download || !security::should_inline(&meta.mime_type) {
"attachment"
} else {
"inline"
};
let filename = security::download_file_name(&key, &meta.original_name);
let builder = Response::builder()
.status(StatusCode::OK)
.header("content-type", &meta.mime_type)
.header("content-length", file_meta.len().to_string())
.header(
"content-disposition",
security::content_disposition(&filename, disposition),
)
.header("cache-control", "private, no-store")
.header("x-content-type-options", "nosniff")
.header("referrer-policy", "no-referrer");
if req.method() == Method::HEAD {
return builder.body(Body::empty()).unwrap_or_else(|_| not_found());
}
let file = match tokio::fs::File::open(&file_path).await {
Ok(f) => f,
Err(_) => return not_found(),
};
let stream = tokio_util::io::ReaderStream::new(file);
tracing::info!("download id={key} size={}", file_meta.len());
builder
.body(Body::from_stream(stream))
.unwrap_or_else(|_| not_found())
}
/// Handles every unmatched route: download URLs (`/{key}` and `/d/{key}`) and
/// the static Svelte frontend.
pub async fn fallback(
State(state): State<AppState>,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
req: Request<Body>,
) -> Response {
let method = req.method().clone();
let path = req.uri().path().to_string();
let static_dir = state.config.static_dir.clone();
if method == Method::GET || method == Method::HEAD {
let raw = path
.strip_prefix("/d/")
.or_else(|| path.strip_prefix('/'))
.unwrap_or("");
if !raw.is_empty() && !raw.contains('/') {
if let Some(key) = security::key_from_path(raw) {
let ip = client_ip(state.config.trust_proxy, addr, req.headers());
return handle_download(state, req, key, ip).await;
}
}
}
if method == Method::GET || method == Method::HEAD {
return match ServeDir::new(static_dir).oneshot(req).await {
Ok(res) => res.map(axum::body::Body::new),
Err(_) => not_found(),
};
}
not_found()
}
+21
View File
@@ -0,0 +1,21 @@
pub mod auth;
pub mod delete;
pub mod download;
pub mod upload;
use axum::extract::State;
use axum::http::StatusCode;
use axum::response::Response;
use crate::http::json;
use crate::AppState;
pub async fn health(State(state): State<AppState>) -> Response {
json(
&serde_json::json!({
"status": "ok",
"uptimeSeconds": state.started_at.elapsed().as_secs(),
}),
StatusCode::OK,
)
}
+224
View File
@@ -0,0 +1,224 @@
use axum::body::Body;
use axum::extract::State;
use axum::http::{header, HeaderMap, StatusCode};
use axum::response::Response;
use tokio::io::AsyncWriteExt;
use crate::http::{json, json_error};
use crate::storage::FileMeta;
use crate::{security, AppState};
const MAX_PASSWORD_LENGTH: usize = 256;
enum Outcome {
Ok,
Multipart,
TooLarge,
Internal,
}
pub async fn upload(
State(state): State<AppState>,
headers: HeaderMap,
body: Body,
) -> Response {
let content_type = headers
.get(header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or("");
let boundary = match multer::parse_boundary(content_type) {
Ok(b) => b,
Err(_) => return json_error(StatusCode::BAD_REQUEST, "expected a multipart/form-data upload"),
};
let id = security::new_file_id();
let usage = state.storage.storage_usage_ratio().await;
if usage > state.config.max_storage_usage {
tracing::warn!(
"upload rejected id={id}: storage usage {:.1}% exceeds limit {:.0}%",
usage * 100.0,
state.config.max_storage_usage * 100.0
);
return json_error(StatusCode::SERVICE_UNAVAILABLE, "storage is full, try again later");
}
let constraints = multer::Constraints::new()
.size_limit(multer::SizeLimit::new().whole_stream(u64::MAX).per_field(u64::MAX));
let mut multipart =
multer::Multipart::with_constraints(body.into_data_stream(), boundary, constraints);
let mut filename = String::new();
let mut mime = "application/octet-stream".to_string();
let mut size: u64 = 0;
let mut has_file = false;
let mut password = String::new();
let mut outcome = Outcome::Ok;
'fields: loop {
let mut field = match multipart.next_field().await {
Ok(Some(f)) => f,
Ok(None) => break,
Err(_) => {
outcome = Outcome::Multipart;
break;
}
};
if field.file_name().is_some() {
if !has_file {
has_file = true;
filename = field.file_name().unwrap_or("").to_string();
let ct = field
.content_type()
.map(|m| m.as_ref().to_string())
.unwrap_or_else(|| "application/octet-stream".to_string());
mime = security::sanitize_mime(&ct);
let part_path = state.storage.part_path(&id);
let mut file = match tokio::fs::File::create(&part_path).await {
Ok(f) => f,
Err(_) => {
outcome = Outcome::Internal;
break 'fields;
}
};
loop {
match field.chunk().await {
Ok(Some(chunk)) => {
size += chunk.len() as u64;
if size > state.config.max_file_size {
outcome = Outcome::TooLarge;
break 'fields;
}
if file.write_all(&chunk).await.is_err() {
outcome = Outcome::Internal;
break 'fields;
}
}
Ok(None) => break,
Err(_) => {
outcome = Outcome::Internal;
break 'fields;
}
}
}
let _ = file.flush().await;
drop(file);
if matches!(outcome, Outcome::Ok) {
if tokio::fs::rename(&part_path, &state.storage.file_path(&id))
.await
.is_err()
{
outcome = Outcome::Internal;
break 'fields;
}
}
} else {
while let Ok(Some(_)) = field.chunk().await {}
}
} else {
let name = field.name().unwrap_or("").to_string();
if name == "password" && password.is_empty() {
let mut value = String::new();
loop {
match field.chunk().await {
Ok(Some(chunk)) => {
value.push_str(&String::from_utf8_lossy(&chunk));
if value.len() > MAX_PASSWORD_LENGTH {
outcome = Outcome::Multipart;
break 'fields;
}
}
Ok(None) => break,
Err(_) => {
outcome = Outcome::Multipart;
break 'fields;
}
}
}
password = value;
} else {
while let Ok(Some(_)) = field.chunk().await {}
}
}
}
if !matches!(outcome, Outcome::Ok) || !has_file {
state.storage.delete(&id).await;
state.storage.delete_partial(&id).await;
}
match outcome {
Outcome::TooLarge => {
tracing::warn!("upload rejected id={id} exceeding max size {}", state.config.max_file_size);
return json_error(StatusCode::PAYLOAD_TOO_LARGE, "file too large");
}
Outcome::Multipart => {
tracing::warn!("upload rejected id={id}: invalid multipart body");
return json_error(StatusCode::BAD_REQUEST, "invalid multipart body");
}
Outcome::Internal => {
tracing::error!("upload failed id={id}");
return json_error(StatusCode::INTERNAL_SERVER_ERROR, "upload failed");
}
Outcome::Ok => {}
}
if !has_file {
return json_error(StatusCode::BAD_REQUEST, "no file part in the upload");
}
let original_name = security::sanitize_original_name(&filename);
let password_hash = if !password.trim().is_empty() {
security::hash_password(&password)
} else {
None
};
let now = chrono::Utc::now();
let download_name = security::download_file_name(&id, &original_name);
let meta = FileMeta {
id: id.clone(),
original_name: original_name.clone(),
size,
mime_type: mime.clone(),
download_key_hash: security::hash_secret(&id),
password_hash,
created_at: now.to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
expires_at: Some(
(now + chrono::Duration::milliseconds(state.config.file_ttl_ms))
.to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
),
};
if state.storage.write_metadata(&meta).await.is_err() {
state.storage.delete(&id).await;
return json_error(StatusCode::INTERNAL_SERVER_ERROR, "upload failed");
}
let url = match &state.config.public_base_url {
Some(base) => format!("{base}/{download_name}"),
None => format!("/{download_name}"),
};
tracing::info!("upload ok id={id} size={size} name={original_name:?}");
json(
&serde_json::json!({
"id": id,
"key": id,
"url": url,
"originalName": original_name,
"downloadName": download_name,
"size": size,
"mimeType": mime,
"protected": meta.password_hash.is_some(),
}),
StatusCode::OK,
)
}
+262
View File
@@ -0,0 +1,262 @@
use base64::Engine;
use hmac::{Hmac, Mac};
use rand::RngCore;
use scrypt::{scrypt, Params};
use sha2::{Digest, Sha256};
use std::sync::atomic::{AtomicUsize, Ordering};
use subtle::ConstantTimeEq;
static KEY_LENGTH: AtomicUsize = AtomicUsize::new(8);
pub fn set_key_length(len: usize) {
KEY_LENGTH.store(len, Ordering::Relaxed);
}
pub fn key_length() -> usize {
KEY_LENGTH.load(Ordering::Relaxed)
}
const CHARSET: &[u8] = b"abcdefghijklmnopqrstuvwxyz0123456789";
const CHARSET_SIZE: usize = 36;
const MAX_BYTE: u8 = (256 / CHARSET_SIZE) as u8 * CHARSET_SIZE as u8; // 252
/// Cryptographically secure, unguessable lowercase-alphanumeric id/key.
pub fn new_file_id() -> String {
let len = key_length();
let mut out = String::with_capacity(len);
while out.len() < len {
let mut byte = [0u8; 1];
rand::rngs::OsRng.fill_bytes(&mut byte);
let b = byte[0];
if b >= MAX_BYTE {
continue;
}
out.push(CHARSET[(b as usize) % CHARSET_SIZE] as char);
}
out
}
/// A bare key of the configured length (`a-z0-9` only).
pub fn is_valid_key(key: &str) -> bool {
key.len() == key_length()
&& key
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit())
}
/// Accepts a bare key (`abc12345`) or a key with a cosmetic file extension
/// (`abc12345.png`). The key is everything before the first dot; the extension
/// is ignored for lookup and never touches the filesystem.
pub fn key_from_path(segment: &str) -> Option<String> {
if segment.is_empty() || segment.contains('/') {
return None;
}
let key = segment.split('.').next().unwrap_or("");
if is_valid_key(key) {
Some(key.to_string())
} else {
None
}
}
fn hex_encode(bytes: &[u8]) -> String {
let mut s = String::with_capacity(bytes.len() * 2);
for b in bytes {
s.push_str(&format!("{b:02x}"));
}
s
}
fn hex_decode(s: &str) -> Result<Vec<u8>, ()> {
if s.len() % 2 != 0 {
return Err(());
}
(0..s.len())
.step_by(2)
.map(|i| u8::from_str_radix(&s[i..i + 2], 16).map_err(|_| ()))
.collect()
}
pub fn hash_secret(value: &str) -> String {
let digest = Sha256::digest(value.as_bytes());
hex_encode(&digest)
}
pub fn secret_matches(stored_hex: &str, candidate: &str) -> bool {
let Ok(stored) = hex_decode(stored_hex) else {
return false;
};
let computed = Sha256::digest(candidate.as_bytes());
stored.as_slice().ct_eq(computed.as_slice()).into()
}
const SCRYPT_LOG_N: u8 = 14; // N = 16384 (matches Node's scryptSync default)
const SCRYPT_R: u32 = 8;
const SCRYPT_P: u32 = 1;
const SCRYPT_LEN: usize = 64;
fn scrypt_params() -> Result<Params, scrypt::errors::InvalidParams> {
Params::new(SCRYPT_LOG_N, SCRYPT_R, SCRYPT_P, SCRYPT_LEN)
}
/// Returns `scrypt:<salt_hex>:<hash_hex>` or `None` on parameter error.
pub fn hash_password(password: &str) -> Option<String> {
let mut salt = [0u8; 16];
rand::rngs::OsRng.fill_bytes(&mut salt);
let mut out = [0u8; SCRYPT_LEN];
scrypt(
password.as_bytes(),
&salt,
&scrypt_params().ok()?,
&mut out,
)
.ok()?;
Some(format!(
"scrypt:{}:{}",
hex_encode(&salt),
hex_encode(&out)
))
}
pub fn password_matches(stored: &str, password: &str) -> bool {
let parts: Vec<&str> = stored.split(':').collect();
if parts.len() != 3 || parts[0] != "scrypt" {
return false;
}
let Ok(salt) = hex_decode(parts[1]) else {
return false;
};
let Ok(expected) = hex_decode(parts[2]) else {
return false;
};
if expected.len() != SCRYPT_LEN {
return false;
}
let Ok(params) = scrypt_params() else {
return false;
};
let mut computed = [0u8; SCRYPT_LEN];
if scrypt(password.as_bytes(), &salt, &params, &mut computed).is_err() {
return false;
}
expected.as_slice().ct_eq(computed.as_slice()).into()
}
type HmacSha256 = Hmac<Sha256>;
fn hmac_sign(payload: &str, secret: &str) -> String {
let mut mac =
HmacSha256::new_from_slice(secret.as_bytes()).expect("HMAC accepts keys of any size");
mac.update(payload.as_bytes());
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes())
}
pub fn make_auth_token(key: &str, secret: &str, ttl_ms: i64) -> String {
let expires = chrono::Utc::now().timestamp_millis() + ttl_ms;
let payload = format!("{key}.{expires}");
format!("{payload}.{}", hmac_sign(&payload, secret))
}
pub fn parse_auth_token(token: &str, secret: &str) -> Option<String> {
let last_dot = token.rfind('.')?;
let payload = &token[..last_dot];
let signature = &token[last_dot + 1..];
let expected = hmac_sign(payload, secret);
if !bool::from(signature.as_bytes().ct_eq(expected.as_bytes())) {
return None;
}
let dot = payload.rfind('.')?;
let key = &payload[..dot];
let expires = payload[dot + 1..].parse::<i64>().ok()?;
if expires <= chrono::Utc::now().timestamp_millis() {
return None;
}
if !is_valid_key(key) {
return None;
}
Some(key.to_string())
}
pub fn sanitize_original_name(name: &str) -> String {
let base = name.replace('\\', "/");
let base = base.rsplit('/').next().unwrap_or("");
let cleaned: String = base
.chars()
.map(|c| if (c as u32) < 0x20 || c == '\u{7f}' { '_' } else { c })
.collect();
let cleaned = cleaned.trim();
if cleaned.is_empty() {
"file".to_string()
} else {
cleaned.to_string()
}
}
pub fn sanitize_mime(value: &str) -> String {
let cleaned: String = value
.chars()
.filter(|c| !matches!(c, '\r' | '\n' | '"' | '\\'))
.collect();
let cleaned = cleaned.trim();
if !cleaned.is_empty() && cleaned.len() <= 200 {
cleaned.to_string()
} else {
"application/octet-stream".to_string()
}
}
fn urlencode_utf8(s: &str) -> String {
let mut out = String::new();
for b in s.as_bytes() {
match *b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'!' | b'~' => {
out.push(*b as char)
}
_ => out.push_str(&format!("%{b:02X}")),
}
}
out
}
pub fn content_disposition(name: &str, disposition: &str) -> String {
let ascii: String = name
.chars()
.map(|c| {
if ('\u{20}'..='\u{7e}').contains(&c) && c != '"' && c != '\\' {
c
} else {
'_'
}
})
.collect();
let encoded = urlencode_utf8(name);
format!(
"{disposition}; filename=\"{ascii}\"; filename*=UTF-8''{encoded}"
)
}
pub fn download_file_name(key: &str, original_name: &str) -> String {
match original_name.rfind('.') {
Some(dot) if dot > 0 && dot < original_name.len() - 1 => {
format!("{key}.{}", &original_name[dot + 1..])
}
_ => key.to_string(),
}
}
/// Whether a file should render inline in the browser rather than be forced as
/// a download. Restricted to inert media types so the XSS protection is kept.
pub fn should_inline(mime_type: &str) -> bool {
let mime = mime_type.split(';').next().unwrap_or("").trim().to_lowercase();
if mime.is_empty() || mime == "application/octet-stream" {
return false;
}
if mime == "image/svg+xml" || mime == "text/html" || mime == "application/xhtml+xml" {
return false;
}
mime.starts_with("image/")
|| mime.starts_with("video/")
|| mime.starts_with("audio/")
|| mime == "text/plain"
|| mime == "application/pdf"
}
+216
View File
@@ -0,0 +1,216 @@
use std::path::PathBuf;
use base64::Engine;
use rand::RngCore;
use serde::{Deserialize, Serialize};
use crate::security::is_valid_key;
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FileMeta {
pub id: String,
pub original_name: String,
pub size: u64,
pub mime_type: String,
pub download_key_hash: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub password_hash: Option<String>,
pub created_at: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub expires_at: Option<String>,
}
pub fn is_expired(meta: &FileMeta, now_ms: i64) -> bool {
match &meta.expires_at {
Some(s) => chrono::DateTime::parse_from_rfc3339(s)
.map(|dt| dt.timestamp_millis() <= now_ms)
.unwrap_or(false),
None => false,
}
}
pub struct Storage {
root: PathBuf,
files_dir: PathBuf,
metadata_dir: PathBuf,
}
impl Storage {
pub fn new(root: PathBuf) -> Self {
let files_dir = root.join("files");
let metadata_dir = root.join("metadata");
Self {
root,
files_dir,
metadata_dir,
}
}
pub async fn init(&self) -> std::io::Result<()> {
tokio::fs::create_dir_all(&self.files_dir).await?;
tokio::fs::create_dir_all(&self.metadata_dir).await?;
Ok(())
}
pub async fn load_secret(&self) -> std::io::Result<String> {
let path = self.root.join(".secret");
if let Ok(existing) = tokio::fs::read_to_string(&path).await {
let existing = existing.trim().to_string();
if !existing.is_empty() {
return Ok(existing);
}
}
let mut bytes = [0u8; 32];
rand::rngs::OsRng.fill_bytes(&mut bytes);
let secret = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes);
#[cfg(unix)]
{
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
let mut opts = std::fs::OpenOptions::new();
opts.write(true).create_new(true).mode(0o600);
let mut f = opts.open(&path)?;
f.write_all(secret.as_bytes())?;
}
#[cfg(not(unix))]
{
tokio::fs::write(&path, &secret).await?;
}
Ok(secret)
}
pub fn file_path(&self, id: &str) -> PathBuf {
self.files_dir.join(id)
}
pub fn part_path(&self, id: &str) -> PathBuf {
self.files_dir.join(format!("{id}.part"))
}
fn meta_path(&self, id: &str) -> PathBuf {
self.metadata_dir.join(format!("{id}.json"))
}
pub async fn storage_usage_ratio(&self) -> f64 {
match nix::sys::statvfs::statvfs(&self.root) {
Ok(stats) => {
let total = stats.blocks() * stats.fragment_size();
let avail = stats.blocks_available() * stats.fragment_size();
if total == 0 {
return 0.0;
}
let ratio = 1.0 - (avail as f64 / total as f64);
ratio.clamp(0.0, 1.0)
}
Err(_) => 0.0,
}
}
pub async fn write_metadata(&self, meta: &FileMeta) -> std::io::Result<()> {
let target = self.meta_path(&meta.id);
let tmp = self.metadata_dir.join(format!("{}.json.tmp", meta.id));
let body = serde_json::to_string_pretty(meta)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?;
tokio::fs::write(&tmp, format!("{body}\n")).await?;
tokio::fs::rename(&tmp, &target).await
}
pub async fn read_metadata(&self, id: &str) -> Option<FileMeta> {
if !is_valid_key(id) {
return None;
}
let raw = tokio::fs::read_to_string(self.meta_path(id)).await.ok()?;
let meta: FileMeta = serde_json::from_str(&raw).ok()?;
if meta.id.is_empty() || meta.download_key_hash.is_empty() {
return None;
}
Some(meta)
}
pub async fn delete(&self, id: &str) {
let _ = tokio::fs::remove_file(self.file_path(id)).await;
let _ = tokio::fs::remove_file(self.meta_path(id)).await;
}
pub async fn delete_partial(&self, id: &str) {
let _ = tokio::fs::remove_file(self.part_path(id)).await;
}
pub async fn expire_expired(&self, now_ms: i64) -> usize {
let mut removed = 0;
let Ok(mut entries) = tokio::fs::read_dir(&self.metadata_dir).await else {
return 0;
};
while let Ok(Some(entry)) = entries.next_entry().await {
let name = entry.file_name();
let name = name.to_string_lossy();
if !name.ends_with(".json") {
continue;
}
let id = &name[..name.len() - 5];
if !is_valid_key(id) {
continue;
}
if let Some(meta) = self.read_metadata(id).await {
if is_expired(&meta, now_ms) {
self.delete(id).await;
removed += 1;
}
}
}
removed
}
/// Consistency repair: remove orphans (file without metadata, metadata
/// without file, leftover `.part` files). Never touches valid pairs.
pub async fn sweep(&self) -> (usize, usize) {
let mut removed_files = 0;
let mut removed_metadata = 0;
if let Ok(mut entries) = tokio::fs::read_dir(&self.files_dir).await {
while let Ok(Some(entry)) = entries.next_entry().await {
let name = entry.file_name();
let name = name.to_string_lossy().to_string();
if name.ends_with(".part") || !is_valid_key(&name) {
let _ = tokio::fs::remove_file(entry.path()).await;
removed_files += 1;
continue;
}
if tokio::fs::metadata(self.metadata_dir.join(format!("{name}.json")))
.await
.is_err()
{
let _ = tokio::fs::remove_file(entry.path()).await;
removed_files += 1;
}
}
}
if let Ok(mut entries) = tokio::fs::read_dir(&self.metadata_dir).await {
while let Ok(Some(entry)) = entries.next_entry().await {
let name = entry.file_name();
let name = name.to_string_lossy().to_string();
if !name.ends_with(".json") {
let _ = tokio::fs::remove_file(entry.path()).await;
removed_metadata += 1;
continue;
}
let id = &name[..name.len() - 5];
if !is_valid_key(id) {
let _ = tokio::fs::remove_file(entry.path()).await;
removed_metadata += 1;
continue;
}
if tokio::fs::metadata(self.files_dir.join(id)).await.is_err() {
let _ = tokio::fs::remove_file(entry.path()).await;
removed_metadata += 1;
}
}
}
(removed_files, removed_metadata)
}
}