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,
|
||||
|
||||
@@ -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 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>
|
||||
</footer>
|
||||
</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> {
|
||||
const headers: Record<string, string> = {
|
||||
@@ -67,3 +67,10 @@ export const uploadAsset = (file: File) => {
|
||||
};
|
||||
export const deleteAsset = (name: string) =>
|
||||
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;
|
||||
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 ContactForm from '../components/react/ContactForm';
|
||||
|
||||
interface ContactLink {
|
||||
kind: string;
|
||||
@@ -32,18 +33,6 @@ try {
|
||||
const links: ContactLink[] = siteConfig.contact_links ?? [];
|
||||
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> = {
|
||||
email: 'Email',
|
||||
mastodon: 'Mastodon',
|
||||
@@ -52,6 +41,19 @@ const KIND_LABEL: Record<string, string> = {
|
||||
instagram: 'Instagram',
|
||||
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.">
|
||||
@@ -74,43 +76,78 @@ const KIND_LABEL: Record<string, string> = {
|
||||
</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 && (
|
||||
<ul class="space-y-3">
|
||||
{links.map((link) => (
|
||||
<li>
|
||||
<a
|
||||
href={hrefFor(link)}
|
||||
{...(isExternal(link) ? { target: '_blank', rel: 'noopener noreferrer me' } : {})}
|
||||
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"
|
||||
style="border-radius: 1px"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<div class="font-display italic text-xs uppercase tracking-[0.25em] text-[var(--subtext0)] mb-1">
|
||||
{KIND_LABEL[link.kind] ?? link.kind}
|
||||
<ul class="space-y-3 mb-12">
|
||||
{links.map((link) => {
|
||||
const isEmail = link.kind === 'email';
|
||||
const obf = isEmail ? obfuscateEmail(link.value.trim()) : null;
|
||||
return (
|
||||
<li>
|
||||
<a
|
||||
href={obf ? '#' : link.value}
|
||||
{...(!isEmail ? { target: '_blank', rel: 'noopener noreferrer me' } : {})}
|
||||
{...(obf ? { 'data-mail': '', 'data-mail-u': obf.u64, 'data-mail-h': obf.h64 } : {})}
|
||||
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"
|
||||
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 class="font-display italic text-xl md:text-2xl text-[var(--text)] group-hover:text-[var(--mauve)] transition-colors truncate">
|
||||
{link.label}
|
||||
<div
|
||||
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 class="font-mono text-xs text-[var(--subtext0)] hidden md:block truncate max-w-[40%]" title={link.value}>
|
||||
{link.value}
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</a>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</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">
|
||||
<span class="ornament">✦</span>
|
||||
</div>
|
||||
</section>
|
||||
</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
|
||||
</a>
|
||||
<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">
|
||||
<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
|
||||
|
||||
Reference in New Issue
Block a user