update projects and contact form

This commit is contained in:
will
2026-04-02 23:02:19 +01:00
parent ea864a99c4
commit 746e51370d
9776 changed files with 953 additions and 2075549 deletions

109
app/api/contact/route.ts Normal file
View File

@@ -0,0 +1,109 @@
import { NextResponse } from 'next/server';
import nodemailer from 'nodemailer';
const transporter = nodemailer.createTransport({
sendmail: true,
newline: 'unix',
path: '/usr/sbin/sendmail',
});
export async function POST(req: Request) {
try {
const {
name,
email,
message,
company, // honeypot
turnstileToken,
formStartedAt,
} = await req.json();
// Basic validation
if (!name || !email || !message) {
return NextResponse.json(
{ error: 'Missing required fields.' },
{ status: 400 },
);
}
// Honeypot: bots often fill this
if (company && String(company).trim().length > 0) {
return NextResponse.json({ error: 'Spam detected.' }, { status: 400 });
}
// Time-based spam check
const started = Number(formStartedAt);
if (!started || Date.now() - started < 3000) {
return NextResponse.json(
{ error: 'Submission too fast.' },
{ status: 400 },
);
}
// Turnstile validation
if (!turnstileToken) {
return NextResponse.json(
{ error: 'Missing captcha token.' },
{ status: 400 },
);
}
const secret = process.env.TURNSTILE_SECRET_KEY;
if (!secret) {
return NextResponse.json(
{ error: 'Turnstile secret not configured.' },
{ status: 500 },
);
}
const ip = req.headers.get('cf-connecting-ip') || '';
const verifyRes = await fetch(
'https://challenges.cloudflare.com/turnstile/v0/siteverify',
{
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
secret,
response: turnstileToken,
remoteip: ip,
}),
},
);
const verifyData = (await verifyRes.json()) as {
success: boolean;
'error-codes'?: string[];
};
if (!verifyData.success) {
return NextResponse.json(
{
error: 'Captcha verification failed.',
details: verifyData['error-codes'] || [],
},
{ status: 400 },
);
}
// Send email via local sendmail (Postfix/Sendmail/Exim)
await transporter.sendMail({
from: 'William March <contact@williammarch.xyz>',
to: 'your-real-inbox@example.com', // TODO: change to your inbox
replyTo: email,
subject: `New message from ${name} via williammarch.xyz`,
text: `From: ${name} <${email}>\n\n${message}`,
html: `
<p><strong>From:</strong> ${name} &lt;${email}&gt;</p>
<p><strong>Message:</strong></p>
<p>${message.replace(/\n/g, '<br/>')}</p>
`,
});
return NextResponse.json({ ok: true });
} catch (error) {
const msg =
error instanceof Error ? error.message : 'Failed to send email.';
return NextResponse.json({ error: msg }, { status: 500 });
}
}

View File

@@ -0,0 +1,43 @@
import { NextRequest, NextResponse } from 'next/server';
import { marked } from 'marked';
const GITEA_URL = process.env.GITEA_URL;
const GITEA_USERNAME = process.env.GITEA_USERNAME;
if (!GITEA_URL || !GITEA_USERNAME) {
throw new Error('GITEA_URL and GITEA_USERNAME must be set in env');
}
export async function GET(req: NextRequest) {
const repo = req.nextUrl.searchParams.get('repo');
if (!repo) {
return new NextResponse('Missing repo', { status: 400 });
}
// raw README (default branch)
const readmeUrl = `${GITEA_URL}/api/v1/repos/${GITEA_USERNAME}/${encodeURIComponent(
repo,
)}/raw/README.md`;
const res = await fetch(readmeUrl, {
headers: { Accept: 'text/plain' },
cache: 'no-store',
});
if (!res.ok) {
return new NextResponse(
`Failed to fetch README: HTTP ${res.status}`,
{ status: 502 },
);
}
const md = await res.text();
const html = marked.parse(md || '');
return new NextResponse(html, {
status: 200,
headers: {
'Content-Type': 'text/html; charset=utf-8',
},
});
}