init elas atelier #1
@@ -11,6 +11,7 @@ pub enum AppError {
|
|||||||
Unauthorized,
|
Unauthorized,
|
||||||
NotFound(String),
|
NotFound(String),
|
||||||
BadRequest(String),
|
BadRequest(String),
|
||||||
|
TooManyRequests(String),
|
||||||
/// (public_message, internal_details) — details are logged but not returned.
|
/// (public_message, internal_details) — details are logged but not returned.
|
||||||
Internal(String, Option<String>),
|
Internal(String, Option<String>),
|
||||||
}
|
}
|
||||||
@@ -21,6 +22,7 @@ impl IntoResponse for AppError {
|
|||||||
AppError::Unauthorized => (StatusCode::UNAUTHORIZED, "Unauthorized".to_string()),
|
AppError::Unauthorized => (StatusCode::UNAUTHORIZED, "Unauthorized".to_string()),
|
||||||
AppError::NotFound(msg) => (StatusCode::NOT_FOUND, msg),
|
AppError::NotFound(msg) => (StatusCode::NOT_FOUND, msg),
|
||||||
AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg),
|
AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg),
|
||||||
|
AppError::TooManyRequests(msg) => (StatusCode::TOO_MANY_REQUESTS, msg),
|
||||||
AppError::Internal(msg, details) => {
|
AppError::Internal(msg, details) => {
|
||||||
if let Some(d) = details {
|
if let Some(d) = details {
|
||||||
error!("Internal error: {} — {}", msg, d);
|
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 auth;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
|
pub mod contact;
|
||||||
pub mod posts;
|
pub mod posts;
|
||||||
pub mod upload;
|
pub mod upload;
|
||||||
|
|||||||
+9
-1
@@ -9,7 +9,7 @@ use axum::{
|
|||||||
http::{HeaderValue, header},
|
http::{HeaderValue, header},
|
||||||
routing::{delete, get, post},
|
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 tokio::sync::Mutex;
|
||||||
use tower_http::{
|
use tower_http::{
|
||||||
cors::{AllowOrigin, CorsLayer},
|
cors::{AllowOrigin, CorsLayer},
|
||||||
@@ -22,6 +22,7 @@ pub struct AppState {
|
|||||||
pub data_dir: PathBuf,
|
pub data_dir: PathBuf,
|
||||||
pub cookie_secure: bool,
|
pub cookie_secure: bool,
|
||||||
pub post_lock: Mutex<()>,
|
pub post_lock: Mutex<()>,
|
||||||
|
pub contact_rate_limit: Mutex<HashMap<String, Vec<i64>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
@@ -61,6 +62,7 @@ async fn main() {
|
|||||||
data_dir,
|
data_dir,
|
||||||
cookie_secure,
|
cookie_secure,
|
||||||
post_lock: Mutex::new(()),
|
post_lock: Mutex::new(()),
|
||||||
|
contact_rate_limit: Mutex::new(HashMap::new()),
|
||||||
});
|
});
|
||||||
|
|
||||||
// CORS — locked down by default. Set FRONTEND_ORIGIN to the public URL of
|
// 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),
|
delete(handlers::upload::delete_upload),
|
||||||
)
|
)
|
||||||
.route("/api/upload", post(handlers::upload::upload_file))
|
.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" }))
|
.route("/healthz", get(|| async { "ok" }))
|
||||||
.nest_service("/uploads", ServeDir::new(uploads_dir))
|
.nest_service("/uploads", ServeDir::new(uploads_dir))
|
||||||
.layer(DefaultBodyLimit::max(50 * 1024 * 1024))
|
.layer(DefaultBodyLimit::max(50 * 1024 * 1024))
|
||||||
|
|||||||
@@ -138,6 +138,40 @@ pub struct CreatePostRequest {
|
|||||||
pub content: String,
|
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)]
|
#[derive(Serialize)]
|
||||||
pub struct ErrorResponse {
|
pub struct ErrorResponse {
|
||||||
pub error: String,
|
pub error: String,
|
||||||
|
|||||||
@@ -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<Status>('idle');
|
||||||
|
const [errorMsg, setErrorMsg] = useState('');
|
||||||
|
const startedAt = useRef<number>(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 (
|
||||||
|
<div
|
||||||
|
className="glass p-8 text-center"
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
<div className="font-display italic text-[var(--green)] text-xs tracking-[0.3em] uppercase mb-3">
|
||||||
|
Delivered
|
||||||
|
</div>
|
||||||
|
<p className="font-display italic text-[var(--text)] text-xl md:text-2xl mb-4">
|
||||||
|
Your message is on its way.
|
||||||
|
</p>
|
||||||
|
<p className="font-sans text-sm text-[var(--subtext1)] mb-6">
|
||||||
|
Thank you for writing in. A reply will follow when time allows.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setStatus('idle');
|
||||||
|
startedAt.current = Date.now();
|
||||||
|
}}
|
||||||
|
className="chip chip-accent uppercase"
|
||||||
|
>
|
||||||
|
Send another
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6" noValidate>
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{ position: 'absolute', left: '-10000px', top: 'auto', width: '1px', height: '1px', overflow: 'hidden' }}
|
||||||
|
>
|
||||||
|
<label>
|
||||||
|
Website (leave empty)
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
tabIndex={-1}
|
||||||
|
autoComplete="off"
|
||||||
|
value={website}
|
||||||
|
onChange={e => setWebsite(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{status === 'error' && (
|
||||||
|
<div
|
||||||
|
className="p-4 text-sm font-display italic text-center border bg-[var(--red)]/15 text-[var(--red)] border-[var(--red)]/30"
|
||||||
|
style={{ borderRadius: 1 }}
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
{errorMsg}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid md:grid-cols-2 gap-6">
|
||||||
|
<div>
|
||||||
|
<label className="field-label" htmlFor="contact-name">Name <span className="text-[var(--subtext0)] italic normal-case tracking-normal">(optional)</span></label>
|
||||||
|
<input
|
||||||
|
id="contact-name"
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
onChange={e => setName(e.target.value)}
|
||||||
|
className="field-input"
|
||||||
|
autoComplete="name"
|
||||||
|
maxLength={200}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="field-label" htmlFor="contact-email">Email <span className="text-[var(--subtext0)] italic normal-case tracking-normal">(for a reply)</span></label>
|
||||||
|
<input
|
||||||
|
id="contact-email"
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={e => setEmail(e.target.value)}
|
||||||
|
className="field-input"
|
||||||
|
autoComplete="email"
|
||||||
|
maxLength={200}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="field-label" htmlFor="contact-subject">Subject <span className="text-[var(--subtext0)] italic normal-case tracking-normal">(optional)</span></label>
|
||||||
|
<input
|
||||||
|
id="contact-subject"
|
||||||
|
type="text"
|
||||||
|
value={subject}
|
||||||
|
onChange={e => setSubject(e.target.value)}
|
||||||
|
className="field-input"
|
||||||
|
maxLength={300}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="field-label" htmlFor="contact-message">Message</label>
|
||||||
|
<textarea
|
||||||
|
id="contact-message"
|
||||||
|
value={message}
|
||||||
|
onChange={e => setMessage(e.target.value)}
|
||||||
|
rows={7}
|
||||||
|
className="field-input"
|
||||||
|
required
|
||||||
|
maxLength={10000}
|
||||||
|
placeholder="What's on your mind?"
|
||||||
|
/>
|
||||||
|
<p className="text-[10px] font-display italic text-[var(--subtext0)] mt-2 tracking-wider">
|
||||||
|
{message.length} / 10000
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={status === 'sending'}
|
||||||
|
className="btn-stamp disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{status === 'sending' ? 'Sending…' : 'Send message'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { listMessages, deleteMessage, ApiError } from '../../../lib/api';
|
||||||
|
import type { Message } from '../../../lib/types';
|
||||||
|
|
||||||
|
export default function Inbox() {
|
||||||
|
const [messages, setMessages] = useState<Message[] | null>(null);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
const data = await listMessages();
|
||||||
|
setMessages(data);
|
||||||
|
setError('');
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof ApiError ? e.message : 'Failed to load messages.');
|
||||||
|
setMessages([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(id: string) {
|
||||||
|
if (!confirm('Delete this message? This cannot be undone.')) return;
|
||||||
|
try {
|
||||||
|
await deleteMessage(id);
|
||||||
|
setMessages(prev => (prev ?? []).filter(m => m.id !== id));
|
||||||
|
} catch (e) {
|
||||||
|
alert(e instanceof ApiError ? e.message : 'Failed to delete.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(ms: number): string {
|
||||||
|
const d = new Date(ms);
|
||||||
|
return d.toLocaleString(undefined, {
|
||||||
|
year: 'numeric', month: 'short', day: 'numeric',
|
||||||
|
hour: '2-digit', minute: '2-digit',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (messages === null) {
|
||||||
|
return <p className="font-display italic text-[var(--subtext0)]">Loading…</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="p-4 text-sm font-display italic text-center border bg-[var(--red)]/15 text-[var(--red)] border-[var(--red)]/30"
|
||||||
|
style={{ borderRadius: 1 }}
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (messages.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="glass p-12 md:p-16 text-center">
|
||||||
|
<div className="font-display italic text-[var(--subtext0)] text-xs tracking-[0.3em] uppercase mb-3">Inbox empty</div>
|
||||||
|
<p className="font-display italic text-[var(--text)] text-2xl">No messages yet.</p>
|
||||||
|
<p className="font-sans text-sm text-[var(--subtext1)] mt-3">When visitors send a note from the contact page, it'll appear here.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul className="space-y-3">
|
||||||
|
{messages.map(m => {
|
||||||
|
const isOpen = expandedId === m.id;
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={m.id}
|
||||||
|
className="border border-[var(--surface2)]/60"
|
||||||
|
style={{ borderRadius: 1 }}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setExpandedId(isOpen ? null : m.id)}
|
||||||
|
className="w-full flex flex-col md:flex-row md:items-baseline md:justify-between gap-2 md:gap-4 px-5 py-4 text-left hover:bg-[var(--surface0)]/40 transition-colors"
|
||||||
|
aria-expanded={isOpen}
|
||||||
|
>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="font-display italic text-base md:text-lg text-[var(--text)] truncate">
|
||||||
|
{m.subject || m.name || '(no subject)'}
|
||||||
|
</div>
|
||||||
|
<div className="font-display italic text-xs text-[var(--subtext0)] tracking-wider mt-1 truncate">
|
||||||
|
{m.name ? `${m.name} · ` : ''}
|
||||||
|
{m.email ?? 'no email'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="font-display italic text-xs text-[var(--subtext0)] tracking-wider shrink-0">
|
||||||
|
{formatDate(m.received_at)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
{isOpen && (
|
||||||
|
<div className="px-5 pb-5 pt-2 border-t border-[var(--surface2)]/40 space-y-4">
|
||||||
|
<pre className="font-sans whitespace-pre-wrap text-[var(--text)] text-sm leading-relaxed">
|
||||||
|
{m.body}
|
||||||
|
</pre>
|
||||||
|
<div className="flex flex-wrap items-center gap-2 pt-2">
|
||||||
|
{m.email && (
|
||||||
|
<a
|
||||||
|
href={`mailto:${m.email}${m.subject ? `?subject=${encodeURIComponent('Re: ' + m.subject)}` : ''}`}
|
||||||
|
className="chip chip-accent uppercase"
|
||||||
|
>
|
||||||
|
Reply
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => remove(m.id)}
|
||||||
|
className="chip text-[var(--red)]"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
{m.ip_hash && (
|
||||||
|
<span className="font-mono text-[10px] text-[var(--overlay0)] ml-auto" title="Hashed sender bucket">
|
||||||
|
sender: {m.ip_hash.slice(0, 8)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -167,7 +167,18 @@ const hasContact = (siteConfig.contact_links?.length ?? 0) > 0;
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-[var(--overlay0)] text-xs italic">
|
<div class="text-[var(--overlay0)] text-xs italic">
|
||||||
© {year} · {siteConfig.title}
|
©
|
||||||
|
{isAdmin ? (
|
||||||
|
<span>{year}</span>
|
||||||
|
) : (
|
||||||
|
<a
|
||||||
|
href="/admin/login"
|
||||||
|
aria-label="Sign in"
|
||||||
|
title="Sign in"
|
||||||
|
class="text-inherit no-underline hover:text-[var(--mauve)] transition-colors"
|
||||||
|
>{year}</a>
|
||||||
|
)}
|
||||||
|
· {siteConfig.title}
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { Post, SiteConfig, Asset } from './types';
|
import type { Post, SiteConfig, Asset, ContactSubmission, Message } from './types';
|
||||||
|
|
||||||
async function apiFetch<T>(path: string, options: RequestInit = {}): Promise<T> {
|
async function apiFetch<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
@@ -67,3 +67,10 @@ export const uploadAsset = (file: File) => {
|
|||||||
};
|
};
|
||||||
export const deleteAsset = (name: string) =>
|
export const deleteAsset = (name: string) =>
|
||||||
apiFetch<void>(`/uploads/${encodeURIComponent(name)}`, { method: 'DELETE' });
|
apiFetch<void>(`/uploads/${encodeURIComponent(name)}`, { method: 'DELETE' });
|
||||||
|
|
||||||
|
// Contact
|
||||||
|
export const submitContact = (data: ContactSubmission) =>
|
||||||
|
apiFetch<{ ok: boolean }>('/contact', { method: 'POST', body: JSON.stringify(data) });
|
||||||
|
export const listMessages = () => apiFetch<Message[]>('/messages');
|
||||||
|
export const deleteMessage = (id: string) =>
|
||||||
|
apiFetch<{ ok: boolean }>(`/messages/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
||||||
|
|||||||
@@ -35,3 +35,22 @@ export interface Asset {
|
|||||||
name: string;
|
name: string;
|
||||||
url: string;
|
url: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ContactSubmission {
|
||||||
|
name?: string;
|
||||||
|
email?: string;
|
||||||
|
subject?: string;
|
||||||
|
message: string;
|
||||||
|
website?: string;
|
||||||
|
started_at: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Message {
|
||||||
|
id: string;
|
||||||
|
name?: string;
|
||||||
|
email?: string;
|
||||||
|
subject?: string;
|
||||||
|
body: string;
|
||||||
|
received_at: number;
|
||||||
|
ip_hash?: string;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
---
|
||||||
|
import AdminLayout from '../../layouts/AdminLayout.astro';
|
||||||
|
import Inbox from '../../components/react/admin/Inbox';
|
||||||
|
---
|
||||||
|
|
||||||
|
<AdminLayout title="Messages">
|
||||||
|
<p slot="header-subtitle" class="mt-2 text-sm md:text-base" style="color: var(--text) !important;">Notes sent from the public contact page.</p>
|
||||||
|
<Inbox client:only="react" />
|
||||||
|
</AdminLayout>
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
---
|
---
|
||||||
import Layout from '../layouts/Layout.astro';
|
import Layout from '../layouts/Layout.astro';
|
||||||
|
import ContactForm from '../components/react/ContactForm';
|
||||||
|
|
||||||
interface ContactLink {
|
interface ContactLink {
|
||||||
kind: string;
|
kind: string;
|
||||||
@@ -32,18 +33,6 @@ try {
|
|||||||
const links: ContactLink[] = siteConfig.contact_links ?? [];
|
const links: ContactLink[] = siteConfig.contact_links ?? [];
|
||||||
const intro = siteConfig.contact_intro ?? '';
|
const intro = siteConfig.contact_intro ?? '';
|
||||||
|
|
||||||
function hrefFor(link: ContactLink): string {
|
|
||||||
const v = link.value.trim();
|
|
||||||
if (link.kind === 'email') {
|
|
||||||
return v.startsWith('mailto:') ? v : `mailto:${v}`;
|
|
||||||
}
|
|
||||||
return v;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isExternal(link: ContactLink): boolean {
|
|
||||||
return link.kind !== 'email';
|
|
||||||
}
|
|
||||||
|
|
||||||
const KIND_LABEL: Record<string, string> = {
|
const KIND_LABEL: Record<string, string> = {
|
||||||
email: 'Email',
|
email: 'Email',
|
||||||
mastodon: 'Mastodon',
|
mastodon: 'Mastodon',
|
||||||
@@ -52,6 +41,19 @@ const KIND_LABEL: Record<string, string> = {
|
|||||||
instagram: 'Instagram',
|
instagram: 'Instagram',
|
||||||
url: 'Link',
|
url: 'Link',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function b64(s: string): string {
|
||||||
|
return Buffer.from(s, 'utf-8').toString('base64');
|
||||||
|
}
|
||||||
|
|
||||||
|
function obfuscateEmail(addr: string): { user: string; host: string; display: string; u64: string; h64: string } | null {
|
||||||
|
const at = addr.indexOf('@');
|
||||||
|
if (at === -1) return null;
|
||||||
|
const user = addr.slice(0, at);
|
||||||
|
const host = addr.slice(at + 1);
|
||||||
|
const display = `${user} [at] ${host.replace(/\./g, ' [dot] ')}`;
|
||||||
|
return { user, host, display, u64: b64(user), h64: b64(host) };
|
||||||
|
}
|
||||||
---
|
---
|
||||||
|
|
||||||
<Layout title="Contact" description="Get in touch.">
|
<Layout title="Contact" description="Get in touch.">
|
||||||
@@ -74,43 +76,78 @@ const KIND_LABEL: Record<string, string> = {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{links.length === 0 && !error && (
|
|
||||||
<div class="glass p-10 text-center">
|
|
||||||
<p class="font-display italic text-[var(--subtext0)] text-lg">
|
|
||||||
No contact channels listed yet.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{links.length > 0 && (
|
{links.length > 0 && (
|
||||||
<ul class="space-y-3">
|
<ul class="space-y-3 mb-12">
|
||||||
{links.map((link) => (
|
{links.map((link) => {
|
||||||
<li>
|
const isEmail = link.kind === 'email';
|
||||||
<a
|
const obf = isEmail ? obfuscateEmail(link.value.trim()) : null;
|
||||||
href={hrefFor(link)}
|
return (
|
||||||
{...(isExternal(link) ? { target: '_blank', rel: 'noopener noreferrer me' } : {})}
|
<li>
|
||||||
class="group flex items-baseline justify-between gap-4 px-5 py-4 border border-[var(--surface2)]/60 hover:border-[var(--mauve)]/60 transition-colors"
|
<a
|
||||||
style="border-radius: 1px"
|
href={obf ? '#' : link.value}
|
||||||
>
|
{...(!isEmail ? { target: '_blank', rel: 'noopener noreferrer me' } : {})}
|
||||||
<div class="min-w-0">
|
{...(obf ? { 'data-mail': '', 'data-mail-u': obf.u64, 'data-mail-h': obf.h64 } : {})}
|
||||||
<div class="font-display italic text-xs uppercase tracking-[0.25em] text-[var(--subtext0)] mb-1">
|
class="group grid md:grid-cols-[1fr_minmax(0,auto)] gap-2 md:gap-6 px-5 md:px-6 py-4 md:items-baseline border border-[var(--surface2)]/60 hover:border-[var(--mauve)]/60 transition-colors"
|
||||||
{KIND_LABEL[link.kind] ?? link.kind}
|
style="border-radius: 1px"
|
||||||
|
>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<div class="font-display italic text-[11px] uppercase tracking-[0.18em] text-[var(--subtext0)] mb-1 pr-1">
|
||||||
|
{KIND_LABEL[link.kind] ?? link.kind}
|
||||||
|
</div>
|
||||||
|
<div class="font-display italic text-xl md:text-2xl text-[var(--text)] group-hover:text-[var(--mauve)] transition-colors break-words">
|
||||||
|
{link.label}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="font-display italic text-xl md:text-2xl text-[var(--text)] group-hover:text-[var(--mauve)] transition-colors truncate">
|
<div
|
||||||
{link.label}
|
class="font-mono text-xs text-[var(--subtext0)] md:text-right break-words min-w-0"
|
||||||
|
title={obf ? obf.display : link.value}
|
||||||
|
data-mail-text={obf ? '' : undefined}
|
||||||
|
>
|
||||||
|
{obf ? obf.display : link.value}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</a>
|
||||||
<div class="font-mono text-xs text-[var(--subtext0)] hidden md:block truncate max-w-[40%]" title={link.value}>
|
</li>
|
||||||
{link.value}
|
);
|
||||||
</div>
|
})}
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div class="mt-8">
|
||||||
|
<h2 class="font-display italic text-2xl md:text-3xl text-[var(--text)] border-l-2 border-[var(--mauve)] pl-4 mb-6">
|
||||||
|
Or send a note directly
|
||||||
|
</h2>
|
||||||
|
<ContactForm client:load />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="section-rule mt-16">
|
<div class="section-rule mt-16">
|
||||||
<span class="ornament">✦</span>
|
<span class="ornament">✦</span>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function hydrateMail() {
|
||||||
|
document.querySelectorAll<HTMLAnchorElement>('a[data-mail]').forEach((a) => {
|
||||||
|
const u = a.dataset.mailU;
|
||||||
|
const h = a.dataset.mailH;
|
||||||
|
if (!u || !h) return;
|
||||||
|
try {
|
||||||
|
const user = atob(u);
|
||||||
|
const host = atob(h);
|
||||||
|
const addr = `${user}@${host}`;
|
||||||
|
a.href = `mailto:${addr}`;
|
||||||
|
a.querySelectorAll<HTMLElement>('[data-mail-text]').forEach((el) => {
|
||||||
|
el.textContent = addr;
|
||||||
|
el.setAttribute('title', addr);
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// leave obfuscated form
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', hydrateMail);
|
||||||
|
} else {
|
||||||
|
hydrateMail();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|||||||
@@ -91,6 +91,10 @@ const isAdmin = Astro.cookies.get('admin_session')?.value === '1';
|
|||||||
New work
|
New work
|
||||||
</a>
|
</a>
|
||||||
<AssetsButton client:load className="btn-ghost" iconSize={12} />
|
<AssetsButton client:load className="btn-ghost" iconSize={12} />
|
||||||
|
<a href="/admin/messages" class="btn-ghost">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>
|
||||||
|
Messages
|
||||||
|
</a>
|
||||||
<a href="/admin/settings" class="btn-ghost">
|
<a href="/admin/settings" class="btn-ghost">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/></svg>
|
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/></svg>
|
||||||
Settings
|
Settings
|
||||||
|
|||||||
Reference in New Issue
Block a user