1
mirror of https://github.com/jakejarvis/jarv.is.git synced 2025-07-03 12:46:38 -04:00

server all the actions!

This commit is contained in:
2025-02-08 12:37:41 -05:00
parent fa5edc003f
commit 37375b766f
27 changed files with 689 additions and 707 deletions

View File

@ -1,96 +0,0 @@
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;
};

View File

@ -1,31 +0,0 @@
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 });
}

View File

@ -1,10 +1,15 @@
import { NextResponse } from "next/server";
import { prisma } from "../../../lib/helpers/prisma";
import type { SiteStats } from "../../../types";
import type { hits as Hits } from "@prisma/client";
export const revalidate = 900; // 15 mins
export async function GET(): Promise<NextResponse<SiteStats>> {
export async function GET(): Promise<
NextResponse<{
total: Pick<Hits, "hits">;
pages: Hits[];
}>
> {
// fetch all rows from db sorted by most hits
const pages = await prisma.hits.findMany({
orderBy: [

60
app/contact/actions.ts Normal file
View File

@ -0,0 +1,60 @@
"use server";
import { Resend } from "resend";
import config from "../../lib/config";
export async function sendMessage(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
prevState: any,
formData: FormData
): Promise<{
success: boolean;
message: string;
payload: FormData;
}> {
try {
// 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 (!formData.get("name") || !formData.get("email") || !formData.get("message")) {
return { success: false, message: "Please make sure that all fields are properly filled in.", payload: formData };
}
// validate captcha
const turnstileResponse = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
secret: process.env.TURNSTILE_SECRET_KEY || "1x0000000000000000000000000000000AA",
response: formData.get("cf-turnstile-response"),
}),
});
const turnstileData = await turnstileResponse.json();
if (!turnstileData.success) {
return {
success: false,
message: "Did you complete the CAPTCHA? (If you're human, that is...)",
payload: formData,
};
}
// send email
const resend = new Resend(process.env.RESEND_API_KEY);
await resend.emails.send({
from: `${formData.get("name")} <${process.env.RESEND_DOMAIN ? `noreply@${process.env.RESEND_DOMAIN}` : "onboarding@resend.dev"}>`,
replyTo: `${formData.get("name")} <${formData.get("email")}>`,
to: [config.authorEmail],
subject: `[${config.siteDomain}] Contact Form Submission`,
text: formData.get("message") as string,
});
return { success: true, message: "Thanks! You should hear from me soon.", payload: formData };
} catch (error) {
console.error(error);
return {
success: false,
message: "Internal server error... Try again later or shoot me an old-fashioned email?",
payload: formData,
};
}
}

View File

