update projects and contact form
This commit is contained in:
109
app/api/contact/route.ts
Normal file
109
app/api/contact/route.ts
Normal 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} <${email}></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 });
|
||||
}
|
||||
}
|
||||
43
app/api/gitea/readme/route.ts
Normal file
43
app/api/gitea/readme/route.ts
Normal 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',
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user