Add SHSF-backed contact form #1
6
.shsf.json
Normal file
6
.shsf.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"default": {
|
||||||
|
"id": "89",
|
||||||
|
"from": "shsf/contact-api"
|
||||||
|
}
|
||||||
|
}
|
||||||
180
shsf/contact-api/main.py
Normal file
180
shsf/contact-api/main.py
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from _db_com import database
|
||||||
|
|
||||||
|
RATE_LIMIT_FILE = "/app/ratelimit.json"
|
||||||
|
RATE_LIMIT_WINDOW_SECONDS = 60 * 60
|
||||||
|
RATE_LIMIT_MAX_REQUESTS = 5
|
||||||
|
ALLOWED_ORIGINS = {
|
||||||
|
"https://luna.reversed.dev",
|
||||||
|
"http://localhost:5173",
|
||||||
|
"http://localhost:4173",
|
||||||
|
}
|
||||||
|
EMAIL_RE = re.compile(r"^[^\s@]+@[^\s@]+\.[^\s@]+$")
|
||||||
|
|
||||||
|
|
||||||
|
def _cors_headers(origin=""):
|
||||||
|
allowed_origin = origin if origin in ALLOWED_ORIGINS else "https://luna.reversed.dev"
|
||||||
|
return {
|
||||||
|
"Access-Control-Allow-Origin": allowed_origin,
|
||||||
|
"Access-Control-Allow-Methods": "POST, OPTIONS",
|
||||||
|
"Access-Control-Allow-Headers": "Content-Type",
|
||||||
|
"Access-Control-Max-Age": "86400",
|
||||||
|
"Vary": "Origin",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _response(origin, status_code, payload):
|
||||||
|
return {
|
||||||
|
"_shsf": "v2",
|
||||||
|
"_code": status_code,
|
||||||
|
"_headers": _cors_headers(origin),
|
||||||
|
"_res": payload,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _load_rate_limits():
|
||||||
|
if not os.path.exists(RATE_LIMIT_FILE):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(RATE_LIMIT_FILE, "r", encoding="utf-8") as file:
|
||||||
|
data = json.load(file)
|
||||||
|
return data if isinstance(data, dict) else {}
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _save_rate_limits(rate_limits):
|
||||||
|
os.makedirs(os.path.dirname(RATE_LIMIT_FILE), exist_ok=True)
|
||||||
|
temp_path = f"{RATE_LIMIT_FILE}.tmp"
|
||||||
|
with open(temp_path, "w", encoding="utf-8") as file:
|
||||||
|
json.dump(rate_limits, file)
|
||||||
|
os.replace(temp_path, RATE_LIMIT_FILE)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_ip(args):
|
||||||
|
headers = args.get("headers", {}) or {}
|
||||||
|
forwarded = headers.get("x-forwarded-for", "")
|
||||||
|
if forwarded:
|
||||||
|
return forwarded.split(",")[0].strip()
|
||||||
|
|
||||||
|
for header in ("cf-connecting-ip", "x-real-ip"):
|
||||||
|
value = headers.get(header, "").strip()
|
||||||
|
if value:
|
||||||
|
return value
|
||||||
|
|
||||||
|
return "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_payload(payload):
|
||||||
|
username = str(payload.get("username", "")).strip()
|
||||||
|
email = str(payload.get("email", "")).strip()
|
||||||
|
message = str(payload.get("message", "")).strip()
|
||||||
|
|
||||||
|
if len(username) < 2 or len(username) > 80:
|
||||||
|
return None, "Username must be between 2 and 80 characters."
|
||||||
|
if len(email) < 3 or len(email) > 200 or not EMAIL_RE.match(email):
|
||||||
|
return None, "Please provide a valid email address."
|
||||||
|
if len(message) < 10 or len(message) > 5000:
|
||||||
|
return None, "Message must be between 10 and 5000 characters."
|
||||||
|
|
||||||
|
return {
|
||||||
|
"username": username,
|
||||||
|
"email": email,
|
||||||
|
"message": message,
|
||||||
|
}, None
|
||||||
|
|
||||||
|
|
||||||
|
def _check_rate_limit(ip_address, now):
|
||||||
|
rate_limits = _load_rate_limits()
|
||||||
|
fresh_after = now - RATE_LIMIT_WINDOW_SECONDS
|
||||||
|
cleaned = {
|
||||||
|
ip: timestamps
|
||||||
|
for ip, timestamps in rate_limits.items()
|
||||||
|
if isinstance(timestamps, list)
|
||||||
|
for timestamps in [[ts for ts in timestamps if isinstance(ts, (int, float)) and ts >= fresh_after]]
|
||||||
|
if timestamps
|
||||||
|
}
|
||||||
|
|
||||||
|
ip_timestamps = cleaned.get(ip_address, [])
|
||||||
|
if len(ip_timestamps) >= RATE_LIMIT_MAX_REQUESTS:
|
||||||
|
retry_after = max(1, int(RATE_LIMIT_WINDOW_SECONDS - (now - ip_timestamps[0])))
|
||||||
|
_save_rate_limits(cleaned)
|
||||||
|
return False, retry_after
|
||||||
|
|
||||||
|
ip_timestamps.append(now)
|
||||||
|
cleaned[ip_address] = ip_timestamps
|
||||||
|
_save_rate_limits(cleaned)
|
||||||
|
return True, None
|
||||||
|
|
||||||
|
|
||||||
|
def main(args):
|
||||||
|
origin = (args.get("headers", {}) or {}).get("origin", "")
|
||||||
|
method = str(args.get("method", "POST")).upper()
|
||||||
|
|
||||||
|
if method == "OPTIONS":
|
||||||
|
return _response(origin, 204, "")
|
||||||
|
|
||||||
|
if method != "POST":
|
||||||
|
return _response(origin, 405, {"ok": False, "error": "Method not allowed."})
|
||||||
|
|
||||||
|
raw_body = args.get("body", "{}")
|
||||||
|
try:
|
||||||
|
payload = raw_body if isinstance(raw_body, dict) else json.loads(raw_body)
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
raise ValueError("JSON body must be an object")
|
||||||
|
except (TypeError, json.JSONDecodeError, ValueError):
|
||||||
|
return _response(origin, 400, {"ok": False, "error": "Invalid JSON payload."})
|
||||||
|
|
||||||
|
if not payload and all(key in args for key in ("username", "email", "message")):
|
||||||
|
payload = {
|
||||||
|
"username": args.get("username", ""),
|
||||||
|
"email": args.get("email", ""),
|
||||||
|
"message": args.get("message", ""),
|
||||||
|
}
|
||||||
|
|
||||||
|
valid_payload, error_message = _validate_payload(payload)
|
||||||
|
if error_message:
|
||||||
|
return _response(origin, 400, {"ok": False, "error": error_message})
|
||||||
|
|
||||||
|
ip_address = _extract_ip(args)
|
||||||
|
now = int(time.time())
|
||||||
|
allowed, retry_after = _check_rate_limit(ip_address, now)
|
||||||
|
if not allowed:
|
||||||
|
return {
|
||||||
|
"_shsf": "v2",
|
||||||
|
"_code": 429,
|
||||||
|
"_headers": {
|
||||||
|
**_cors_headers(origin),
|
||||||
|
"Retry-After": str(retry_after),
|
||||||
|
},
|
||||||
|
"_res": {
|
||||||
|
"ok": False,
|
||||||
|
"error": "Too many messages from this IP. Please try again later.",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
db = database()
|
||||||
|
try:
|
||||||
|
db.create_storage("portfolio_contact_messages", purpose="Portfolio contact form submissions")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
submission_id = f"message_{int(time.time() * 1000)}_{uuid.uuid4().hex[:8]}"
|
||||||
|
record = {
|
||||||
|
"id": submission_id,
|
||||||
|
"username": valid_payload["username"],
|
||||||
|
"email": valid_payload["email"],
|
||||||
|
"message": valid_payload["message"],
|
||||||
|
"ip": ip_address,
|
||||||
|
"user_agent": (args.get("headers", {}) or {}).get("user-agent", ""),
|
||||||
|
"created_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(now)),
|
||||||
|
}
|
||||||
|
db.set("portfolio_contact_messages", submission_id, json.dumps(record))
|
||||||
|
|
||||||
|
return _response(origin, 201, {"ok": True, "message": "Message received.", "id": submission_id})
|
||||||
1
shsf/contact-api/requirements.txt
Normal file
1
shsf/contact-api/requirements.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
requests==2.31.0
|
||||||
@@ -1,14 +1,69 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
import { SectionWrapper } from '../components/SectionWrapper';
|
import { SectionWrapper } from '../components/SectionWrapper';
|
||||||
|
|
||||||
|
const CONTACT_API_URL = import.meta.env.VITE_CONTACT_API_URL || 'https://shsf-api.reversed.dev/api/exec/17/cba6645c-2ca2-4e7a-ad94-e6114cbde761';
|
||||||
|
|
||||||
const socialLinks = [
|
const socialLinks = [
|
||||||
{ name: 'Email', url: 'mailto:space@reversed.dev', icon: '📧', desc: 'space@reversed.dev' },
|
{ name: 'Email', url: 'mailto:space@reversed.dev', icon: '📧', desc: 'space@reversed.dev' },
|
||||||
{ name: 'GitHub / Gitea', url: 'https://gitea.reversed.dev/luna', icon: '⌨️', desc: 'gitea.reversed.dev/luna' },
|
{ name: 'GitHub / Gitea', url: 'https://gitea.reversed.dev/luna', icon: '⌨️', desc: 'gitea.reversed.dev/luna' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const initialForm = {
|
||||||
|
username: '',
|
||||||
|
email: '',
|
||||||
|
message: '',
|
||||||
|
};
|
||||||
|
|
||||||
export function Contact() {
|
export function Contact() {
|
||||||
|
const [form, setForm] = useState(initialForm);
|
||||||
|
const [status, setStatus] = useState({ type: '', message: '' });
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const handleChange = (event) => {
|
||||||
|
const { name, value } = event.target;
|
||||||
|
setForm((current) => ({ ...current, [name]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
setIsSubmitting(true);
|
||||||
|
setStatus({ type: '', message: '' });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(CONTACT_API_URL, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(form),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
const payload = data?._res && typeof data._res === 'object' ? data._res : data;
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(payload?.error || payload?.message || 'Something went wrong while sending the message.');
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus({
|
||||||
|
type: 'success',
|
||||||
|
message: payload?.message || 'Message received. A reply might take a bit, but it made it through.',
|
||||||
|
});
|
||||||
|
setForm(initialForm);
|
||||||
|
} catch (error) {
|
||||||
|
setStatus({
|
||||||
|
type: 'error',
|
||||||
|
message: error.message || 'Something went wrong while sending the message.',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section id="contact" className="py-24 px-4 bg-darker">
|
<section id="contact" className="py-24 px-4 bg-darker">
|
||||||
<div className="max-w-4xl mx-auto">
|
<div className="max-w-5xl mx-auto">
|
||||||
<SectionWrapper>
|
<SectionWrapper>
|
||||||
<div className="text-center mb-16">
|
<div className="text-center mb-16">
|
||||||
<h2 className="text-4xl font-bold mb-4">
|
<h2 className="text-4xl font-bold mb-4">
|
||||||
@@ -17,47 +72,137 @@ export function Contact() {
|
|||||||
</span>
|
</span>
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-slate-400 max-w-2xl mx-auto">
|
<p className="text-slate-400 max-w-2xl mx-auto">
|
||||||
Want to reach me? Here's how.
|
Send a message directly from the site, or use one of the links below.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</SectionWrapper>
|
</SectionWrapper>
|
||||||
|
|
||||||
<SectionWrapper delay={100}>
|
<div className="grid gap-8 lg:grid-cols-[1.15fr_0.85fr] items-start">
|
||||||
<div className="max-w-lg mx-auto space-y-4">
|
<SectionWrapper delay={100}>
|
||||||
{/* Allowlist notice */}
|
<div className="bg-dark border border-slate-800 rounded-2xl p-6 md:p-8 shadow-xl shadow-black/20">
|
||||||
<div className="p-4 bg-dark rounded-lg border border-amber-500/30 mb-6">
|
<div className="mb-6">
|
||||||
<div className="flex items-start gap-3">
|
<h3 className="text-2xl font-semibold text-slate-100 mb-2">Contact form</h3>
|
||||||
<span className="text-2xl">⚠️</span>
|
<p className="text-slate-400 text-sm">
|
||||||
|
Messages go straight into the inbox backend. No weird third-party form service nonsense.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<div className="font-medium text-amber-400 mb-1">Email Allowlist</div>
|
<label htmlFor="username" className="block text-sm font-medium text-slate-300 mb-2">
|
||||||
<p className="text-sm text-slate-400">
|
Name
|
||||||
I only receive emails from approved senders. To contact me, email{' '}
|
</label>
|
||||||
<span className="text-slate-300">space@reversed.dev</span> and ask
|
<input
|
||||||
him to add you to my allowlist.
|
id="username"
|
||||||
|
name="username"
|
||||||
|
type="text"
|
||||||
|
value={form.username}
|
||||||
|
onChange={handleChange}
|
||||||
|
minLength={2}
|
||||||
|
maxLength={80}
|
||||||
|
required
|
||||||
|
className="w-full rounded-xl border border-slate-700 bg-darker px-4 py-3 text-slate-100 placeholder:text-slate-500 focus:border-primary focus:outline-none"
|
||||||
|
placeholder="What should this site call you?"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="email" className="block text-sm font-medium text-slate-300 mb-2">
|
||||||
|
Email
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
value={form.email}
|
||||||
|
onChange={handleChange}
|
||||||
|
maxLength={200}
|
||||||
|
required
|
||||||
|
className="w-full rounded-xl border border-slate-700 bg-darker px-4 py-3 text-slate-100 placeholder:text-slate-500 focus:border-primary focus:outline-none"
|
||||||
|
placeholder="name@example.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="message" className="block text-sm font-medium text-slate-300 mb-2">
|
||||||
|
Message
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="message"
|
||||||
|
name="message"
|
||||||
|
value={form.message}
|
||||||
|
onChange={handleChange}
|
||||||
|
minLength={10}
|
||||||
|
maxLength={5000}
|
||||||
|
required
|
||||||
|
rows={6}
|
||||||
|
className="w-full rounded-xl border border-slate-700 bg-darker px-4 py-3 text-slate-100 placeholder:text-slate-500 focus:border-primary focus:outline-none resize-y"
|
||||||
|
placeholder="Say something good. Or interesting. Ideally both."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{status.message ? (
|
||||||
|
<div
|
||||||
|
className={`rounded-xl border px-4 py-3 text-sm ${
|
||||||
|
status.type === 'success'
|
||||||
|
? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-300'
|
||||||
|
: 'border-rose-500/30 bg-rose-500/10 text-rose-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{status.message}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<p className="text-xs text-slate-500 max-w-sm">
|
||||||
|
Rate limited per IP to keep spam bots from being annoying.
|
||||||
</p>
|
</p>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="inline-flex items-center justify-center rounded-xl bg-gradient-to-r from-primary to-secondary px-5 py-3 font-medium text-white transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{isSubmitting ? 'Sending...' : 'Send message'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</SectionWrapper>
|
||||||
|
|
||||||
|
<SectionWrapper delay={200}>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="p-4 bg-dark rounded-lg border border-amber-500/30 mb-6">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<span className="text-2xl">⚠️</span>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-amber-400 mb-1">Email Allowlist</div>
|
||||||
|
<p className="text-sm text-slate-400">
|
||||||
|
Direct email is still allowlisted. The form is the easier route if that whole dance is annoying.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{socialLinks.map((link) => (
|
{socialLinks.map((link) => (
|
||||||
<a
|
<a
|
||||||
key={link.name}
|
key={link.name}
|
||||||
href={link.url}
|
href={link.url}
|
||||||
target={link.url.startsWith('mailto') ? undefined : '_blank'}
|
target={link.url.startsWith('mailto') ? undefined : '_blank'}
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="flex items-center gap-4 p-4 bg-dark rounded-lg border border-slate-800 hover:border-primary transition-colors group"
|
className="flex items-center gap-4 p-4 bg-dark rounded-lg border border-slate-800 hover:border-primary transition-colors group"
|
||||||
>
|
>
|
||||||
<span className="text-3xl">{link.icon}</span>
|
<span className="text-3xl">{link.icon}</span>
|
||||||
<div>
|
<div>
|
||||||
<div className="font-medium text-slate-200 group-hover:text-primary transition-colors">
|
<div className="font-medium text-slate-200 group-hover:text-primary transition-colors">
|
||||||
{link.name}
|
{link.name}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-slate-500">{link.desc}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-slate-500">{link.desc}</div>
|
</a>
|
||||||
</div>
|
))}
|
||||||
</a>
|
</div>
|
||||||
))}
|
</SectionWrapper>
|
||||||
</div>
|
</div>
|
||||||
</SectionWrapper>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user