@ -25,23 +25,6 @@
resize: vertical;
}
.markdownTip {
font-size: 0.825em;
line-height: 1.75;
}
.markdownIcon {
display: inline;
width: 1.25em;
height: 1.25em;
vertical-align: -0.25em;
margin-right: 0.25em;
}
.captcha {
margin: 1em 0;
}
.actionRow {
display: flex;
align-items: center;

View File

@ -1,199 +1,114 @@
"use client";
import { useState } from "react";
import { Formik, Form, Field } from "formik";
import { useState, useActionState } from "react";
import TextareaAutosize from "react-textarea-autosize";
import Turnstile from "react-turnstile";
import clsx from "clsx";
import Link from "../../components/Link";
import useTheme from "../../hooks/useTheme";
import { sendMessage } from "./actions";
import { GoCheck, GoX } from "react-icons/go";
import { SiMarkdown } from "react-icons/si";
import type { FormikHelpers, FormikProps, FieldInputProps, FieldMetaProps } from "formik";
import styles from "./form.module.css";
type FormValues = {
name: string;
email: string;
message: string;
"cf-turnstile-response": string;
};
export type ContactFormProps = {
className?: string;
};
const ContactForm = ({ className }: ContactFormProps) => {
const ContactForm = () => {
const { activeTheme } = useTheme();
// status/feedback:
const [submitted, setSubmitted] = useState(false);
const [success, setSuccess] = useState(false);
const [feedback, setFeedback] = useState("");
const handleSubmit = (values: FormValues, { setSubmitting }: FormikHelpers<FormValues>) => {
// once a user attempts a submission, this is true and stays true whether or not the next attempt(s) are successful
setSubmitted(true);
// https://stackoverflow.com/a/68372184
const formData = new FormData();
for (const key in values) {
formData.append(key, values[key as keyof FormValues]);
}
// if we've gotten here then all data is (or should be) valid and ready to post to API
fetch("/api/contact/", {
method: "POST",
headers: {
Accept: "application/json",
},
body: formData,
})
.then((response) => response.json())
.then((data) => {
if (data.success === true) {
// handle successful submission
// disable submissions, hide the send button, and let user know we were successful
setSuccess(true);
setFeedback("Thanks! You should hear from me soon.");
} else {
// pass on any error sent by the server to the catch block below
throw new Error(data.message);
}
})
.catch((error) => {
setSuccess(false);
if (error.message === "missing_data") {
// this should be validated client-side but it's also checked server-side just in case someone slipped past
setFeedback("Please make sure that all fields are properly filled in.");
} else if (error.message === "invalid_captcha") {
// missing/invalid captcha
setFeedback("Did you complete the CAPTCHA? (If you're human, that is...)");
} else {
// something else went wrong, and it's probably my fault...
setFeedback("Internal server error... Try again later or shoot me an old-fashioned email?");
}
})
.finally(() => setSubmitting(false));
};
const [formState, formAction, pending] = useActionState<
Partial<{ success: boolean; message: string; payload: FormData }>,
FormData
>(sendMessage, {});
const [turnstileToken, setTurnstileToken] = useState<string>("");
return (
<Formik
onSubmit={handleSubmit}
initialValues={{
name: "",
email: "",
message: "",
"cf-turnstile-response": "",
}}
validate={(values: FormValues) => {
const errors: Partial<Record<keyof FormValues, boolean>> = {};
<form action={formAction}>
<input
type="text"
name="name"
placeholder="Name"
required
className={styles.input}
defaultValue={(formState.payload?.get("name") || "") as string}
disabled={formState.success}
/>
errors.name = !values.name;
errors.email = !values.email; // also loosely validated that it's email-like via browser (not foolproof)
errors.message = !values.message;
errors["cf-turnstile-response"] = !values["cf-turnstile-response"];
<input
type="email"
name="email"
placeholder="Email"
required
inputMode="email"
className={styles.input}
defaultValue={(formState.payload?.get("email") || "") as string}
disabled={formState.success}
/>
if (!errors.name && !errors.email && !errors.message && !errors["cf-turnstile-response"]) {
setFeedback("");
return {};
} else {
setSuccess(false);
setFeedback("Please make sure that all fields are properly filled in.");
}
<TextareaAutosize
name="message"
placeholder="Write something..."
minRows={5}
required
className={styles.input}
defaultValue={(formState.payload?.get("message") || "") as string}
disabled={formState.success}
/>
return errors;
}}
>
{({ setFieldValue, isSubmitting }: FormikProps<FormValues>) => (
<Form className={className} name="contact">
<Field name="name">
{({ field, meta }: { field: FieldInputProps<string>; meta: FieldMetaProps<string> }) => (
<input
type="text"
placeholder="Name"
disabled={success}
className={clsx(styles.input, { [styles.missing]: !!(meta.error && meta.touched) })}
{...field}
/>
<div
style={{
fontSize: "0.825em",
lineHeight: 1.75,
}}
>
<SiMarkdown
style={{
display: "inline",
width: "1.25em",
height: "1.25em",
verticalAlign: "-0.25em",
marginRight: "0.25em",
}}
/>{" "}
Basic{" "}
<Link href="https://commonmark.org/help/" title="Markdown reference sheet" style={{ fontWeight: 600 }}>
Markdown syntax
</Link>{" "}
is allowed here, e.g.: <strong>**bold**</strong>, <em>_italics_</em>, [
<Link href="https://jarv.is" underline={false} openInNewTab>
links
</Link>
](https://jarv.is), and <code>`code`</code>.
</div>
<Turnstile
sitekey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY || "1x00000000000000000000AA"}
onVerify={(token) => setTurnstileToken(token)}
style={{ margin: "1em 0" }}
theme={activeTheme === "dark" ? activeTheme : "light"}
/>
<input type="hidden" name="cf-turnstile-response" value={turnstileToken} />
<div className={styles.actionRow}>
{!formState.success && (
<button type="submit" disabled={pending} className={styles.submitButton}>
{pending ? (
<span>Sending...</span>
) : (
<>
<span className={styles.submitIcon}>📤</span> <span>Send</span>
</>
)}
</Field>
</button>
)}
<Field name="email">
{({ field, meta }: { field: FieldInputProps<string>; meta: FieldMetaProps<string> }) => (
<input
type="email"
inputMode="email"
placeholder="Email"
disabled={success}
className={clsx(styles.input, { [styles.missing]: !!(meta.error && meta.touched) })}
{...field}
/>
)}
</Field>
<Field name="message">
{({ field, meta }: { field: FieldInputProps<string>; meta: FieldMetaProps<string> }) => (
<TextareaAutosize
placeholder="Write something..."
minRows={5}
disabled={success}
className={clsx(styles.input, { [styles.missing]: !!(meta.error && meta.touched) })}
{...field}
/>
)}
</Field>
<div className={styles.markdownTip}>
<SiMarkdown className={styles.markdownIcon} /> Basic{" "}
<Link href="https://commonmark.org/help/" title="Markdown reference sheet" style={{ fontWeight: 600 }}>
Markdown syntax
</Link>{" "}
is allowed here, e.g.: <strong>**bold**</strong>, <em>_italics_</em>, [
<Link href="https://jarv.is" underline={false} openInNewTab>
links
</Link>
](https://jarv.is), and <code>`code`</code>.
{formState.message && (
<div className={clsx(styles.result, formState.success ? styles.success : styles.error)}>
{formState.success ? <GoCheck className={styles.resultIcon} /> : <GoX className={styles.resultIcon} />}{" "}
{formState.message}
</div>
<Turnstile
sitekey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY || "1x00000000000000000000AA"}
onVerify={(token) => setFieldValue("cf-turnstile-response", token)}
className={styles.captcha}
theme={activeTheme === "dark" ? activeTheme : "light"}
/>
<div className={styles.actionRow}>
<button
type="submit"
title="Send Message"
aria-label="Send Message"
onClick={() => setSubmitted(true)}
disabled={isSubmitting}
className={styles.submitButton}
style={{ display: success ? "none" : "inline-flex" }}
>
{isSubmitting ? (
<span>Sending...</span>
) : (
<>
<span className={styles.submitIcon}>📤</span> <span>Send</span>
</>
)}
</button>
<div
className={clsx(styles.result, success ? styles.success : styles.error)}
style={{ display: submitted && feedback && !isSubmitting ? "block" : "none" }}
>
{success ? <GoCheck className={styles.resultIcon} /> : <GoX className={styles.resultIcon} />} {feedback}
</div>
</div>
</Form>
)}
</Formik>
)}
</div>
</form>
);
};

View File

@ -0,0 +1,27 @@
import { connection } from "next/server";
import commaNumber from "comma-number";
import { prisma } from "../../../lib/helpers/prisma";
const HitCounter = async ({ slug }: { slug: string }) => {
await connection();
try {
const { hits } = await prisma.hits.upsert({
where: { slug },
create: { slug },
update: {
hits: {
increment: 1,
},
},
});
// we have data!
return <span title={`${commaNumber(hits)} ${hits === 1 ? "view" : "views"}`}>{commaNumber(hits)}</span>;
} catch (error) {
console.error(error);
throw new Error();
}
};
export default HitCounter;

View File

@ -35,7 +35,6 @@
white-space: nowrap;
margin-right: 0.75em;
}
.meta .tag:before {
content: "\0023"; /* cosmetically hashtagify tags */
padding-right: 0.125em;

View File

@ -1,11 +1,13 @@
import { Suspense } from "react";
import * as runtime from "react/jsx-runtime";
import { ErrorBoundary } from "react-error-boundary";
import { evaluate } from "@mdx-js/mdx";
import Content from "../../../components/Content";
import Link from "../../../components/Link";
import Time from "../../../components/Time";
import HitCounter from "../../../components/HitCounter";
import Comments from "../../../components/Comments";
import Loading from "../../../components/Loading";
import HitCounter from "./counter";
import { getPostSlugs, getPostData } from "../../../lib/helpers/posts";
import * as mdxComponents from "../../../lib/helpers/mdx-components";
import { metadata as defaultMetadata } from "../../layout";
@ -19,6 +21,9 @@ import styles from "./page.module.css";
// https://nextjs.org/docs/app/api-reference/functions/generate-static-params#disable-rendering-for-unspecified-paths
export const dynamicParams = false;
// https://nextjs.org/docs/app/building-your-application/rendering/partial-prerendering#using-partial-prerendering
export const experimental_ppr = true;
export async function generateStaticParams() {
const slugs = await getPostSlugs();
@ -135,20 +140,21 @@ export default async function Page({ params }: { params: Promise<{ slug: string
{/* only count hits on production site */}
{process.env.NEXT_PUBLIC_VERCEL_ENV === "production" && (
<div
className={styles.item}
style={{
// fix potential layout shift when number of hits loads
minWidth: "7em",
marginRight: 0,
}}
>
{/* completely hide this block if anything goes wrong on the backend */}
<ErrorBoundary fallback={null}>
<ErrorBoundary fallback={null}>
<div
className={styles.item}
style={{
// fix potential layout shift when number of hits loads
minWidth: "7em",
marginRight: 0,
}}
>
<FiEye className={styles.icon} />
<HitCounter slug={`notes/${frontMatter.slug}`} />
</ErrorBoundary>
</div>
<Suspense fallback={<Loading boxes={3} width={20} />}>
<HitCounter slug={`notes/${frontMatter.slug}`} />
</Suspense>
</div>
</ErrorBoundary>
)}
</div>

View File

@ -6,7 +6,7 @@ import config from "../../lib/config";
import { metadata as defaultMetadata } from "../layout";
import type { ReactElement } from "react";
import type { Metadata, Route } from "next";
import type { PostsByYear } from "../../types";
import type { FrontMatter } from "../../lib/helpers/posts";
import styles from "./page.module.css";
@ -27,7 +27,9 @@ export const metadata: Metadata = {
export default async function Page() {
// parse the year of each note and group them together
const notes = await getAllPosts();
const notesByYear: PostsByYear = {};
const notesByYear: {
[year: string]: FrontMatter[];
} = {};
notes.forEach((note) => {
const year = new Date(note.date).getUTCFullYear();
@ -45,7 +47,11 @@ export default async function Page() {
<li className={styles.post} key={slug}>
<Time date={date} format="MMM D" className={styles.postDate} />
<span>
<Link href={`/notes/${slug}` as Route} dangerouslySetInnerHTML={{ __html: htmlTitle || title }} />
<Link
href={`/notes/${slug}` as Route}
prefetch={null}
dangerouslySetInnerHTML={{ __html: htmlTitle || title }}
/>
</span>
</li>
))}

View File

@ -30,18 +30,6 @@
font-size: 0.6em;
}
.pgpIcon {
vertical-align: -0.25em;
stroke-width: 0.5;
}
.pgpKey {
margin: 0 0.15em;
font-family: var(--fonts-mono);
letter-spacing: 0.075em;
word-spacing: -0.4em;
}
.wave {
display: inline-block;
margin-left: 0.1em;
@ -85,15 +73,15 @@
}
@media (max-width: 768px) {
.h1 {
.page h1 {
font-size: 1.6em;
}
.h2 {
.page h2 {
font-size: 1.25em;
}
.paragraph {
.page p {
font-size: 0.925em;
line-height: 1.825;
}

View File

@ -254,8 +254,23 @@ export default function Page() {
underline={false}
openInNewTab
>
<GoLock size="1.25em" className={styles.pgpIcon} />{" "}
<span className={styles.pgpKey}>2B0C 9CF2 51E6 9A39</span>
<GoLock
size="1.25em"
style={{
verticalAlign: "-0.25em",
strokeWidth: 0.5,
}}
/>{" "}
<span
style={{
margin: "0 0.15em",
fontFamily: "var(--fonts-mono)",
letterSpacing: "0.075em",
wordSpacing: "-0.4em",
}}
>
2B0C 9CF2 51E6 9A39
</span>
</ColorfulLink>
</sup>
,{" "}

View File

@ -69,18 +69,3 @@
border-radius: 50%;
vertical-align: text-top;
}
.viewMore {
text-align: center;
margin-bottom: 0;
font-weight: 500px;
}
.githubIcon {
display: inline;
width: 1.2em;
height: 1.2em;
vertical-align: -0.2em;
margin: 0 0.15em;
fill: var(--colors-text);
}

View File

@ -10,7 +10,6 @@ import { GoStar, GoRepoForked } from "react-icons/go";
import { SiGithub } from "react-icons/si";
import type { Metadata } from "next";
import type { User, Repository } from "@octokit/graphql-schema";
import type { Project } from "../../types";
import styles from "./page.module.css";
@ -29,6 +28,19 @@ export const metadata: Metadata = {
},
};
type Project = {
name: string;
url: string;
description?: string;
language?: {
name: string;
color?: string;
};
stars?: number;
forks?: number;
updatedAt: string;
};
async function getRepos(): Promise<Project[] | null> {
// don't fail the entire site build if the required API key for this page is missing
if (!process.env.GH_PUBLIC_TOKEN || process.env.GH_PUBLIC_TOKEN === "") {
@ -164,9 +176,26 @@ export default async function Page() {
))}
</div>
<p className={styles.viewMore}>
<p
style={{
textAlign: "center",
marginBottom: 0,
fontWeight: 500,
}}
>
<Link href={`https://github.com/${config.authorSocial.github}`}>
View more on <SiGithub className={styles.githubIcon} /> GitHub...
View more on{" "}
<SiGithub
style={{
display: "inline",
width: "1.2em",
height: "1.2em",
verticalAlign: "-0.2em",
margin: "0 0.15em",
fill: "var(--colors-text)",
}}
/>{" "}
GitHub...
</Link>
</p>
</Content>