1
mirror of https://github.com/jakejarvis/jarv.is.git synced 2026-06-05 19:15:30 -04:00

revert back to zod

This commit is contained in:
2025-05-17 20:43:15 -04:00
parent 360d0fda1b
commit cb5934647f
11 changed files with 662 additions and 878 deletions
+52 -48
View File
@@ -1,5 +1,5 @@
import { createEnv } from "@t3-oss/env-nextjs";
import * as v from "valibot";
import { z } from "zod";
export const env = createEnv({
server: {
@@ -8,21 +8,21 @@ export const env = createEnv({
*
* @see https://www.better-auth.com/docs/installation#set-environment-variables
*/
AUTH_SECRET: v.string(),
AUTH_SECRET: z.string().min(1),
/**
* Required. The client ID from the GitHub Developer Portal for this site's OAuth App.
*
* @see https://www.better-auth.com/docs/authentication/github
*/
AUTH_GITHUB_CLIENT_ID: v.string(),
AUTH_GITHUB_CLIENT_ID: z.string().min(1),
/**
* Required. A client secret from the GitHub Developer Portal for this site's OAuth App.
*
* @see https://www.better-auth.com/docs/authentication/github
*/
AUTH_GITHUB_CLIENT_SECRET: v.string(),
AUTH_GITHUB_CLIENT_SECRET: z.string().min(1),
/**
* Required. Database connection string for a Postgres database. May be set automatically by Vercel's Neon
@@ -30,7 +30,7 @@ export const env = createEnv({
*
* @see https://vercel.com/integrations/neon
*/
DATABASE_URL: v.pipe(v.string(), v.startsWith("postgres://")),
DATABASE_URL: z.string().startsWith("postgres://"),
/**
* Required. GitHub API token used for [/projects](../app/projects/page.tsx) grid. Only needs the `public_repo`
@@ -38,7 +38,7 @@ export const env = createEnv({
*
* @see https://github.com/settings/tokens/new?scopes=public_repo
*/
GITHUB_TOKEN: v.optional(v.pipe(v.string(), v.startsWith("ghp_"))),
GITHUB_TOKEN: z.string().startsWith("ghp_").optional(),
/**
* Required. Uses Resend API to send contact form submissions via a [server action](../app/contact/action.ts). May
@@ -47,7 +47,7 @@ export const env = createEnv({
* @see https://resend.com/api-keys
* @see https://vercel.com/integrations/resend
*/
RESEND_API_KEY: v.pipe(v.string(), v.startsWith("re_")),
RESEND_API_KEY: z.string().startsWith("re_"),
/**
* Optional, but will throw a warning if unset. Use an approved domain (or subdomain) on the Resend account to send
@@ -56,17 +56,17 @@ export const env = createEnv({
*
* @see https://resend.com/domains
*/
RESEND_FROM_EMAIL: v.fallback(v.pipe(v.string(), v.email()), "onboarding@resend.dev"),
RESEND_FROM_EMAIL: z.string().email().default("onboarding@resend.dev"),
/** Required. The destination email for contact form submissions. */
RESEND_TO_EMAIL: v.pipe(v.string(), v.email()),
RESEND_TO_EMAIL: z.string().email(),
/**
* Required. Secret for Cloudflare `siteverify` API to validate a form's turnstile result on the backend.
*
* @see https://developers.cloudflare.com/turnstile/get-started/server-side-validation/
*/
TURNSTILE_SECRET_KEY: v.optional(v.string(), "1x0000000000000000000000000000000AA"),
TURNSTILE_SECRET_KEY: z.string().default("1x0000000000000000000000000000000AA"),
},
client: {
/**
@@ -76,48 +76,52 @@ export const env = createEnv({
*
* @see https://nextjs.org/docs/app/api-reference/functions/generate-metadata#default-value
*/
NEXT_PUBLIC_BASE_URL: v.fallback(
v.pipe(v.string(), v.url()),
() =>
// Vercel: https://vercel.com/docs/environment-variables/system-environment-variables
(process.env.VERCEL
? process.env.VERCEL_ENV === "production"
? `https://${process.env.VERCEL_PROJECT_PRODUCTION_URL}`
: process.env.VERCEL_ENV === "preview"
? `https://${process.env.VERCEL_BRANCH_URL}`
: process.env.VERCEL_URL
? `https://${process.env.VERCEL_URL}`
: undefined
: undefined) ||
// Netlify: https://docs.netlify.com/configure-builds/environment-variables/#read-only-variables
(process.env.NETLIFY
? process.env.CONTEXT === "production"
? `${process.env.URL}`
: process.env.DEPLOY_PRIME_URL
? `${process.env.DEPLOY_PRIME_URL}`
: process.env.DEPLOY_URL
? `${process.env.DEPLOY_URL}`
: undefined
: undefined) ||
// next dev
`http://localhost:${process.env.PORT || 3000}`
),
NEXT_PUBLIC_BASE_URL: z
.string()
.url()
.default(
((): string =>
(process.env.VERCEL
? process.env.VERCEL_ENV === "production"
? `https://${process.env.VERCEL_PROJECT_PRODUCTION_URL}`
: process.env.VERCEL_ENV === "preview"
? `https://${process.env.VERCEL_BRANCH_URL}`
: process.env.VERCEL_URL
? `https://${process.env.VERCEL_URL}`
: undefined
: undefined) ||
(process.env.NETLIFY
? process.env.CONTEXT === "production"
? `${process.env.URL}`
: process.env.DEPLOY_PRIME_URL
? `${process.env.DEPLOY_PRIME_URL}`
: process.env.DEPLOY_URL
? `${process.env.DEPLOY_URL}`
: undefined
: undefined) ||
`http://localhost:${process.env.PORT || 3000}`)()
),
/**
* Optional. Set this to override the best guess as to the environment the site is running in.
*/
NEXT_PUBLIC_ENV: v.fallback(v.picklist(["production", "development"]), () =>
(process.env.VERCEL && process.env.VERCEL_ENV === "production") ||
(process.env.NETLIFY && process.env.CONTEXT === "production")
? "production"
: "development"
),
NEXT_PUBLIC_ENV: z
.enum(["production", "development"])
.default(
((): "production" | "development" =>
(process.env.VERCEL && process.env.VERCEL_ENV === "production") ||
(process.env.NETLIFY && process.env.CONTEXT === "production")
? "production"
: "development")()
),
/** Required. GitHub repository for the site in the format of `{username}/{repo}`. */
NEXT_PUBLIC_GITHUB_REPO: v.pipe(v.string(), v.includes("/")),
NEXT_PUBLIC_GITHUB_REPO: z.string().refine((val) => val.includes("/"), {
message: "Must be in the format {username}/{repo}",
}),
/** Required. GitHub username of the author, used to generate [/projects](../app/projects/page.tsx). */
NEXT_PUBLIC_GITHUB_USERNAME: v.string(),
NEXT_PUBLIC_GITHUB_USERNAME: z.string().min(1),
/**
* Optional. Sets an `Onion-Location` header in responses to advertise a URL for the same page but hosted on a
@@ -126,14 +130,14 @@ export const env = createEnv({
*
* @see https://community.torproject.org/onion-services/advanced/onion-location/
*/
NEXT_PUBLIC_ONION_DOMAIN: v.optional(v.pipe(v.string(), v.endsWith(".onion"))),
NEXT_PUBLIC_ONION_DOMAIN: z.string().endsWith(".onion").optional(),
/**
* Optional. Locale code to define the site's language in ISO-639 format. Defaults to `en-US`.
*
* @see https://www.loc.gov/standards/iso639-2/php/code_list.php
*/
NEXT_PUBLIC_SITE_LOCALE: v.optional(v.string(), "en-US"),
NEXT_PUBLIC_SITE_LOCALE: z.string().default("en-US"),
/**
* Optional. Consistent timezone for the site. Doesn't really matter what it is, as long as it's the same everywhere
@@ -141,7 +145,7 @@ export const env = createEnv({
*
* @see https://en.wikipedia.org/wiki/List_of_tz_database_time_zones#List
*/
NEXT_PUBLIC_SITE_TZ: v.optional(v.string(), "America/New_York"),
NEXT_PUBLIC_SITE_TZ: z.string().default("America/New_York"),
/**
* Required. Site key must be prefixed with NEXT_PUBLIC_ since it is used to embed the captcha widget. Falls back to
@@ -149,7 +153,7 @@ export const env = createEnv({
*
* @see https://developers.cloudflare.com/turnstile/troubleshooting/testing/
*/
NEXT_PUBLIC_TURNSTILE_SITE_KEY: v.optional(v.string(), "1x00000000000000000000AA"),
NEXT_PUBLIC_TURNSTILE_SITE_KEY: z.string().default("1x00000000000000000000AA"),
},
experimental__runtimeEnv: {
NEXT_PUBLIC_BASE_URL: process.env.NEXT_PUBLIC_BASE_URL,
+17 -28
View File
@@ -2,36 +2,25 @@
import { env } from "@/lib/env";
import { headers } from "next/headers";
import * as v from "valibot";
import { Resend } from "resend";
import { z } from "zod";
import siteConfig from "@/lib/config/site";
const ContactSchema = v.object({
name: v.message(v.pipe(v.string(), v.trim(), v.nonEmpty()), "Your name is required."),
email: v.message(v.pipe(v.string(), v.trim(), v.nonEmpty(), v.email()), "Your email address is required."),
message: v.message(v.pipe(v.string(), v.trim(), v.minLength(15)), "Your message must be at least 15 characters."),
"cf-turnstile-response": v.message(
v.pipe(
// token wasn't submitted at _all_, most likely a direct POST request by a spam bot
v.string(),
// form submitted properly but token was missing, might be a forgetful human
v.nonEmpty(),
// very rudimentary length check based on Cloudflare's docs
// https://developers.cloudflare.com/turnstile/troubleshooting/testing/
// https://developers.cloudflare.com/turnstile/get-started/server-side-validation/
v.maxLength(2048),
v.readonly()
),
"Are you sure you're not a robot...? 🤖"
),
});
const ContactSchema = z
.object({
name: z.string().trim().min(1, { message: "Your name is required." }),
email: z.string().email({ message: "Your email address is required." }),
message: z.string().trim().min(15, { message: "Your message must be at least 15 characters." }),
"cf-turnstile-response": z.string().min(1, { message: "Are you sure you're not a robot...? 🤖" }),
})
.readonly();
export type ContactInput = v.InferInput<typeof ContactSchema>;
export type ContactInput = z.infer<typeof ContactSchema>;
export type ContactState = {
success: boolean;
message: string;
errors?: v.FlatErrors<typeof ContactSchema>["nested"];
errors?: Record<string, string[]>;
};
export const send = async (state: ContactState, payload: FormData): Promise<ContactState> => {
@@ -39,13 +28,13 @@ export const send = async (state: ContactState, payload: FormData): Promise<Cont
console.debug("[server/resend] received payload:", payload);
try {
const data = v.safeParse(ContactSchema, Object.fromEntries(payload));
const data = ContactSchema.safeParse(Object.fromEntries(payload));
if (!data.success) {
return {
success: false,
message: "Please make sure all fields are filled in.",
errors: v.flatten(data.issues).nested,
errors: data.error.flatten().fieldErrors,
};
}
@@ -61,7 +50,7 @@ export const send = async (state: ContactState, payload: FormData): Promise<Cont
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
secret: env.TURNSTILE_SECRET_KEY,
response: data.output["cf-turnstile-response"],
response: data.data["cf-turnstile-response"],
remoteip,
}),
cache: "no-store",
@@ -88,11 +77,11 @@ export const send = async (state: ContactState, payload: FormData): Promise<Cont
// send email
const resend = new Resend(env.RESEND_API_KEY);
await resend.emails.send({
from: `${data.output.name} <${env.RESEND_FROM_EMAIL || "onboarding@resend.dev"}>`,
replyTo: `${data.output.name} <${data.output.email}>`,
from: `${data.data.name} <${env.RESEND_FROM_EMAIL || "onboarding@resend.dev"}>`,
replyTo: `${data.data.name} <${data.data.email}>`,
to: [env.RESEND_TO_EMAIL],
subject: `[${siteConfig.name}] Contact Form Submission`,
text: data.output.message,
text: data.data.message,
});
return { success: true, message: "Thanks! You should hear from me soon." };
+3 -3
View File
@@ -8,16 +8,16 @@ import { page } from "@/lib/db/schema";
export const incrementViews = async (slug: string): Promise<number> => {
try {
// First, try to find the existing record
const existingHit = await db.select().from(page).where(eq(page.slug, slug)).limit(1);
const existingPage = await db.select().from(page).where(eq(page.slug, slug)).limit(1);
if (existingHit.length === 0) {
if (existingPage.length === 0) {
// Create new record if it doesn't exist
await db.insert(page).values({ slug, views: 1 }).execute();
return 1; // New record starts with 1 hit
} else {
// Calculate new hit count
const newViewCount = existingHit[0].views + 1;
const newViewCount = existingPage[0].views + 1;
// Update existing record by incrementing hits
await db.update(page).set({ views: newViewCount }).where(eq(page.slug, slug)).execute();