Part O
This commit is contained in:
@@ -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('\'', "'")
|
||||
}
|
||||
Reference in New Issue
Block a user