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
47 changes: 46 additions & 1 deletion app/api/careers/apply/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,27 @@
//
// Three fields and a link — no file upload, so we never take custody of a
// resume. Writes with the service role because applicants have no session.
//
// Being unauthenticated and on the open internet, this endpoint carries two
// spam defences. The (job_id, email) unique constraint only stops an honest
// double-submit; a script that varies the address walks straight past it.
// 1. A honeypot field the widget renders hidden. Humans never fill it.
// 2. A per-source hourly cap, counted off a salted hash of the client IP.

import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { serviceClient } from "@/lib/supabase/service";
import { isValidEmail, normalizeLink } from "@/lib/careers/jobs";
import { notifyNewApplication } from "@/lib/careers/notify";
import { clientIpFromHeaders } from "@/lib/tracker/geo";
import { hashIp } from "@/lib/rateLimit";

export const runtime = "nodejs";

// Applications allowed from one source per hour. A real person applying to
// several roles at one company stays well under it; a scripted flood does not.
export const APPLY_HOURLY_CAP = 8;

const bodySchema = z.object({
site: z.string().uuid(),
job: z.string().uuid(),
Expand All @@ -19,6 +32,8 @@ const bodySchema = z.object({
link: z.string().max(500).nullable().optional(),
note: z.string().max(2000).nullable().optional(),
url: z.string().max(2048).nullable().optional(),
// Honeypot. Named to look worth filling in to a bot scanning field names.
company: z.string().max(200).nullable().optional(),
});

function corsHeaders(request: Request) {
Expand Down Expand Up @@ -55,6 +70,12 @@ export async function POST(request: NextRequest) {
if (!parsed.success) return fail(request, "Check the form and try again.");
const body = parsed.data;

// Honeypot tripped: answer exactly as we would on success, so whatever is
// filling it gets no signal to adapt. Nothing is written.
if (body.company && body.company.trim()) {
return NextResponse.json({ ok: true }, { headers: corsHeaders(request) });
}

const fullName = body.fullName.trim().replace(/\s+/g, " ");
const email = body.email.trim().toLowerCase();
if (!fullName) return fail(request, "Enter your name.");
Expand All @@ -69,13 +90,26 @@ export async function POST(request: NextRequest) {
}

const supabase = serviceClient();
const ipHash = hashIp(clientIpFromHeaders(request.headers));

// Per-source hourly cap. Counted before the posting lookup so a flood costs
// one indexed count() rather than the full write path.
const since = new Date(Date.now() - 60 * 60 * 1000).toISOString();
const { count } = await supabase
.from("job_applications")
.select("id", { count: "exact", head: true })
.eq("ip_hash", ipHash)
.gte("created_at", since);
if ((count ?? 0) >= APPLY_HOURLY_CAP) {
return fail(request, "Too many applications from here. Try again later.", 429);
}

// The posting must be open and belong to a project with the module on —
// otherwise a stale widget could keep posting to a closed role.
const [{ data: job }, { data: project }] = await Promise.all([
supabase
.from("job_postings")
.select("id, status")
.select("id, status, title")
.eq("id", body.job)
.eq("project_id", body.site)
.maybeSingle(),
Expand Down Expand Up @@ -104,6 +138,7 @@ export async function POST(request: NextRequest) {
link,
note: body.note?.trim().slice(0, 2000) || null,
source_url: body.url?.slice(0, 2048) ?? null,
ip_hash: ipHash,
referrer: request.headers.get("referer")?.slice(0, 2048) ?? null,
user_agent: request.headers.get("user-agent")?.slice(0, 500) ?? null,
updated_at: new Date().toISOString(),
Expand All @@ -117,5 +152,15 @@ export async function POST(request: NextRequest) {
return fail(request, "Could not submit right now. Try again shortly.", 500);
}

// The applicant is done either way — a mail failure must not surface to them
// as a failed application, so this is awaited but never throws.
await notifyNewApplication({
projectId: body.site,
jobTitle: (job as { title?: string }).title ?? "a role",
fullName,
email,
link,
});

return NextResponse.json({ ok: true }, { headers: corsHeaders(request) });
}
8 changes: 7 additions & 1 deletion app/careers.js/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,8 @@ const snippet = `(function(){
'.cp-careers-msg{font-size:.85em}',
'.cp-careers-empty{padding:20px 0;opacity:.7}',
'.cp-careers-credit{margin-top:14px;font-size:.75em;opacity:.55}',
'.cp-careers-credit a{color:inherit}'
'.cp-careers-credit a{color:inherit}',
'.cp-hp{position:absolute;left:-9999px;width:1px;height:1px;overflow:hidden}'
].join('');

function injectStyle() {
Expand Down Expand Up @@ -167,6 +168,10 @@ const snippet = `(function(){
html += '<label>Full Name<input name="fullName" type="text" required maxlength="200" placeholder="Jane Doe" autocomplete="name"></label>';
html += '<label>Email Address<input name="email" type="email" required maxlength="254" placeholder="jane@example.com" autocomplete="email"></label>';
html += '<label>Portfolio / LinkedIn / GitHub<input name="link" type="url" maxlength="500" placeholder="https://github.com/username" autocomplete="url"></label>';
// Honeypot. Positioned off-screen rather than display:none, which some
// bots skip; aria-hidden and tabindex=-1 keep it away from screen
// readers and the tab order so no real applicant can reach it.
html += '<div class="cp-hp" aria-hidden="true"><label>Company<input name="company" type="text" tabindex="-1" autocomplete="off"></label></div>';
html += '<div class="cp-careers-actions"><button type="submit" class="cp-careers-submit">Submit application</button><span class="cp-careers-msg" role="status"></span></div>';
html += '</form>';
}
Expand Down Expand Up @@ -262,6 +267,7 @@ const snippet = `(function(){
fullName: form.fullName.value,
email: form.email.value,
link: form.link.value,
company: form.company ? form.company.value : '',
url: location.origin + location.pathname
};
if (!payloadBody.fullName.trim()) { msg.textContent = 'Enter your name.'; return; }
Expand Down
53 changes: 52 additions & 1 deletion app/sitemap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,5 +65,56 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
// Don't 500 the sitemap if Supabase is briefly unreachable.
}

return [...staticEntries, ...reportEntries];
// Hosted job boards and the individual postings under them. These exist so
// that a client-rendered careers widget still has crawlable HTML behind it —
// which only pays off if crawlers can find the pages, so they belong here.
let careerEntries: MetadataRoute.Sitemap = [];
try {
const svc = serviceClient();
// Two queries rather than an embedded join: the serving RPC gates on both
// flags, so the sitemap has to gate on them too or it advertises boards
// that 404 — and resolving the enabled set first says that plainly.
const { data: enabled } = await svc
.from("projects")
.select("id")
.eq("careers_enabled", true)
.eq("tracker_enabled", true);
const enabledIds = ((enabled ?? []) as Array<{ id: string }>).map((p) => p.id);

if (enabledIds.length > 0) {
const { data } = await svc
.from("job_postings")
.select("slug, project_id, published_at, updated_at")
.eq("status", "open")
.in("project_id", enabledIds)
.order("published_at", { ascending: false })
.limit(500);

const rows = (data ?? []) as Array<{
slug: string;
project_id: string;
published_at: string | null;
updated_at: string | null;
}>;

const boards = new Set(rows.map((r) => r.project_id));
careerEntries = [
...Array.from(boards).map((projectId) => ({
url: `${base}/c/${projectId}`,
changeFrequency: "daily" as const,
priority: 0.7,
})),
...rows.map((row) => ({
url: `${base}/c/${row.project_id}/${row.slug}`,
lastModified: new Date(row.updated_at ?? row.published_at ?? Date.now()),
changeFrequency: "weekly" as const,
priority: 0.6,
})),
];
}
} catch {
// Same rule as above — a sitemap missing job pages beats a 500.
}

return [...staticEntries, ...reportEntries, ...careerEntries];
}
58 changes: 58 additions & 0 deletions lib/careers/notify.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
// Notification for a new job application.
//
// Without this, applications land in the dashboard silently and the employer
// has to think to go and look — which, for a hiring inbox, means good
// candidates go stale. Best-effort throughout: a mail failure must never turn
// a successfully-recorded application into an error for the applicant.

import { env } from "@/lib/env";
import { sendCareersApplicationEmail } from "@/lib/email";
import { serviceClient } from "@/lib/supabase/service";

export interface ApplicationNotice {
projectId: string;
jobTitle: string;
fullName: string;
email: string;
link: string | null;
}

/**
* Email the project owner that someone applied.
*
* Resolves the recipient from the project owner's profile. Returns quietly if
* mail isn't configured, the owner has no address, or the send fails — the
* caller has already written the row and the applicant is already done.
*/
export async function notifyNewApplication(notice: ApplicationNotice): Promise<void> {
try {
const supabase = serviceClient();
const { data: project } = await supabase
.from("projects")
.select("name, owner_id")
.eq("id", notice.projectId)
.maybeSingle();
const ownerId = (project as { owner_id?: string } | null)?.owner_id;
if (!ownerId) return;

const { data: profile } = await supabase
.from("profiles")
.select("email")
.eq("id", ownerId)
.maybeSingle();
const to = (profile as { email?: string | null } | null)?.email;
if (!to) return;

await sendCareersApplicationEmail({
to,
projectName: (project as { name?: string } | null)?.name ?? "your site",
jobTitle: notice.jobTitle,
applicantName: notice.fullName,
applicantEmail: notice.email,
link: notice.link,
inboxUrl: `${env.siteUrl.replace(/\/+$/, "")}/projects/${notice.projectId}/stats/careers`,
});
} catch {
// Swallowed on purpose — see the module comment.
}
}
66 changes: 66 additions & 0 deletions lib/email.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1247,3 +1247,69 @@ export function broadcastEmailHtml(input: {
footerNote: "You're receiving this because you have a CrawlProof account.",
});
}

// New job application landed in a project's careers inbox.
//
// Every field here is applicant-controlled and arrives from an unauthenticated
// public form, so all of it goes through escapeHtml. The portfolio link is
// rendered as text rather than an anchor: it has been normalized to http(s)
// server-side, but there is no reason to make a stranger's URL one click away
// inside the owner's mail client.
export async function sendCareersApplicationEmail(input: {
to: string;
projectName: string;
jobTitle: string;
applicantName: string;
applicantEmail: string;
link: string | null;
inboxUrl: string;
}): Promise<{ sent: boolean; error?: string }> {
const c = client();
if (!c) return { sent: false, error: "RESEND_API_KEY not set" };

const linkRow = input.link
? `<p style="margin:6px 0 0;font-size:14px;color:#94a3b8;">
Portfolio / LinkedIn / GitHub:
<span style="color:#e7e9ee;">${escapeHtml(input.link)}</span>
</p>`
: "";

const innerHtml = `
<tr>
<td style="padding:28px 32px 8px;">
<h1 style="margin:0 0 8px;font-size:22px;font-weight:700;color:#e7e9ee;">New application</h1>
<p style="margin:0 0 12px;font-size:15px;line-height:1.6;color:#94a3b8;">
<strong style="color:#e7e9ee;">${escapeHtml(input.applicantName)}</strong> applied for
<strong style="color:#e7e9ee;">${escapeHtml(input.jobTitle)}</strong>
at ${escapeHtml(input.projectName)}.
</p>
<p style="margin:0;font-size:14px;color:#94a3b8;">
Email: <span style="color:#e7e9ee;">${escapeHtml(input.applicantEmail)}</span>
</p>
${linkRow}
</td>
</tr>
<tr>
<td style="padding:16px 32px 32px;">
<a href="${input.inboxUrl}"
style="display:inline-block;background:#6ee7b7;color:#042f1a;font-weight:700;font-size:15px;
text-decoration:none;padding:12px 28px;border-radius:8px;">
Review applicant
</a>
</td>
</tr>
`;

const res = await c.send({
from: env.resendFrom,
to: input.to,
subject: `New application: ${escapeHtml(input.jobTitle)}`,
html: emailShell({
title: `New application for ${input.jobTitle}`,
innerHtml,
footerNote: "You're receiving this because you own this CrawlProof project.",
}),
});
if (!res.sent) return { sent: false, error: res.error };
return { sent: true };
}
15 changes: 15 additions & 0 deletions supabase/migrations/20260803170000_careers_applicant_ip.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
-- Spam control for the public application form.
--
-- /api/careers/apply is an unauthenticated POST on the open internet. The
-- (job_id, email) unique constraint stops an honest double-submit but does
-- nothing against a script that varies the address, so we need a per-source
-- counter. Storing a salted hash rather than the address itself keeps the
-- rate limit workable without turning the applications table into a log of
-- who visited from where.
alter table public.job_applications
add column if not exists ip_hash text;

-- The rate-limit query is "how many applications from this source recently",
-- so the index leads on ip_hash and orders by time.
create index if not exists job_applications_ip_recent_idx
on public.job_applications(ip_hash, created_at desc);
Loading
Loading