mirror of
https://github.com/jakejarvis/jarv.is.git
synced 2025-07-03 14:46:37 -04:00
backpedal a bit on caching
This commit is contained in:
@ -2,8 +2,9 @@ import { NextResponse } from "next/server";
|
||||
import { unstable_cache as cache } from "next/cache";
|
||||
import redis from "../../../lib/helpers/redis";
|
||||
|
||||
export const GET = async (): Promise<
|
||||
NextResponse<{
|
||||
// cache response from the db
|
||||
const getData = cache(
|
||||
async (): Promise<{
|
||||
total: {
|
||||
hits: number;
|
||||
};
|
||||
@ -11,45 +12,42 @@ export const GET = async (): Promise<
|
||||
slug: string;
|
||||
hits: number;
|
||||
}>;
|
||||
}>
|
||||
> => {
|
||||
const { total, pages } = await cache(
|
||||
async () => {
|
||||
// get all keys (aka slugs)
|
||||
const slugs = await redis.scan(0, {
|
||||
match: "hits:*",
|
||||
type: "string",
|
||||
// set an arbitrary yet generous upper limit, just in case...
|
||||
count: 99,
|
||||
});
|
||||
}> => {
|
||||
// get all keys (aka slugs)
|
||||
const slugs = await redis.scan(0, {
|
||||
match: "hits:*",
|
||||
type: "string",
|
||||
// set an arbitrary yet generous upper limit, just in case...
|
||||
count: 99,
|
||||
});
|
||||
|
||||
// get the value (number of hits) for each key (the slug of the page)
|
||||
const values = await redis.mget<string[]>(...slugs[1]);
|
||||
// get the value (number of hits) for each key (the slug of the page)
|
||||
const values = await redis.mget<string[]>(...slugs[1]);
|
||||
|
||||
// pair the slugs with their hit values
|
||||
const pages = slugs[1].map((slug, index) => ({
|
||||
slug: slug.split(":").pop() as string, // remove the "hits:" prefix
|
||||
hits: parseInt(values[index], 10),
|
||||
}));
|
||||
// pair the slugs with their hit values
|
||||
const pages = slugs[1].map((slug, index) => ({
|
||||
slug: slug.split(":").pop() as string, // remove the "hits:" prefix
|
||||
hits: parseInt(values[index], 10),
|
||||
}));
|
||||
|
||||
// sort descending by hits
|
||||
pages.sort((a, b) => b.hits - a.hits);
|
||||
// sort descending by hits
|
||||
pages.sort((a, b) => b.hits - a.hits);
|
||||
|
||||
// calculate total hits
|
||||
const total = { hits: 0 };
|
||||
pages.forEach((page) => {
|
||||
// add these hits to running tally
|
||||
total.hits += page.hits;
|
||||
});
|
||||
// calculate total hits
|
||||
const total = { hits: 0 };
|
||||
pages.forEach((page) => {
|
||||
// add these hits to running tally
|
||||
total.hits += page.hits;
|
||||
});
|
||||
|
||||
return { total, pages };
|
||||
},
|
||||
undefined,
|
||||
{
|
||||
revalidate: 1800, // 30 minutes
|
||||
tags: ["hits"],
|
||||
}
|
||||
)();
|
||||
return { total, pages };
|
||||
},
|
||||
undefined,
|
||||
{
|
||||
revalidate: 900, // 15 minutes
|
||||
tags: ["hits"],
|
||||
}
|
||||
);
|
||||
|
||||
return NextResponse.json({ total, pages });
|
||||
};
|
||||
export const GET = async (): Promise<NextResponse<Awaited<ReturnType<typeof getData>>>> =>
|
||||
NextResponse.json(await getData());
|
||||
|
122
app/contact/action.ts
Normal file
122
app/contact/action.ts
Normal file
@ -0,0 +1,122 @@
|
||||
"use server";
|
||||
|
||||
import { headers } from "next/headers";
|
||||
import * as v from "valibot";
|
||||
import { Resend } from "resend";
|
||||
import * as config from "../../lib/config";
|
||||
|
||||
const ContactSchema = v.object({
|
||||
// TODO: replace duplicate error messages with v.message() when released. see:
|
||||
// https://valibot.dev/api/message/
|
||||
// https://github.com/fabian-hiller/valibot/blob/main/library/src/methods/message/message.ts
|
||||
name: v.pipe(v.string("Your name is required."), v.trim(), v.nonEmpty("Your name is required.")),
|
||||
email: v.pipe(
|
||||
v.string("Your email address is required."),
|
||||
v.trim(),
|
||||
v.nonEmpty("Your email address is required."),
|
||||
v.email("Invalid email address.")
|
||||
),
|
||||
message: v.pipe(
|
||||
v.string("A message is required."),
|
||||
v.trim(),
|
||||
v.nonEmpty("A message is required."),
|
||||
v.minLength(10, "Your message must be at least 10 characters.")
|
||||
),
|
||||
"cf-turnstile-response": v.pipe(
|
||||
// token wasn't submitted at _all_, most likely a direct POST request by a spam bot
|
||||
v.string("Shoo, bot."),
|
||||
// form submitted properly but token was missing, might be a forgetful human
|
||||
v.nonEmpty("Just do the stinkin CAPTCHA, human! 🤖"),
|
||||
// very rudimentary length check based on Cloudflare's docs
|
||||
// https://developers.cloudflare.com/turnstile/troubleshooting/testing/
|
||||
v.minLength("XXXX.DUMMY.TOKEN.XXXX".length),
|
||||
// "A Turnstile token can have up to 2048 characters."
|
||||
// https://developers.cloudflare.com/turnstile/get-started/server-side-validation/
|
||||
v.maxLength(2048),
|
||||
v.readonly()
|
||||
),
|
||||
});
|
||||
|
||||
export type ContactInput = v.InferInput<typeof ContactSchema>;
|
||||
|
||||
export type ContactState = {
|
||||
success: boolean;
|
||||
message: string;
|
||||
errors?: v.FlatErrors<typeof ContactSchema>["nested"];
|
||||
};
|
||||
|
||||
export const send = async (state: ContactState, payload: FormData): Promise<ContactState> => {
|
||||
// TODO: remove after debugging why automated spam entries are causing 500 errors
|
||||
console.debug("[contact form] received payload:", payload);
|
||||
|
||||
if (!process.env.RESEND_API_KEY) {
|
||||
throw new Error("[contact form] 'RESEND_API_KEY' is not set.");
|
||||
}
|
||||
|
||||
try {
|
||||
const data = v.safeParse(ContactSchema, Object.fromEntries(payload));
|
||||
|
||||
if (!data.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: "Please make sure all fields are filled in.",
|
||||
errors: v.flatten(data.issues).nested,
|
||||
};
|
||||
}
|
||||
|
||||
// try to get the client IP (for turnstile accuracy, not logging!) but no biggie if we can't
|
||||
let remoteip;
|
||||
try {
|
||||
remoteip = (await headers()).get("x-forwarded-for");
|
||||
} catch {} // eslint-disable-line no-empty
|
||||
|
||||
// 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: data.output["cf-turnstile-response"],
|
||||
remoteip,
|
||||
}),
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
if (!turnstileResponse || !turnstileResponse.ok) {
|
||||
throw new Error(`[contact form] turnstile validation failed: ${turnstileResponse.status}`);
|
||||
}
|
||||
|
||||
const turnstileData = (await turnstileResponse.json()) as { success: boolean };
|
||||
|
||||
if (!turnstileData.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: "Did you complete the CAPTCHA? (If you're human, that is...)",
|
||||
};
|
||||
}
|
||||
|
||||
if (!process.env.RESEND_FROM_EMAIL) {
|
||||
// https://resend.com/docs/api-reference/emails/send-email
|
||||
console.warn("[contact form] 'RESEND_FROM_EMAIL' is not set, falling back to onboarding@resend.dev.");
|
||||
}
|
||||
|
||||
// send email
|
||||
const resend = new Resend(process.env.RESEND_API_KEY!);
|
||||
await resend.emails.send({
|
||||
from: `${data.output.name} <${process.env.RESEND_FROM_EMAIL || "onboarding@resend.dev"}>`,
|
||||
replyTo: `${data.output.name} <${data.output.email}>`,
|
||||
to: [config.authorEmail],
|
||||
subject: `[${config.siteName}] Contact Form Submission`,
|
||||
text: data.output.message,
|
||||
});
|
||||
|
||||
return { success: true, message: "Thanks! You should hear from me soon." };
|
||||
} catch (error) {
|
||||
console.error("[contact form] fatal error:", error);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: "Internal server error. Please try again later or shoot me an email.",
|
||||
};
|
||||
}
|
||||
};
|
@ -6,16 +6,13 @@ import Turnstile from "react-turnstile";
|
||||
import clsx from "clsx";
|
||||
import { CheckIcon, XIcon } from "lucide-react";
|
||||
import Link from "../../components/Link";
|
||||
import type { ContactInput, ContactState } from "./schema";
|
||||
|
||||
import { send, type ContactState, type ContactInput } from "./action";
|
||||
|
||||
import styles from "./form.module.css";
|
||||
|
||||
const ContactForm = ({
|
||||
serverAction,
|
||||
}: {
|
||||
serverAction: (state: ContactState, payload: FormData) => Promise<ContactState>;
|
||||
}) => {
|
||||
const [formState, formAction, pending] = useActionState<ContactState, FormData>(serverAction, {
|
||||
const ContactForm = () => {
|
||||
const [formState, formAction, pending] = useActionState<ContactState, FormData>(send, {
|
||||
success: false,
|
||||
message: "",
|
||||
});
|
||||
|
@ -1,13 +1,8 @@
|
||||
import { headers } from "next/headers";
|
||||
import * as v from "valibot";
|
||||
import { Resend } from "resend";
|
||||
import PageTitle from "../../components/PageTitle";
|
||||
import Link from "../../components/Link";
|
||||
import { addMetadata } from "../../lib/helpers/metadata";
|
||||
import * as config from "../../lib/config";
|
||||
|
||||
import ContactForm from "./form";
|
||||
import ContactSchema, { type ContactState } from "./schema";
|
||||
|
||||
export const metadata = addMetadata({
|
||||
title: "Contact Me",
|
||||
@ -17,84 +12,6 @@ export const metadata = addMetadata({
|
||||
},
|
||||
});
|
||||
|
||||
const send = async (state: ContactState, payload: FormData): Promise<ContactState> => {
|
||||
"use server";
|
||||
|
||||
// TODO: remove after debugging why automated spam entries are causing 500 errors
|
||||
console.debug("[contact form] received payload:", payload);
|
||||
|
||||
if (!process.env.RESEND_API_KEY) {
|
||||
throw new Error("[contact form] 'RESEND_API_KEY' is not set.");
|
||||
}
|
||||
|
||||
try {
|
||||
const data = v.safeParse(ContactSchema, Object.fromEntries(payload));
|
||||
|
||||
if (!data.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: "Please make sure all fields are filled in.",
|
||||
errors: v.flatten(data.issues).nested,
|
||||
};
|
||||
}
|
||||
|
||||
// try to get the client IP (for turnstile accuracy, not logging!) but no biggie if we can't
|
||||
let remoteip;
|
||||
try {
|
||||
remoteip = (await headers()).get("x-forwarded-for");
|
||||
} catch {} // eslint-disable-line no-empty
|
||||
|
||||
// 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: data.output["cf-turnstile-response"],
|
||||
remoteip,
|
||||
}),
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
if (!turnstileResponse || !turnstileResponse.ok) {
|
||||
throw new Error(`[contact form] turnstile validation failed: ${turnstileResponse.status}`);
|
||||
}
|
||||
|
||||
const turnstileData = (await turnstileResponse.json()) as { success: boolean };
|
||||
|
||||
if (!turnstileData.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: "Did you complete the CAPTCHA? (If you're human, that is...)",
|
||||
};
|
||||
}
|
||||
|
||||
if (!process.env.RESEND_FROM_EMAIL) {
|
||||
// https://resend.com/docs/api-reference/emails/send-email
|
||||
console.warn("[contact form] 'RESEND_FROM_EMAIL' is not set, falling back to onboarding@resend.dev.");
|
||||
}
|
||||
|
||||
// send email
|
||||
const resend = new Resend(process.env.RESEND_API_KEY!);
|
||||
await resend.emails.send({
|
||||
from: `${data.output.name} <${process.env.RESEND_FROM_EMAIL || "onboarding@resend.dev"}>`,
|
||||
replyTo: `${data.output.name} <${data.output.email}>`,
|
||||
to: [config.authorEmail],
|
||||
subject: `[${config.siteName}] Contact Form Submission`,
|
||||
text: data.output.message,
|
||||
});
|
||||
|
||||
return { success: true, message: "Thanks! You should hear from me soon." };
|
||||
} catch (error) {
|
||||
console.error("[contact form] fatal error:", error);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: "Internal server error. Please try again later or shoot me an email.",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const Page = () => {
|
||||
return (
|
||||
<div
|
||||
@ -127,7 +44,7 @@ const Page = () => {
|
||||
.
|
||||
</p>
|
||||
|
||||
<ContactForm serverAction={send} />
|
||||
<ContactForm />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@ -1,43 +0,0 @@
|
||||
import * as v from "valibot";
|
||||
|
||||
export const ContactSchema = v.object({
|
||||
// TODO: replace duplicate error messages with v.message() when released. see:
|
||||
// https://valibot.dev/api/message/
|
||||
// https://github.com/fabian-hiller/valibot/blob/main/library/src/methods/message/message.ts
|
||||
name: v.pipe(v.string("Your name is required."), v.trim(), v.nonEmpty("Your name is required.")),
|
||||
email: v.pipe(
|
||||
v.string("Your email address is required."),
|
||||
v.trim(),
|
||||
v.nonEmpty("Your email address is required."),
|
||||
v.email("Invalid email address.")
|
||||
),
|
||||
message: v.pipe(
|
||||
v.string("A message is required."),
|
||||
v.trim(),
|
||||
v.nonEmpty("A message is required."),
|
||||
v.minLength(10, "Your message must be at least 10 characters.")
|
||||
),
|
||||
"cf-turnstile-response": v.pipe(
|
||||
// token wasn't submitted at _all_, most likely a direct POST request by a spam bot
|
||||
v.string("Shoo, bot."),
|
||||
// form submitted properly but token was missing, might be a forgetful human
|
||||
v.nonEmpty("Just do the stinkin CAPTCHA, human! 🤖"),
|
||||
// very rudimentary length check based on Cloudflare's docs
|
||||
// https://developers.cloudflare.com/turnstile/troubleshooting/testing/
|
||||
v.minLength("XXXX.DUMMY.TOKEN.XXXX".length),
|
||||
// "A Turnstile token can have up to 2048 characters."
|
||||
// https://developers.cloudflare.com/turnstile/get-started/server-side-validation/
|
||||
v.maxLength(2048),
|
||||
v.readonly()
|
||||
),
|
||||
});
|
||||
|
||||
export type ContactInput = v.InferInput<typeof ContactSchema>;
|
||||
|
||||
export type ContactState = {
|
||||
success: boolean;
|
||||
message: string;
|
||||
errors?: v.FlatErrors<typeof ContactSchema>["nested"];
|
||||
};
|
||||
|
||||
export default ContactSchema;
|
@ -1,3 +1,4 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { buildFeed } from "../../lib/helpers/build-feed";
|
||||
|
||||
export const dynamic = "force-static";
|
||||
@ -5,7 +6,7 @@ export const dynamic = "force-static";
|
||||
export const GET = async () => {
|
||||
const feed = await buildFeed();
|
||||
|
||||
return new Response(feed.atom1(), {
|
||||
return new NextResponse(feed.atom1(), {
|
||||
headers: {
|
||||
"content-type": "application/atom+xml; charset=utf-8",
|
||||
},
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { buildFeed } from "../../lib/helpers/build-feed";
|
||||
|
||||
export const dynamic = "force-static";
|
||||
@ -5,7 +6,7 @@ export const dynamic = "force-static";
|
||||
export const GET = async () => {
|
||||
const feed = await buildFeed();
|
||||
|
||||
return new Response(feed.rss2(), {
|
||||
return new NextResponse(feed.rss2(), {
|
||||
headers: {
|
||||
"content-type": "application/rss+xml; charset=utf-8",
|
||||
},
|
||||
|
@ -1,6 +1,8 @@
|
||||
import * as config from "../lib/config";
|
||||
import type { MetadataRoute } from "next";
|
||||
|
||||
export const dynamic = "force-static";
|
||||
|
||||
const manifest = (): MetadataRoute.Manifest => {
|
||||
return {
|
||||
name: config.siteName,
|
||||
|
36
app/page.tsx
36
app/page.tsx
@ -1,3 +1,4 @@
|
||||
import clsx from "clsx";
|
||||
import hash from "@emotion/hash";
|
||||
import { rgba } from "polished";
|
||||
import { LockIcon } from "lucide-react";
|
||||
@ -9,26 +10,35 @@ import styles from "./page.module.css";
|
||||
const Link = ({
|
||||
lightColor,
|
||||
darkColor,
|
||||
className,
|
||||
children,
|
||||
...rest
|
||||
}: ComponentPropsWithoutRef<typeof UnstyledLink> & {
|
||||
lightColor: string;
|
||||
darkColor: string;
|
||||
lightColor?: string;
|
||||
darkColor?: string;
|
||||
}) => {
|
||||
const uniqueId = hash(`${lightColor},${darkColor}`);
|
||||
if (lightColor && darkColor) {
|
||||
const uniqueId = hash(`${lightColor},${darkColor}`);
|
||||
|
||||
return (
|
||||
<UnstyledLink className={clsx(`t_${uniqueId}`, className)} {...rest}>
|
||||
{children}
|
||||
|
||||
<style
|
||||
// workaround to have react combine all of these inline styles into a single <style> tag up top, see:
|
||||
// https://react.dev/reference/react-dom/components/style#rendering-an-inline-css-stylesheet
|
||||
href={uniqueId}
|
||||
precedence={styles.page}
|
||||
>
|
||||
{`.t_${uniqueId}{--colors-link:${lightColor};--colors-link-underline:${rgba(lightColor, 0.4)}}[data-theme="dark"] .t_${uniqueId}{--colors-link:${darkColor};--colors-link-underline:${rgba(darkColor, 0.4)}}`}
|
||||
</style>
|
||||
</UnstyledLink>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<UnstyledLink className={`t_${uniqueId}`} {...rest}>
|
||||
<UnstyledLink className={className} {...rest}>
|
||||
{children}
|
||||
|
||||
<style
|
||||
// workaround to have react combine all of these inline styles into a single <style> tag up top, see:
|
||||
// https://react.dev/reference/react-dom/components/style#rendering-an-inline-css-stylesheet
|
||||
href={uniqueId}
|
||||
precedence={styles.page}
|
||||
>
|
||||
{`.t_${uniqueId}{--colors-link:${lightColor};--colors-link-underline:${rgba(lightColor, 0.4)}}[data-theme="dark"] .t_${uniqueId}{--colors-link:${darkColor};--colors-link-underline:${rgba(darkColor, 0.4)}}`}
|
||||
</style>
|
||||
</UnstyledLink>
|
||||
);
|
||||
};
|
||||
|
@ -36,7 +36,8 @@ const sitemap = async (): Promise<MetadataRoute.Sitemap> => {
|
||||
});
|
||||
});
|
||||
|
||||
(await getFrontMatter()).forEach((post) => {
|
||||
const frontmatter = await getFrontMatter();
|
||||
frontmatter.forEach((post) => {
|
||||
routes.push({
|
||||
url: post.permalink,
|
||||
// pull lastModified from front matter date
|
||||
|
Reference in New Issue
Block a user