mirror of
https://github.com/jakejarvis/jarv.is.git
synced 2025-07-03 15:16:40 -04:00
Migrate to app router (#2254)
This commit is contained in:
96
app/api/contact/route.ts
Normal file
96
app/api/contact/route.ts
Normal file
@ -0,0 +1,96 @@
|
||||
import nodemailer from "nodemailer";
|
||||
import fetcher from "../../../lib/helpers/fetcher";
|
||||
import config from "../../../lib/config";
|
||||
import { headers } from "next/headers";
|
||||
import { NextResponse, NextRequest } from "next/server";
|
||||
|
||||
export async function POST(req: NextRequest): Promise<
|
||||
NextResponse<{
|
||||
success?: boolean;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
error?: any;
|
||||
} | null>
|
||||
> {
|
||||
try {
|
||||
// possible weirdness? https://github.com/orgs/vercel/discussions/78#discussioncomment-5089059
|
||||
const data = await req.formData();
|
||||
const headersList = await headers();
|
||||
|
||||
// these are both backups to client-side validations just in case someone squeezes through without them. the codes
|
||||
// are identical so they're caught in the same fashion.
|
||||
if (!data.get("name") || !data.get("email") || !data.get("message")) {
|
||||
// all fields are required
|
||||
throw new Error("missing_data");
|
||||
}
|
||||
if (
|
||||
!data.get("cf-turnstile-response") ||
|
||||
!(await validateCaptcha(
|
||||
data.get("cf-turnstile-response"),
|
||||
headersList.get("x-forwarded-for") || headersList.get("x-real-ip") || ""
|
||||
))
|
||||
) {
|
||||
// either the captcha is wrong or completely missing
|
||||
throw new Error("invalid_captcha");
|
||||
}
|
||||
|
||||
// throw an internal error, not user's fault
|
||||
if (!(await sendMessage(data))) {
|
||||
throw new Error("nodemailer_error");
|
||||
}
|
||||
|
||||
// success! let the client know
|
||||
return NextResponse.json({ success: true }, { status: 201 });
|
||||
} catch (
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
error: any
|
||||
) {
|
||||
return NextResponse.json({ error: error.message ?? "Bad request." }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
const validateCaptcha = async (formResponse: unknown, ip: string): Promise<unknown> => {
|
||||
const response = await fetcher("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
// fallback to dummy secret for testing: https://developers.cloudflare.com/turnstile/troubleshooting/testing/
|
||||
secret: process.env.TURNSTILE_SECRET_KEY || "1x0000000000000000000000000000000AA",
|
||||
response: formResponse,
|
||||
remoteip: ip,
|
||||
}),
|
||||
});
|
||||
|
||||
return response?.success;
|
||||
};
|
||||
|
||||
const sendMessage = async (data: FormData): Promise<boolean> => {
|
||||
try {
|
||||
const transporter = nodemailer.createTransport({
|
||||
// https://resend.com/docs/send-with-nodemailer-smtp
|
||||
host: "smtp.resend.com",
|
||||
secure: true,
|
||||
port: 465,
|
||||
auth: {
|
||||
user: "resend",
|
||||
pass: process.env.RESEND_API_KEY,
|
||||
},
|
||||
});
|
||||
|
||||
await transporter.sendMail({
|
||||
from: `${data.get("name")} <${process.env.RESEND_DOMAIN ? `noreply@${process.env.RESEND_DOMAIN}` : "onboarding@resend.dev"}>`,
|
||||
sender: `nodemailer <${process.env.RESEND_DOMAIN ? `noreply@${process.env.RESEND_DOMAIN}` : "onboarding@resend.dev"}>`,
|
||||
replyTo: `${data.get("name")} <${data.get("email")}>`,
|
||||
to: `<${config.authorEmail}>`,
|
||||
subject: `[${config.siteDomain}] Contact Form Submission`,
|
||||
// TODO: add markdown parsing as promised on the form.
|
||||
text: `${data.get("message")}`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
31
app/api/count/route.ts
Normal file
31
app/api/count/route.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "../../../lib/helpers/prisma";
|
||||
import type { PageStats } from "../../../types";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const revalidate = 0;
|
||||
|
||||
export async function GET(req: NextRequest): Promise<NextResponse<PageStats>> {
|
||||
const slug = req.nextUrl.searchParams.get("slug");
|
||||
|
||||
// extremely basic input validation.
|
||||
// TODO: actually check if the note exists before continuing (and allow pages other than notes).
|
||||
if (typeof slug !== "string" || !new RegExp(/^notes\/([A-Za-z0-9-]+)$/i).test(slug)) {
|
||||
// @ts-expect-error
|
||||
return NextResponse.json({ error: "Missing or invalid 'slug' parameter." }, { status: 400 });
|
||||
}
|
||||
|
||||
// +1 hit!
|
||||
const { hits } = await prisma.hits.upsert({
|
||||
where: { slug },
|
||||
create: { slug },
|
||||
update: {
|
||||
hits: {
|
||||
increment: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// add one to this page's count and return the new number
|
||||
return NextResponse.json({ hits });
|
||||
}
|
26
app/api/hits/route.ts
Normal file
26
app/api/hits/route.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "../../../lib/helpers/prisma";
|
||||
import type { SiteStats } from "../../../types";
|
||||
|
||||
export const revalidate = 900; // 15 mins
|
||||
|
||||
export async function GET(): Promise<NextResponse<SiteStats>> {
|
||||
// fetch all rows from db sorted by most hits
|
||||
const pages = await prisma.hits.findMany({
|
||||
orderBy: [
|
||||
{
|
||||
hits: "desc",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const total = { hits: 0 };
|
||||
|
||||
// calculate total hits
|
||||
pages.forEach((page) => {
|
||||
// add these hits to running tally
|
||||
total.hits += page.hits;
|
||||
});
|
||||
|
||||
return NextResponse.json({ total, pages });
|
||||
}
|
Reference in New Issue
Block a user