diff --git a/.env.example b/.env.example index d1ce5a4c6..f57774fd7 100644 --- a/.env.example +++ b/.env.example @@ -9,6 +9,10 @@ SERVER_PORT="3001" # OpenGraph metadata, and absolute upload URLs. APP_URL="http://localhost:3000" +# Optional: serve one already-public resume at /. Use the ID from /builder/. +# Unset or blank keeps the marketing home. Restart after changes. +# ROOT_RESUME_ID= + # --- Database (PostgreSQL) --- # PostgreSQL connection URL. In Docker Compose, the hostname is usually `postgres`; # when running directly on your machine, `localhost` is typical. diff --git a/apps/server/src/static/web.test.ts b/apps/server/src/static/web.test.ts index e4df86e91..419b735d8 100644 --- a/apps/server/src/static/web.test.ts +++ b/apps/server/src/static/web.test.ts @@ -2,7 +2,7 @@ import fs from "node:fs/promises"; import { beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ - env: { APP_URL: "https://rxresu.me" }, + env: { APP_URL: "https://rxresu.me", ROOT_RESUME_ID: undefined as string | undefined }, serveStatic: vi.fn((_options?: unknown) => vi.fn()), getPublicResumeSocialMeta: vi.fn(), })); @@ -45,6 +45,7 @@ const staticOptions = mocks.serveStatic.mock.calls[0]?.[0] as StaticOptions | un describe("web app fallback classification", () => { beforeEach(() => { vi.clearAllMocks(); + mocks.env.ROOT_RESUME_ID = undefined; vi.mocked(fs.readFile).mockResolvedValue("app"); mocks.getPublicResumeSocialMeta.mockResolvedValue(null); }); @@ -289,3 +290,28 @@ describe("web app fallback classification", () => { expect(await unknownResponse.text()).toBe(""); }); }); + +describe("configured root shell", () => { + it.each(["GET", "HEAD"])("serves no-store noindex headers for %s", async (method) => { + mocks.env.ROOT_RESUME_ID = "private-or-missing-id"; + const response = await handleWebApp(new Request("https://attacker.example/", { method })); + expect(response.headers.get("X-Robots-Tag")).toBe("noindex, follow"); + expect(response.headers.get("Cache-Control")).toBe("private, no-store"); + }); + it("uses configured canonical root without leaking ID or marketing metadata", async () => { + mocks.env.ROOT_RESUME_ID = "private-or-missing-id"; + vi.mocked(fs.readFile).mockResolvedValue( + 'Marketing title', + ); + const html = await ( + await handleWebApp( + new Request("https://attacker.example/?id=other", { + headers: { host: "attacker.example", "x-forwarded-host": "evil.example" }, + }), + ) + ).text(); + expect(html).toContain(''); + expect(html).toContain(''); + expect(html).not.toMatch(/private-or-missing-id|attacker|evil|Marketing|application\/ld\+json|timelapse/); + }); +}); diff --git a/apps/server/src/static/web.ts b/apps/server/src/static/web.ts index ce90774d5..92f99551d 100644 --- a/apps/server/src/static/web.ts +++ b/apps/server/src/static/web.ts @@ -258,6 +258,14 @@ export const serveWebDistStatic = serveStatic({ }); function getFallbackResponseHeaders(pathname: string) { + if (pathname === "/" && env.ROOT_RESUME_ID?.trim()) { + return { + "Content-Type": "text/html; charset=UTF-8", + "X-Robots-Tag": "noindex, follow", + "Cache-Control": "private, no-store", + ...BASE_SECURITY_HEADERS, + }; + } if (pathname === "/" || indexableAppPaths.has(pathname)) { return { "Content-Type": "text/html; charset=UTF-8", ...BASE_SECURITY_HEADERS }; } @@ -299,6 +307,16 @@ export async function handleWebApp(request: Request) { const html = await fs.readFile(indexHtmlPath, "utf-8"); const canonicalUrl = new URL("/", env.APP_URL).toString(); + if (pathname === "/" && env.ROOT_RESUME_ID?.trim()) { + // Root configuration never discloses a target in the HTML shell. The public API + // gates data and browser metadata; shell requests must not count extra views. + const shell = html + .replace(/[^<]*<\/title>/, "<title>Reactive Resume") + .replace(/]*>/, ''); + const markup = ``; + return new Response(shell.replace("", `${markup}`), { headers }); + } + if (pathname === "/") { return new Response(html.replace("", `${createRootSeoMarkup(canonicalUrl)}`), { headers }); } diff --git a/apps/web/src/features/auth/pages/resume-password.tsx b/apps/web/src/features/auth/pages/resume-password.tsx index 0ed4a49c3..47a651ca6 100644 --- a/apps/web/src/features/auth/pages/resume-password.tsx +++ b/apps/web/src/features/auth/pages/resume-password.tsx @@ -18,19 +18,18 @@ const formSchema = z.object({ password: z.string().min(6).max(64), }); -type Props = { +type ResumePasswordPageProps = { + username: string; + slug: string; redirectPath: string; }; -export function ResumePasswordPage({ redirectPath }: Props) { +export function ResumePasswordPage({ username, slug, redirectPath }: ResumePasswordPageProps) { const navigate = useNavigate(); const [showPassword, toggleShowPassword] = useToggle(false); const { mutate: verifyPassword } = useMutation(orpc.resume.verifyPassword.mutationOptions()); - const [username, slug] = redirectPath.split("/").slice(1) as [string, string]; - if (!username || !slug) throw navigate({ to: "/" }); - const form = useAppForm({ defaultValues: { password: "" }, validators: { onSubmit: formSchema }, diff --git a/apps/web/src/features/auth/resume-password-search.test.ts b/apps/web/src/features/auth/resume-password-search.test.ts new file mode 100644 index 000000000..aad69bcb6 --- /dev/null +++ b/apps/web/src/features/auth/resume-password-search.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { resumePasswordSearchSchema } from "./resume-password-search"; + +describe("resume password continuation", () => { + it("keeps ordinary slug redirects compatible", () => { + expect(resumePasswordSearchSchema.parse({ redirect: "/owner/resume" })).toEqual({ redirect: "/owner/resume" }); + }); + it("accepts root return independently of the verification identity", () => { + expect(resumePasswordSearchSchema.parse({ redirect: "/owner/resume", returnTo: "/" })).toEqual({ + redirect: "/owner/resume", + returnTo: "/", + }); + }); + it.each(["//evil.example", "https://evil.example", "/\\evil.example", "/other/path", "/?next=evil"])( + "rejects return path %s", + (returnTo) => { + expect(resumePasswordSearchSchema.safeParse({ redirect: "/owner/resume", returnTo }).success).toBe(false); + }, + ); + it.each([ + "/", + "//evil.example", + "/owner/slug/extra", + "/owner/slug?next=evil", + "/owner/%2f%2fevil", + "/owner/\\evil", + "/owner/slug#hash", + ])("rejects malformed verification identity %s", (redirect) => { + expect(resumePasswordSearchSchema.safeParse({ redirect, returnTo: "/" }).success).toBe(false); + }); +}); diff --git a/apps/web/src/features/auth/resume-password-search.ts b/apps/web/src/features/auth/resume-password-search.ts new file mode 100644 index 000000000..867e04449 --- /dev/null +++ b/apps/web/src/features/auth/resume-password-search.ts @@ -0,0 +1,8 @@ +import z from "zod"; + +// Keep the existing slug-shaped `redirect` as verification identity. The optional +// continuation is deliberately limited to the configured instance root. +export const resumePasswordSearchSchema = z.object({ + redirect: z.string().regex(/^\/[^/\\?#%\s]+\/[^/\\?#%\s]+$/), + returnTo: z.literal("/").optional(), +}); diff --git a/apps/web/src/features/resume/public/public-resume.test.tsx b/apps/web/src/features/resume/public/public-resume.test.tsx index 19472ca00..b6c48e53b 100644 --- a/apps/web/src/features/resume/public/public-resume.test.tsx +++ b/apps/web/src/features/resume/public/public-resume.test.tsx @@ -130,3 +130,26 @@ describe("PublicResumeRoute", () => { expect(viewerFrame).not.toHaveClass("min-h-0", "flex-1", "overflow-hidden"); }); }); + +describe("PublicResumePage at root", () => { + it("renders supplied identity and links to dashboard without slug route hooks", async () => { + const { PublicResumePage } = await import("./public-resume"); + render( + + + , + ); + expect(screen.getByRole("link", { name: /Build your own resume/ })).toHaveAttribute("href", "/dashboard"); + expect(screen.getByRole("main")).toHaveAttribute("id", "main-content"); + expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent(sampleResumeData.basics.name); + expect(publicResumeMock.useResumeExport).toHaveBeenCalledWith(publicResumeMock.resume, { + publicResumePdf: { publicResume: { username: "root-owner", slug: "renamed" } }, + }); + }); +}); diff --git a/apps/web/src/features/resume/public/public-resume.tsx b/apps/web/src/features/resume/public/public-resume.tsx index 4bf51c502..451c91586 100644 --- a/apps/web/src/features/resume/public/public-resume.tsx +++ b/apps/web/src/features/resume/public/public-resume.tsx @@ -1,3 +1,4 @@ +import type { ResumeData } from "@reactive-resume/schema/resume/data"; import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; import { CircleNotchIcon, DownloadSimpleIcon } from "@phosphor-icons/react"; @@ -18,6 +19,18 @@ export function PublicResumeRoute() { const { flags } = publicResumeRoute.useRouteContext(); const { data: resume } = useQuery(orpc.resume.getBySlug.queryOptions({ input: { username, slug } })); + return ; +} + +type PublicResumePageProps = { + resume: { id?: string; name: string; slug: string; data: ResumeData; showDownloadButtons?: boolean } | undefined; + username: string; + slug: string; + flags: { disableSignups: boolean }; + isRoot?: boolean; +}; + +export function PublicResumePage({ resume, username, slug, flags, isRoot = false }: PublicResumePageProps) { const publicResume = useMemo(() => ({ username, slug }), [slug, username]); const { onDownloadPDF, isExporting } = useResumeExport(resume, { ...(resume ? { publicResumePdf: { publicResume } } : {}), @@ -51,14 +64,14 @@ export function PublicResumeRoute() { )} -
+
{!flags.disableSignups && (