190 lines
6.2 KiB
Rust
190 lines
6.2 KiB
Rust
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: #fafafa; color: #171717; min-height: 100vh;
|
|
display: flex; align-items: center; justify-content: center; padding: 16px;
|
|
}}
|
|
.card {{
|
|
width: 100%; max-width: 400px; background: #ffffff; border: 1px solid #e5e5e5;
|
|
border-radius: 8px; padding: 28px;
|
|
}}
|
|
.brand {{ display: flex; align-items: center; gap: 10px; margin-bottom: 20px; }}
|
|
.brand img {{ width: 32px; height: 32px; }}
|
|
.brand span {{ font-size: 15px; font-weight: 600; }}
|
|
h1 {{ margin: 0 0 6px; font-size: 18px; }}
|
|
p {{ margin: 0 0 18px; color: #737373; font-size: 14px; word-break: break-all; }}
|
|
.err {{ margin: 0 0 14px; color: #dc2626; background: #fef2f2;
|
|
border: 1px solid #fecaca; border-radius: 6px; padding: 10px 12px; }}
|
|
input[type="password"] {{
|
|
width: 100%; background: #ffffff; border: 1px solid #e5e5e5; color: #171717;
|
|
border-radius: 6px; padding: 11px 12px; font-size: 14px; margin-bottom: 12px;
|
|
}}
|
|
input[type="password"]:focus {{ outline: none; border-color: #171717; }}
|
|
button {{ width: 100%; font: inherit; cursor: pointer; border: none; border-radius: 6px;
|
|
padding: 11px 14px; font-size: 14px; font-weight: 500; background: #171717; color: #fff; }}
|
|
button:hover {{ background: #0a0a0a; }}
|
|
</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('\'', "'")
|
|
}
|