Part O
This commit is contained in:
@@ -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
|
||||
Generated
+1286
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
.replace('\'', "'")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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, ¶ms, &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"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user