added admin login to frontend + obscurification for contact details
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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,4 +1,5 @@
|
||||
pub mod auth;
|
||||
pub mod config;
|
||||
pub mod contact;
|
||||
pub mod posts;
|
||||
pub mod upload;
|
||||
|
||||
+9
-1
@@ -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))
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user