added admin login to frontend + obscurification for contact details

This commit is contained in:
2026-05-14 17:21:34 +02:00
parent 0102c89d81
commit 244dc076cb
13 changed files with 722 additions and 44 deletions
+2
View File
@@ -11,6 +11,7 @@ pub enum AppError {
Unauthorized,
NotFound(String),
BadRequest(String),
TooManyRequests(String),
/// (public_message, internal_details) — details are logged but not returned.
Internal(String, Option<String>),
}
@@ -21,6 +22,7 @@ impl IntoResponse for AppError {
AppError::Unauthorized => (StatusCode::UNAUTHORIZED, "Unauthorized".to_string()),
AppError::NotFound(msg) => (StatusCode::NOT_FOUND, msg),
AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg),
AppError::TooManyRequests(msg) => (StatusCode::TOO_MANY_REQUESTS, msg),
AppError::Internal(msg, details) => {
if let Some(d) = details {
error!("Internal error: {} — {}", msg, d);
+237
View File
@@ -0,0 +1,237 @@
use axum::{
Json,
extract::{Path, State},
http::HeaderMap,
response::IntoResponse,
};
use chrono::Utc;
use std::{
collections::hash_map::DefaultHasher,
fs,
hash::{Hash, Hasher},
sync::Arc,
};
use tracing::{error, info, warn};
use crate::{
AppState,
auth::check_auth,
error::AppError,
models::{ContactResponse, ContactSubmission, Message},
};
const MIN_FILL_TIME_MS: i64 = 3_000;
const MAX_FORM_AGE_MS: i64 = 24 * 60 * 60 * 1000;
const RATE_LIMIT_WINDOW_MS: i64 = 60 * 60 * 1000;
const RATE_LIMIT_MAX: usize = 5;
const MAX_NAME: usize = 200;
const MAX_EMAIL: usize = 200;
const MAX_SUBJECT: usize = 300;
const MAX_BODY: usize = 10_000;
fn client_ip(headers: &HeaderMap) -> String {
if let Some(xff) = headers.get("x-forwarded-for").and_then(|h| h.to_str().ok()) {
if let Some(first) = xff.split(',').next() {
let s = first.trim();
if !s.is_empty() {
return s.to_string();
}
}
}
if let Some(real_ip) = headers.get("x-real-ip").and_then(|h| h.to_str().ok()) {
let s = real_ip.trim();
if !s.is_empty() {
return s.to_string();
}
}
"unknown".to_string()
}
fn hash_ip(ip: &str) -> String {
let mut h = DefaultHasher::new();
ip.hash(&mut h);
format!("{:x}", h.finish())
}
pub async fn submit_contact(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Json(sub): Json<ContactSubmission>,
) -> Result<Json<ContactResponse>, AppError> {
let now_ms = Utc::now().timestamp_millis();
let ip = client_ip(&headers);
let ip_hash = hash_ip(&ip);
{
let mut map = state.contact_rate_limit.lock().await;
let entry = map.entry(ip.clone()).or_default();
entry.retain(|t| now_ms - *t < RATE_LIMIT_WINDOW_MS);
if entry.len() >= RATE_LIMIT_MAX {
warn!("Contact rate limit hit for ip hash {}", ip_hash);
return Err(AppError::TooManyRequests(
"Too many submissions. Try again later.".into(),
));
}
entry.push(now_ms);
}
if sub
.website
.as_deref()
.map(|s| !s.trim().is_empty())
.unwrap_or(false)
{
info!("Contact honeypot triggered from ip hash {}", ip_hash);
return Ok(Json(ContactResponse { ok: true }));
}
let elapsed = now_ms - sub.started_at;
if elapsed < MIN_FILL_TIME_MS {
return Err(AppError::BadRequest(
"Submission too fast — please take a moment and try again.".into(),
));
}
if elapsed < 0 || elapsed > MAX_FORM_AGE_MS {
return Err(AppError::BadRequest(
"Form expired — refresh the page and try again.".into(),
));
}
let body = sub.message.trim().to_string();
if body.is_empty() {
return Err(AppError::BadRequest("Message cannot be empty.".into()));
}
if body.chars().count() > MAX_BODY {
return Err(AppError::BadRequest(format!(
"Message too long (max {} characters).",
MAX_BODY
)));
}
let name = sub
.name
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
let email = sub
.email
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
let subject = sub
.subject
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
if name.as_ref().is_some_and(|s| s.chars().count() > MAX_NAME) {
return Err(AppError::BadRequest("Name is too long.".into()));
}
if email.as_ref().is_some_and(|s| s.chars().count() > MAX_EMAIL) {
return Err(AppError::BadRequest("Email is too long.".into()));
}
if subject
.as_ref()
.is_some_and(|s| s.chars().count() > MAX_SUBJECT)
{
return Err(AppError::BadRequest("Subject is too long.".into()));
}
if let Some(ref e) = email {
if !e.contains('@') || !e.contains('.') {
return Err(AppError::BadRequest("Email looks invalid.".into()));
}
}
let received_at = now_ms;
let id = Utc::now()
.timestamp_nanos_opt()
.unwrap_or(received_at * 1_000_000)
.to_string();
let msg = Message {
id: id.clone(),
name,
email,
subject,
body,
received_at,
ip_hash: Some(ip_hash),
};
let messages_dir = state.data_dir.join("messages");
fs::create_dir_all(&messages_dir).map_err(|e| {
error!("Failed to create messages dir: {}", e);
AppError::Internal("Storage error".into(), Some(e.to_string()))
})?;
let path = messages_dir.join(format!("{}.json", id));
let json = serde_json::to_string_pretty(&msg).map_err(|e| {
AppError::Internal("Serialization error".into(), Some(e.to_string()))
})?;
fs::write(&path, json).map_err(|e| {
error!("Failed to write message {}: {}", id, e);
AppError::Internal("Storage error".into(), Some(e.to_string()))
})?;
info!("Stored contact message {}", id);
Ok(Json(ContactResponse { ok: true }))
}
pub async fn list_messages(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
) -> impl IntoResponse {
if let Err(e) = check_auth(&headers, &state.admin_token) {
return e.into_response();
}
let messages_dir = state.data_dir.join("messages");
if !messages_dir.exists() {
return Json(Vec::<Message>::new()).into_response();
}
let entries = match fs::read_dir(&messages_dir) {
Ok(e) => e,
Err(e) => {
error!("Failed to read messages dir: {}", e);
return AppError::Internal("Read error".into(), Some(e.to_string()))
.into_response();
}
};
let mut messages: Vec<Message> = Vec::new();
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("json") {
continue;
}
let content = match fs::read_to_string(&path) {
Ok(c) => c,
Err(e) => {
error!("Failed to read {:?}: {}", path, e);
continue;
}
};
match serde_json::from_str::<Message>(&content) {
Ok(m) => messages.push(m),
Err(e) => error!("Malformed message {:?}: {}", path, e),
}
}
messages.sort_by(|a, b| b.received_at.cmp(&a.received_at));
Json(messages).into_response()
}
pub async fn delete_message(
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
headers: HeaderMap,
) -> Result<Json<ContactResponse>, AppError> {
check_auth(&headers, &state.admin_token)?;
if id.contains('/') || id.contains('\\') || id.contains("..") || id.is_empty() {
return Err(AppError::BadRequest("Invalid id.".into()));
}
let path = state.data_dir.join("messages").join(format!("{}.json", id));
if !path.exists() {
return Err(AppError::NotFound("Message not found.".into()));
}
fs::remove_file(&path).map_err(|e| {
AppError::Internal("Delete failed".into(), Some(e.to_string()))
})?;
Ok(Json(ContactResponse { ok: true }))
}
+1
View File
@@ -1,4 +1,5 @@
pub mod auth;
pub mod config;
pub mod contact;
pub mod posts;
pub mod upload;
+9 -1
View File
@@ -9,7 +9,7 @@ use axum::{
http::{HeaderValue, header},
routing::{delete, get, post},
};
use std::{env, fs, path::PathBuf, sync::Arc};
use std::{collections::HashMap, env, fs, path::PathBuf, sync::Arc};
use tokio::sync::Mutex;
use tower_http::{
cors::{AllowOrigin, CorsLayer},
@@ -22,6 +22,7 @@ pub struct AppState {
pub data_dir: PathBuf,
pub cookie_secure: bool,
pub post_lock: Mutex<()>,
pub contact_rate_limit: Mutex<HashMap<String, Vec<i64>>>,
}
#[tokio::main]
@@ -61,6 +62,7 @@ async fn main() {
data_dir,
cookie_secure,
post_lock: Mutex::new(()),
contact_rate_limit: Mutex::new(HashMap::new()),
});
// CORS — locked down by default. Set FRONTEND_ORIGIN to the public URL of
@@ -107,6 +109,12 @@ async fn main() {
delete(handlers::upload::delete_upload),
)
.route("/api/upload", post(handlers::upload::upload_file))
.route("/api/contact", post(handlers::contact::submit_contact))
.route("/api/messages", get(handlers::contact::list_messages))
.route(
"/api/messages/{id}",
delete(handlers::contact::delete_message),
)
.route("/healthz", get(|| async { "ok" }))
.nest_service("/uploads", ServeDir::new(uploads_dir))
.layer(DefaultBodyLimit::max(50 * 1024 * 1024))
+34
View File
@@ -138,6 +138,40 @@ pub struct CreatePostRequest {
pub content: String,
}
#[derive(Deserialize)]
pub struct ContactSubmission {
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub email: Option<String>,
#[serde(default)]
pub subject: Option<String>,
pub message: String,
#[serde(default)]
pub website: Option<String>,
pub started_at: i64,
}
#[derive(Serialize, Deserialize, Clone)]
pub struct Message {
pub id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub email: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub subject: Option<String>,
pub body: String,
pub received_at: i64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ip_hash: Option<String>,
}
#[derive(Serialize)]
pub struct ContactResponse {
pub ok: bool,
}
#[derive(Serialize)]
pub struct ErrorResponse {
pub error: String,