Initial clean commit
This commit is contained in:
1755
contact-api/package-lock.json
generated
Normal file
1755
contact-api/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
29
contact-api/package.json
Normal file
29
contact-api/package.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "contact-api",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"start": "node dist/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^17.2.3",
|
||||
"express": "^4.21.2",
|
||||
"express-rate-limit": "^7.5.1",
|
||||
"helmet": "^8.1.0",
|
||||
"nodemailer": "^7.0.10",
|
||||
"pino-http": "^11.0.0",
|
||||
"zod": "^3.24.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cors": "^2.8.19",
|
||||
"@types/express": "^5.0.3",
|
||||
"@types/node": "^24.6.0",
|
||||
"@types/nodemailer": "^7.0.2",
|
||||
"tsx": "^4.20.5",
|
||||
"typescript": "~5.9.3"
|
||||
}
|
||||
}
|
||||
43
contact-api/src/config.ts
Normal file
43
contact-api/src/config.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import dotenv from "dotenv";
|
||||
import { z } from "zod";
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const boolFromEnv = z
|
||||
.enum(["true", "false"])
|
||||
.transform((value) => value === "true");
|
||||
|
||||
const envSchema = z.object({
|
||||
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
|
||||
PORT: z.coerce.number().int().positive().default(8787),
|
||||
// Comma-separated list of allowed browser origins, e.g.
|
||||
// https://jodyholt.com,https://www.jodyholt.com
|
||||
CONTACT_ALLOWED_ORIGIN: z.string().min(1),
|
||||
TURNSTILE_SECRET_KEY: z.string().min(1),
|
||||
TURNSTILE_EXPECTED_HOSTNAME: z.string().min(1),
|
||||
TURNSTILE_EXPECTED_ACTION: z.string().min(1).default("contact_form"),
|
||||
SMTP_HOST: z.string().min(1),
|
||||
SMTP_PORT: z.coerce.number().int().positive().default(587),
|
||||
SMTP_SECURE: boolFromEnv.default("false"),
|
||||
SMTP_REQUIRE_TLS: boolFromEnv.default("true"),
|
||||
SMTP_USER: z.string().min(1),
|
||||
SMTP_PASS: z.string().min(1),
|
||||
MAIL_FROM_NAME: z.string().min(1).default("Portfolio Contact"),
|
||||
MAIL_FROM_ADDRESS: z.string().email(),
|
||||
MAIL_TO_ADDRESS: z.string().email(),
|
||||
MAIL_SUBJECT_PREFIX: z.string().min(1).default("[Portfolio Contact]"),
|
||||
RATE_LIMIT_WINDOW_MS: z.coerce.number().int().positive().default(600000),
|
||||
RATE_LIMIT_MAX: z.coerce.number().int().positive().default(5),
|
||||
HONEYPOT_FIELD: z.string().min(1).default("website"),
|
||||
MIN_SUBMIT_TIME_MS: z.coerce.number().int().nonnegative().default(3000),
|
||||
});
|
||||
|
||||
const parsed = envSchema.safeParse(process.env);
|
||||
|
||||
if (!parsed.success) {
|
||||
console.error("Invalid contact-api environment configuration:");
|
||||
console.error(parsed.error.flatten().fieldErrors);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
export const config = parsed.data;
|
||||
163
contact-api/src/index.ts
Normal file
163
contact-api/src/index.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import cors from "cors";
|
||||
import express, { type NextFunction, type Request, type Response } from "express";
|
||||
import rateLimit from "express-rate-limit";
|
||||
import helmet from "helmet";
|
||||
import { pinoHttp } from "pino-http";
|
||||
import { config } from "./config.js";
|
||||
import { sendContactEmail, verifyMailerConnection } from "./mailer.js";
|
||||
import { verifyTurnstileToken } from "./turnstile.js";
|
||||
import { contactRequestSchema } from "./validation.js";
|
||||
|
||||
type ApiErrorResponse = {
|
||||
ok: false;
|
||||
error: string;
|
||||
};
|
||||
|
||||
const app = express();
|
||||
app.set("trust proxy", 1);
|
||||
|
||||
const normalizeOrigin = (value: string): string => {
|
||||
const cleaned = value
|
||||
.trim()
|
||||
.replace(/^['"]|['"]$/g, "")
|
||||
.replace(/\/+$/g, "");
|
||||
|
||||
// Some clients can emit explicit default ports. URL.origin normalizes them.
|
||||
try {
|
||||
return new URL(cleaned).origin.toLowerCase();
|
||||
} catch {
|
||||
return cleaned.toLowerCase();
|
||||
}
|
||||
};
|
||||
|
||||
const allowedOrigins = config.CONTACT_ALLOWED_ORIGIN
|
||||
.split(",")
|
||||
.map(normalizeOrigin)
|
||||
.filter((value, index, all) => value.length > 0 && all.indexOf(value) === index);
|
||||
|
||||
app.use(
|
||||
pinoHttp({
|
||||
level: config.NODE_ENV === "production" ? "info" : "debug",
|
||||
redact: {
|
||||
paths: ["req.headers.authorization"],
|
||||
remove: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
app.use(helmet());
|
||||
app.use(
|
||||
cors({
|
||||
origin(origin, callback) {
|
||||
if (!origin) {
|
||||
callback(null, true);
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedOrigin = normalizeOrigin(origin);
|
||||
if (allowedOrigins.includes(normalizedOrigin)) {
|
||||
callback(null, true);
|
||||
return;
|
||||
}
|
||||
|
||||
callback(new Error("Origin not allowed"));
|
||||
},
|
||||
methods: ["GET", "POST", "OPTIONS"],
|
||||
allowedHeaders: ["Content-Type"],
|
||||
}),
|
||||
);
|
||||
|
||||
app.use(express.json({ limit: "16kb" }));
|
||||
|
||||
app.use((err: Error, _req: Request, res: Response<ApiErrorResponse>, next: NextFunction) => {
|
||||
if (err.message === "Origin not allowed") {
|
||||
res.status(403).json({ ok: false, error: "Origin not allowed." });
|
||||
return;
|
||||
}
|
||||
|
||||
next(err);
|
||||
});
|
||||
|
||||
const contactLimiter = rateLimit({
|
||||
windowMs: config.RATE_LIMIT_WINDOW_MS,
|
||||
max: config.RATE_LIMIT_MAX,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
message: {
|
||||
ok: false,
|
||||
error: "Too many requests. Please try again later.",
|
||||
},
|
||||
});
|
||||
|
||||
app.get("/health", (_req, res) => {
|
||||
res.status(200).json({ ok: true });
|
||||
});
|
||||
|
||||
app.post("/contact", contactLimiter, async (req: Request, res: Response) => {
|
||||
const body = (req.body ?? {}) as Record<string, unknown>;
|
||||
|
||||
const honeypotRaw = body[config.HONEYPOT_FIELD];
|
||||
if (typeof honeypotRaw === "string" && honeypotRaw.trim().length > 0) {
|
||||
req.log.warn({ event: "contact_honeypot_triggered" }, "Contact form blocked by honeypot field");
|
||||
res.status(400).json({ ok: false, error: "Invalid submission." });
|
||||
return;
|
||||
}
|
||||
|
||||
const startedAt = Number(body.startedAt);
|
||||
if (!Number.isFinite(startedAt) || Date.now() - startedAt < config.MIN_SUBMIT_TIME_MS) {
|
||||
req.log.warn({ event: "contact_submit_too_fast" }, "Contact form rejected by min submit time");
|
||||
res.status(400).json({ ok: false, error: "Please wait a moment and try again." });
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed = contactRequestSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
req.log.warn({ event: "contact_validation_failed", issues: parsed.error.issues }, "Invalid contact payload");
|
||||
res.status(400).json({ ok: false, error: "Please review your form entries and try again." });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const turnstileResult = await verifyTurnstileToken(parsed.data.turnstileToken, req.ip);
|
||||
if (!turnstileResult.ok) {
|
||||
req.log.warn(
|
||||
{ event: "turnstile_failed", reason: turnstileResult.reason },
|
||||
"Turnstile verification failed",
|
||||
);
|
||||
res.status(403).json({ ok: false, error: "Human verification failed. Please try again." });
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
req.log.error({ err: error }, "Turnstile verification request failed");
|
||||
res.status(502).json({ ok: false, error: "Unable to verify your request right now." });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await sendContactEmail(parsed.data, req.ip ?? "");
|
||||
req.log.info({ event: "contact_sent" }, "Contact email sent");
|
||||
res.status(200).json({ ok: true, message: "Message sent." });
|
||||
} catch (error) {
|
||||
req.log.error({ err: error }, "Failed to send contact email");
|
||||
res.status(502).json({ ok: false, error: "Unable to send message. Please try again soon." });
|
||||
}
|
||||
});
|
||||
|
||||
app.use((err: Error, req: Request, res: Response<ApiErrorResponse>, _next: NextFunction) => {
|
||||
void _next;
|
||||
req.log.error({ err }, "Unhandled contact-api error");
|
||||
res.status(500).json({ ok: false, error: "Internal server error." });
|
||||
});
|
||||
|
||||
const startServer = async (): Promise<void> => {
|
||||
await verifyMailerConnection();
|
||||
|
||||
app.listen(config.PORT, () => {
|
||||
console.log(`contact-api listening on port ${config.PORT}`);
|
||||
});
|
||||
};
|
||||
|
||||
startServer().catch((error) => {
|
||||
console.error("Failed to start contact-api", error);
|
||||
process.exit(1);
|
||||
});
|
||||
65
contact-api/src/mailer.ts
Normal file
65
contact-api/src/mailer.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import nodemailer from "nodemailer";
|
||||
import { config } from "./config.js";
|
||||
import type { ContactRequest } from "./validation.js";
|
||||
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: config.SMTP_HOST,
|
||||
port: config.SMTP_PORT,
|
||||
secure: config.SMTP_SECURE,
|
||||
requireTLS: config.SMTP_REQUIRE_TLS,
|
||||
auth: {
|
||||
user: config.SMTP_USER,
|
||||
pass: config.SMTP_PASS,
|
||||
},
|
||||
tls: {
|
||||
rejectUnauthorized: true,
|
||||
},
|
||||
});
|
||||
|
||||
const escapeHtml = (value: string): string =>
|
||||
value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
|
||||
const newlineToBreaks = (value: string): string =>
|
||||
escapeHtml(value).replace(/\n/g, "<br>");
|
||||
|
||||
export async function verifyMailerConnection(): Promise<void> {
|
||||
await transporter.verify();
|
||||
}
|
||||
|
||||
export async function sendContactEmail(payload: ContactRequest, sourceIp: string): Promise<void> {
|
||||
const receivedAt = new Date().toISOString();
|
||||
const subject = `${config.MAIL_SUBJECT_PREFIX} ${payload.name}`;
|
||||
|
||||
const textBody = [
|
||||
`Name: ${payload.name}`,
|
||||
`Email: ${payload.email}`,
|
||||
`Source IP: ${sourceIp || "unknown"}`,
|
||||
`Received At: ${receivedAt}`,
|
||||
"",
|
||||
"Message:",
|
||||
payload.message,
|
||||
].join("\n");
|
||||
|
||||
const htmlBody = `
|
||||
<h2>Portfolio Contact Submission</h2>
|
||||
<p><strong>Name:</strong> ${escapeHtml(payload.name)}</p>
|
||||
<p><strong>Email:</strong> ${escapeHtml(payload.email)}</p>
|
||||
<p><strong>Source IP:</strong> ${escapeHtml(sourceIp || "unknown")}</p>
|
||||
<p><strong>Received At:</strong> ${escapeHtml(receivedAt)}</p>
|
||||
<p><strong>Message:</strong><br>${newlineToBreaks(payload.message)}</p>
|
||||
`;
|
||||
|
||||
await transporter.sendMail({
|
||||
from: `"${config.MAIL_FROM_NAME}" <${config.MAIL_FROM_ADDRESS}>`,
|
||||
to: config.MAIL_TO_ADDRESS,
|
||||
replyTo: payload.email,
|
||||
subject,
|
||||
text: textBody,
|
||||
html: htmlBody,
|
||||
});
|
||||
}
|
||||
60
contact-api/src/turnstile.ts
Normal file
60
contact-api/src/turnstile.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { config } from "./config.js";
|
||||
|
||||
type TurnstileVerifyResponse = {
|
||||
success: boolean;
|
||||
hostname?: string;
|
||||
action?: string;
|
||||
"error-codes"?: string[];
|
||||
};
|
||||
|
||||
const normalizeHostname = (value: string): string =>
|
||||
value
|
||||
.trim()
|
||||
.replace(/^['"]|['"]$/g, "")
|
||||
.replace(/\.+$/g, "")
|
||||
.toLowerCase();
|
||||
|
||||
const expectedHostnames = config.TURNSTILE_EXPECTED_HOSTNAME
|
||||
.split(",")
|
||||
.map(normalizeHostname)
|
||||
.filter((value, index, all) => value.length > 0 && all.indexOf(value) === index);
|
||||
|
||||
export async function verifyTurnstileToken(
|
||||
token: string,
|
||||
remoteIp?: string,
|
||||
): Promise<{ ok: boolean; reason?: string }> {
|
||||
const body = new URLSearchParams({
|
||||
secret: config.TURNSTILE_SECRET_KEY,
|
||||
response: token,
|
||||
});
|
||||
|
||||
if (remoteIp) {
|
||||
body.append("remoteip", remoteIp);
|
||||
}
|
||||
|
||||
const response = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
|
||||
method: "POST",
|
||||
body,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return { ok: false, reason: `turnstile_http_${response.status}` };
|
||||
}
|
||||
|
||||
const result = (await response.json()) as TurnstileVerifyResponse;
|
||||
|
||||
if (!result.success) {
|
||||
const codes = result["error-codes"]?.join(",") ?? "verification_failed";
|
||||
return { ok: false, reason: codes };
|
||||
}
|
||||
|
||||
if (!result.hostname || !expectedHostnames.includes(normalizeHostname(result.hostname))) {
|
||||
return { ok: false, reason: "hostname_mismatch" };
|
||||
}
|
||||
|
||||
if (result.action !== config.TURNSTILE_EXPECTED_ACTION) {
|
||||
return { ok: false, reason: "action_mismatch" };
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
12
contact-api/src/validation.ts
Normal file
12
contact-api/src/validation.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const contactRequestSchema = z.object({
|
||||
name: z.string().trim().min(2).max(80),
|
||||
email: z.string().trim().email().max(320),
|
||||
message: z.string().trim().min(20).max(2000),
|
||||
website: z.string().optional().default(""),
|
||||
startedAt: z.coerce.number().int().positive(),
|
||||
turnstileToken: z.string().trim().min(1),
|
||||
});
|
||||
|
||||
export type ContactRequest = z.infer<typeof contactRequestSchema>;
|
||||
18
contact-api/tsconfig.json
Normal file
18
contact-api/tsconfig.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"types": ["node"],
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user