From 244dc076cb69e87731326b2926e07a28b4cc1704 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Thu, 14 May 2026 17:21:34 +0200 Subject: [PATCH] added admin login to frontend + obscurification for contact details --- backend/src/error.rs | 2 + backend/src/handlers/contact.rs | 237 ++++++++++++++++++ backend/src/handlers/mod.rs | 1 + backend/src/main.rs | 10 +- backend/src/models.rs | 34 +++ frontend/src/components/react/ContactForm.tsx | 178 +++++++++++++ frontend/src/components/react/admin/Inbox.tsx | 131 ++++++++++ frontend/src/layouts/Layout.astro | 13 +- frontend/src/lib/api.ts | 9 +- frontend/src/lib/types.ts | 19 ++ frontend/src/pages/admin/messages.astro | 9 + frontend/src/pages/contact.astro | 119 ++++++--- frontend/src/pages/index.astro | 4 + 13 files changed, 722 insertions(+), 44 deletions(-) create mode 100644 backend/src/handlers/contact.rs create mode 100644 frontend/src/components/react/ContactForm.tsx create mode 100644 frontend/src/components/react/admin/Inbox.tsx create mode 100644 frontend/src/pages/admin/messages.astro diff --git a/backend/src/error.rs b/backend/src/error.rs index f6aaf55..aabc57d 100644 --- a/backend/src/error.rs +++ b/backend/src/error.rs @@ -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), } @@ -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); diff --git a/backend/src/handlers/contact.rs b/backend/src/handlers/contact.rs new file mode 100644 index 0000000..16b1820 --- /dev/null +++ b/backend/src/handlers/contact.rs @@ -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>, + headers: HeaderMap, + Json(sub): Json, +) -> Result, 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>, + 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::::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 = 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::(&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>, + Path(id): Path, + headers: HeaderMap, +) -> Result, 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 })) +} diff --git a/backend/src/handlers/mod.rs b/backend/src/handlers/mod.rs index f28e4e9..2a7741d 100644 --- a/backend/src/handlers/mod.rs +++ b/backend/src/handlers/mod.rs @@ -1,4 +1,5 @@ pub mod auth; pub mod config; +pub mod contact; pub mod posts; pub mod upload; diff --git a/backend/src/main.rs b/backend/src/main.rs index 8db5634..c242a61 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -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>>, } #[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)) diff --git a/backend/src/models.rs b/backend/src/models.rs index be7de4e..d51b451 100644 --- a/backend/src/models.rs +++ b/backend/src/models.rs @@ -138,6 +138,40 @@ pub struct CreatePostRequest { pub content: String, } +#[derive(Deserialize)] +pub struct ContactSubmission { + #[serde(default)] + pub name: Option, + #[serde(default)] + pub email: Option, + #[serde(default)] + pub subject: Option, + pub message: String, + #[serde(default)] + pub website: Option, + 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, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub email: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub subject: Option, + pub body: String, + pub received_at: i64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ip_hash: Option, +} + +#[derive(Serialize)] +pub struct ContactResponse { + pub ok: bool, +} + #[derive(Serialize)] pub struct ErrorResponse { pub error: String, diff --git a/frontend/src/components/react/ContactForm.tsx b/frontend/src/components/react/ContactForm.tsx new file mode 100644 index 0000000..40aea37 --- /dev/null +++ b/frontend/src/components/react/ContactForm.tsx @@ -0,0 +1,178 @@ +import { useEffect, useRef, useState } from 'react'; +import { submitContact, ApiError } from '../../lib/api'; + +type Status = 'idle' | 'sending' | 'sent' | 'error'; + +export default function ContactForm() { + const [name, setName] = useState(''); + const [email, setEmail] = useState(''); + const [subject, setSubject] = useState(''); + const [message, setMessage] = useState(''); + const [website, setWebsite] = useState(''); + const [status, setStatus] = useState('idle'); + const [errorMsg, setErrorMsg] = useState(''); + const startedAt = useRef(Date.now()); + + useEffect(() => { + startedAt.current = Date.now(); + }, []); + + async function handleSubmit(e: React.SyntheticEvent) { + e.preventDefault(); + if (status === 'sending') return; + if (!message.trim()) { + setStatus('error'); + setErrorMsg('Please write a message before sending.'); + return; + } + setStatus('sending'); + setErrorMsg(''); + try { + await submitContact({ + name: name.trim() || undefined, + email: email.trim() || undefined, + subject: subject.trim() || undefined, + message: message.trim(), + website, + started_at: startedAt.current, + }); + setStatus('sent'); + setName(''); + setEmail(''); + setSubject(''); + setMessage(''); + setWebsite(''); + } catch (err) { + setStatus('error'); + if (err instanceof ApiError) { + setErrorMsg(err.message || 'Something went wrong. Please try again.'); + } else { + setErrorMsg('Could not reach the server. Please try again.'); + } + } + } + + if (status === 'sent') { + return ( +
+
+ Delivered +
+

+ Your message is on its way. +

+

+ Thank you for writing in. A reply will follow when time allows. +

+ +
+ ); + } + + return ( +
+ + + {status === 'error' && ( +
+ {errorMsg} +
+ )} + +
+
+ + setName(e.target.value)} + className="field-input" + autoComplete="name" + maxLength={200} + /> +
+
+ + setEmail(e.target.value)} + className="field-input" + autoComplete="email" + maxLength={200} + /> +
+
+ +
+ + setSubject(e.target.value)} + className="field-input" + maxLength={300} + /> +
+ +
+ +