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, } pub async fn auth( State(state): State, ConnectInfo(addr): ConnectInfo, Path(key): Path, headers: HeaderMap, axum::Form(form): axum::Form, ) -> 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 { "

Incorrect password. Please try again.

" } else { "" }; let safe_filename = escape_html(filename); let html = format!( r#" Protected file
logo File Drop

Protected file

Enter the password to download {safe_filename}.

{message}
"# ); 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('\'', "'") }