Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,8 +1,17 @@
NEXT_PUBLIC_SITE_URL=https://ghimtech.org
# Server-only HTTPS endpoint that durably receives project enquiries.
# Enquiry delivery: configure ONE of the two options below. The webhook wins if both are set.
# Option A: server-only HTTPS endpoint that durably receives project enquiries.
PROJECT_WEBHOOK_URL=
PROJECT_WEBHOOK_TOKEN=
# Shared rate limiter. Required for submissions.
# Option B: email each enquiry through Resend (https://resend.com) to ENQUIRY_INBOX.
# ENQUIRY_FROM is optional. The default onboarding@resend.dev sender only delivers to the
# Resend account owner's own email; set a verified-domain sender to deliver anywhere else.
RESEND_API_KEY=
ENQUIRY_INBOX=
ENQUIRY_FROM=
# Shared rate limiter. Required for submissions. The Upstash integration on the Vercel
# Marketplace injects KV_REST_API_URL / KV_REST_API_TOKEN instead, which are accepted as-is.
UPSTASH_REDIS_REST_URL=
UPSTASH_REDIS_REST_TOKEN=
# Optional. Secret for hashing rate-limit keys; derived from the Redis token when unset.
RATE_LIMIT_SECRET=
75 changes: 65 additions & 10 deletions apps/web/src/app/api/project/route.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { createHmac } from "node:crypto";
import { createHash, createHmac } from "node:crypto";
import { validateEnquiry } from "@/lib/enquiry.mjs";
import { siteUrl } from "@/lib/site";
export const runtime = "nodejs";
Expand Down Expand Up @@ -43,6 +43,34 @@ async function readBody(request: Request) {
}
return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
}
type Enquiry = ReturnType<typeof validateEnquiry>["data"];
const labels: [keyof Enquiry, string][] = [
["name", "Name"],
["email", "Email"],
["company", "Company"],
["website", "Website"],
["budget", "Budget"],
["timeline", "Timeline"],
["business", "What the company does"],
["bottleneck", "What is slowing them down"],
["currentProcess", "How the process is handled today"],
["idealSystem", "What the ideal system would do"],
["context", "Additional context"],
];
// Header values must stay on one line; enquiry text may legitimately contain newlines.
const line = (value: string) => value.replace(/\s+/g, " ").trim();
function renderEnquiry(id: string, data: Enquiry, receivedAt: string) {
const sections = labels
.filter(([key]) => data[key])
.map(([key, label]) => label + "\n" + data[key]);
return [
"New project enquiry from " + siteUrl + "/contact",
"Received " + receivedAt,
"Reference " + id,
"",
...sections.flatMap((section) => [section, ""]),
].join("\n");
}
export async function POST(request: Request) {
const origin = request.headers.get("origin");
const expected = new URL(siteUrl).origin;
Expand Down Expand Up @@ -76,15 +104,28 @@ export async function POST(request: Request) {
const { data, errors } = validateEnquiry(input);
if (Object.keys(errors).length)
return reply(422, "Please check the highlighted fields.", { errors });
// Delivery is either a durable HTTPS webhook or an email through Resend.
// The webhook wins when both are configured.
const webhook = process.env.PROJECT_WEBHOOK_URL;
const token = process.env.PROJECT_WEBHOOK_TOKEN;
const redis = process.env.UPSTASH_REDIS_REST_URL;
const redisToken = process.env.UPSTASH_REDIS_REST_TOKEN;
const secret = process.env.RATE_LIMIT_SECRET;
if (!webhook || !token || !redis || !redisToken || !secret) return reply(503, unavailable);
const resendKey = process.env.RESEND_API_KEY;
const inbox = process.env.ENQUIRY_INBOX;
const sender = process.env.ENQUIRY_FROM || "GhimTech Enquiries <onboarding@resend.dev>";
// Upstash via the Vercel Marketplace injects KV_REST_API_*; a direct Upstash setup uses UPSTASH_REDIS_REST_*.
const redis = process.env.UPSTASH_REDIS_REST_URL || process.env.KV_REST_API_URL;
const redisToken = process.env.UPSTASH_REDIS_REST_TOKEN || process.env.KV_REST_API_TOKEN;
// Hashing secret keeps stored IP and email keys unlinkable. Without an explicit one it is
// derived from the Redis token, which anyone able to read the keys already holds.
const secret =
process.env.RATE_LIMIT_SECRET ||
(redisToken && createHash("sha256").update("ghimtech-rate-limit:" + redisToken).digest("hex"));
const viaWebhook = !!(webhook && token);
const viaResend = !!(resendKey && inbox && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(inbox));
if ((!viaWebhook && !viaResend) || !redis || !redisToken || !secret)
return reply(503, unavailable);
try {
if (new URL(webhook).protocol !== "https:" || new URL(redis).protocol !== "https:")
return reply(503, unavailable);
if (viaWebhook && new URL(webhook!).protocol !== "https:") return reply(503, unavailable);
if (new URL(redis).protocol !== "https:") return reply(503, unavailable);
const hash = (value: string) => createHmac("sha256", secret).update(value).digest("hex");
// Only trust the platform-controlled client IP header on Vercel.
// Other hosts use a shared bucket until an explicit trusted proxy is configured.
Expand Down Expand Up @@ -115,14 +156,28 @@ export async function POST(request: Request) {
"Too many enquiries have been sent. Please wait an hour before trying again.",
);
const receipt = hash(input.requestId + JSON.stringify(data));
const delivered = await fetch(webhook, {
const receivedAt = new Date().toISOString();
const delivery = viaWebhook
? { url: webhook!, auth: token!, body: { id: receipt, ...data, receivedAt } }
: {
url: "https://api.resend.com/emails",
auth: resendKey!,
body: {
from: sender,
to: [inbox!],
reply_to: data.email,
subject: line("Project enquiry: " + data.name + " at " + data.company),
text: renderEnquiry(receipt, data, receivedAt),
},
};
const delivered = await fetch(delivery.url, {
method: "POST",
headers: {
Authorization: "Bearer " + token,
Authorization: "Bearer " + delivery.auth,
"Content-Type": "application/json",
"Idempotency-Key": receipt,
},
body: JSON.stringify({ id: receipt, ...data, receivedAt: new Date().toISOString() }),
body: JSON.stringify(delivery.body),
redirect: "error",
signal: AbortSignal.timeout(10000),
cache: "no-store",
Expand Down
5 changes: 4 additions & 1 deletion apps/web/src/app/contact/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { PageIntro } from "@/components/primitives";
import { ProjectForm } from "@/components/project-form";
import { pageMetadata } from "@/lib/site";
import { contactEmail, pageMetadata } from "@/lib/site";
export const metadata = pageMetadata(
"Start a Project",
"Tell GhimTech about the workflow, manual process, or administrative bottleneck that is slowing your business down.",
Expand Down Expand Up @@ -31,6 +31,9 @@ export default function Contact() {
<li>We review where a system could help.</li>
<li>We discuss scope, fit, and a practical next step.</li>
</ol>
<p className="contact-email">
Prefer email? Write to <a href={"mailto:" + contactEmail}>{contactEmail}</a>.
</p>
</aside>
<ProjectForm />
</div>
Expand Down
10 changes: 10 additions & 0 deletions apps/web/src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -1230,6 +1230,16 @@ a:hover {
.contact-aside li {
padding: 10px 0;
}
.contact-email {
margin-top: 20px;
font-size: 13px;
color: var(--muted);
}
.contact-email a {
color: var(--ink);
text-decoration: underline;
text-underline-offset: 3px;
}
.project-form {
min-width: 0;
}
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/components/primitives.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import Link from "next/link";
import { contactEmail } from "@/lib/site";
import type { ReactNode } from "react";
export function Action({
href,
Expand Down Expand Up @@ -88,6 +89,7 @@ export function Footer() {
<div className="footer-bottom">
<span>© {new Date().getFullYear()} GhimTech</span>
<div>
<a href={"mailto:" + contactEmail}>{contactEmail}</a>
<a href="https://github.com/durga710">GitHub ↗</a>
<Link href="/privacy">Privacy</Link>
<span>Built by GhimTech.</span>
Expand Down
7 changes: 7 additions & 0 deletions apps/web/src/components/project-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import Link from "next/link";
import { useRef, useState } from "react";
import type { FormEvent } from "react";
import { budgets, timelines, validateEnquiry } from "@/lib/enquiry.mjs";
import { contactEmail } from "@/lib/site";
type Errors = Record<string, string>;
export function ProjectForm() {
const [errors, setErrors] = useState<Errors>({});
Expand Down Expand Up @@ -186,6 +187,12 @@ export function ProjectForm() {
>
{success && <h2>A useful conversation starts here.</h2>}
{status && <p>{status}</p>}
{status && !success && (
<p>
You can also send the same details by email to{" "}
<a href={"mailto:" + contactEmail}>{contactEmail}</a>.
</p>
)}
</div>
</div>
);
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/lib/site.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { Metadata } from "next";
export const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || "https://ghimtech.org";
export const contactEmail = "hello@ghimtech.org";
export function pageMetadata(title: string, description: string, path: string): Metadata {
return {
title,
Expand Down
93 changes: 93 additions & 0 deletions apps/web/tests/project-route.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,11 @@ const keys = [
"UPSTASH_REDIS_REST_URL",
"UPSTASH_REDIS_REST_TOKEN",
"RATE_LIMIT_SECRET",
"RESEND_API_KEY",
"ENQUIRY_INBOX",
"ENQUIRY_FROM",
"KV_REST_API_URL",
"KV_REST_API_TOKEN",
];
const original = Object.fromEntries(keys.map((k) => [k, process.env[k]]));
function request(data = valid, headers = {}) {
Expand Down Expand Up @@ -137,3 +142,91 @@ test("network failures preserve an explicit failure response", async () => {
assert.equal(response.status, 503);
assert.ok(!(await response.text()).includes("Internal network details"));
});
function setupResend() {
process.env.RESEND_API_KEY = "re_test_only";
process.env.ENQUIRY_INBOX = "owner@example.com";
process.env.UPSTASH_REDIS_REST_URL = "https://redis.example.com";
process.env.UPSTASH_REDIS_REST_TOKEN = "test-only";
process.env.RATE_LIMIT_SECRET = "test-only-random-secret";
}
test("resend delivery emails the inbox with reply-to and an idempotency key", async () => {
setupResend();
const sent = [];
globalThis.fetch = async (url, options) => {
if (String(url).includes("redis")) return Response.json({ result: 1 });
sent.push({ url: String(url), options });
return Response.json({ id: "email-id" });
};
const response = await POST(request({ ...valid, name: "Line\nBreak", context: "More\ndetail" }));
assert.equal(response.status, 200);
assert.equal(sent.length, 1);
assert.equal(sent[0].url, "https://api.resend.com/emails");
assert.equal(sent[0].options.headers.Authorization, "Bearer re_test_only");
assert.match(sent[0].options.headers["Idempotency-Key"], /^[0-9a-f]{64}$/);
assert.equal(sent[0].options.redirect, "error");
const body = JSON.parse(sent[0].options.body);
assert.deepEqual(body.to, ["owner@example.com"]);
assert.equal(body.reply_to, valid.email);
assert.equal(body.from, "GhimTech Enquiries <onboarding@resend.dev>");
assert.equal(body.subject, "Project enquiry: Line Break at Example");
assert.ok(body.text.includes("What is slowing them down\nManual intake"));
assert.ok(body.text.includes("Additional context\nMore\ndetail"));
assert.ok(!body.text.includes("undefined"));
});
test("resend mode honours a configured sender and fails closed on rejection", async () => {
setupResend();
process.env.ENQUIRY_FROM = "GhimTech <hello@ghimtech.org>";
let from;
globalThis.fetch = async (url, options) => {
if (String(url).includes("redis")) return Response.json({ result: 1 });
from = JSON.parse(options.body).from;
return Response.json({ message: "invalid key" }, { status: 401 });
};
assert.equal((await POST(request())).status, 503);
assert.equal(from, "GhimTech <hello@ghimtech.org>");
});
test("resend mode requires a valid inbox and the shared limiter", async () => {
globalThis.fetch = () => {
throw new Error("Must not send");
};
setupResend();
process.env.ENQUIRY_INBOX = "not-an-email";
assert.equal((await POST(request())).status, 503);
setupResend();
delete process.env.UPSTASH_REDIS_REST_URL;
assert.equal((await POST(request())).status, 503);
});
test("webhook takes precedence when both deliveries are configured", async () => {
setup();
setupResend();
const urls = [];
globalThis.fetch = async (url) => {
urls.push(String(url));
return Response.json({ result: 1 });
};
assert.equal((await POST(request())).status, 200);
assert.deepEqual(urls, ["https://redis.example.com", "https://receiver.example.com"]);
});
test("accepts the Vercel Marketplace KV variable names and derives the hashing secret", async () => {
setupResend();
delete process.env.UPSTASH_REDIS_REST_URL;
delete process.env.UPSTASH_REDIS_REST_TOKEN;
delete process.env.RATE_LIMIT_SECRET;
process.env.KV_REST_API_URL = "https://kv.example.com";
process.env.KV_REST_API_TOKEN = "kv-test-only";
const calls = [];
globalThis.fetch = async (url, options) => {
calls.push({ url: String(url), options });
return String(url).includes("kv.example") ? Response.json({ result: 1 }) : Response.json({ id: "x" });
};
assert.equal((await POST(request())).status, 200);
assert.equal(calls[0].url, "https://kv.example.com");
assert.equal(calls[0].options.headers.Authorization, "Bearer kv-test-only");
const keysUsed = JSON.parse(calls[0].options.body).slice(3);
assert.match(keysUsed[0], /^ghimtech:ip:[0-9a-f]{64}$/);
assert.match(keysUsed[1], /^ghimtech:email:[0-9a-f]{64}$/);
// Still fails closed with no Redis at all.
delete process.env.KV_REST_API_URL;
delete process.env.KV_REST_API_TOKEN;
assert.equal((await POST(request())).status, 503);
});
Loading