From 23ea18241b5e3a9dda88bf244a4c6156a2db4e78 Mon Sep 17 00:00:00 2001 From: Amruth Pillai Date: Tue, 29 Sep 2026 22:05:29 +0200 Subject: [PATCH] fix(api): keep trashed resumes and hidden content out of public sharing Moving a resume to Trash is meant to stop its public link, but the public PDF, the download counter and the social card still looked resumes up by username and slug alone, so a trashed public resume kept serving its PDF, counting downloads and rendering a card. They now skip trashed resumes, like getBySlug and verifyPassword already did. The public getBySlug response also carried everything the author had hidden (sections, entries, the summary and the picture URL), although the builder says hidden sections aren't printed or shared. redactResumeForViewer now strips them for anyone but the owner; the rendered resume is unchanged. The server-rendered social card read the raw row, so it could show the hidden summary and the owner's private dashboard title; it now builds from the same redacted data an anonymous visitor gets. --- .../src/features/resume/access-policy.test.ts | 19 +++++++++++ .../api/src/features/resume/access-policy.ts | 29 +++++++++------- .../src/features/resume/public-pdf.test.ts | 18 ++++++++++ .../api/src/features/resume/public-pdf.ts | 4 +-- .../api/src/features/resume/service.test.ts | 8 +++++ packages/api/src/features/resume/service.ts | 8 ++++- .../src/features/resume/social-meta.test.ts | 34 +++++++++++++++++++ .../api/src/features/resume/social-meta.ts | 12 ++++--- 8 files changed, 113 insertions(+), 19 deletions(-) create mode 100644 packages/api/src/features/resume/social-meta.test.ts diff --git a/packages/api/src/features/resume/access-policy.test.ts b/packages/api/src/features/resume/access-policy.test.ts index 0b8970707..2ab7bd9d0 100644 --- a/packages/api/src/features/resume/access-policy.test.ts +++ b/packages/api/src/features/resume/access-policy.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { defaultResumeData } from "@reactive-resume/schema/resume/default"; +import { sampleResumeData } from "@reactive-resume/schema/resume/sample"; import { assertCanView, isOwner, redactResumeForViewer, shouldCountForStatistics } from "./access-policy"; describe("isOwner", () => { @@ -79,6 +80,24 @@ describe("redactResumeForViewer", () => { expect(result.data.metadata.check).toBeUndefined(); }); + it("strips what the author hid for non-owner and keeps what prints", () => { + const data = structuredClone(sampleResumeData); + data.picture.hidden = true; + data.summary.hidden = true; + data.sections.references.hidden = true; + data.sections.projects.items = data.sections.projects.items.map((item, index) => ({ ...item, hidden: index > 0 })); + data.customSections = data.customSections.map((section) => ({ ...section, hidden: true })); + + const shared = redactResumeForViewer({ name: "Title", data }, false).data; + + expect(shared.picture.url).toBe(""); + expect(shared.summary.content).toBe(""); + expect(shared.sections.references.items).toEqual([]); + expect(shared.sections.projects.items).toEqual(data.sections.projects.items.slice(0, 1)); + expect(shared.customSections).toEqual([]); + expect(shared.sections.education).toEqual(data.sections.education); + }); + it("preserves stylesheet source for an authorized non-owner", () => { const source = { languageVersion: 1, text: "@version 1;\nresume { color: red; }\n" }; const resume = { diff --git a/packages/api/src/features/resume/access-policy.ts b/packages/api/src/features/resume/access-policy.ts index a209e79e6..9a6eb9933 100644 --- a/packages/api/src/features/resume/access-policy.ts +++ b/packages/api/src/features/resume/access-policy.ts @@ -44,6 +44,9 @@ export function assertCanView(resume: Resume, viewer: Viewer): void { * to the author when editing" in the resume schema. * - `resume.data.metadata.check` — the author's Check choices (ignored * issues, job-posting terms hidden as "not true for me"). + * - Everything the author hid: hidden sections, summary, picture and items + * "aren't printed or shared". Hidden custom sections are dropped; built-in + * ones keep their (now empty) slot because the schema requires it. * * Everything else (including `data.basics.name`, the person's name on the * resume itself) is part of the public payload and is returned unchanged. @@ -55,18 +58,20 @@ export function redactResumeForViewer !section.hidden); + const sections: { hidden: boolean; items: { hidden: boolean }[] }[] = [ + ...Object.values(data.sections), + ...data.customSections, + ]; + for (const section of sections) section.items = section.hidden ? [] : section.items.filter((item) => !item.hidden); + + return { ...resume, name: "Resume", data }; } /** diff --git a/packages/api/src/features/resume/public-pdf.test.ts b/packages/api/src/features/resume/public-pdf.test.ts index 4675cf39e..482d83714 100644 --- a/packages/api/src/features/resume/public-pdf.test.ts +++ b/packages/api/src/features/resume/public-pdf.test.ts @@ -2,6 +2,19 @@ import { describe, expect, it, vi } from "vitest"; import { defaultResumeData } from "@reactive-resume/schema/resume/default"; import { createPublicResumePdf } from "./public-pdf"; +// The default lookup runs its real query against a stand-in `pg` client that records the SQL and finds nothing. +const queries = vi.hoisted(() => [] as string[]); +vi.mock("@reactive-resume/db/client", async () => { + const { drizzle } = await import("drizzle-orm/node-postgres"); + const client = { + query: ({ text }: { text: string }) => { + queries.push(text); + return Promise.resolve({ rows: [] }); + }, + }; + return { db: drizzle({ client: client as never }) }; +}); + const requestHeaders = new Headers({ "x-forwarded-for": "203.0.113.7" }); const input = { username: "jane", @@ -40,6 +53,11 @@ describe("createPublicResumePdf", () => { expect(passwordDependencies.renderPdf).not.toHaveBeenCalled(); }); + it("does not look in Trash, so a trashed resume's link stops serving its PDF", async () => { + await expect(createPublicResumePdf(input)).rejects.toMatchObject({ code: "NOT_FOUND" }); + expect(queries.at(-1)).toContain('"resume"."trashed_at" is null'); + }); + it("rejects renderer-unsafe stored data before budget or rendering", async () => { const resume = buildResume(); resume.data.customSections = [ diff --git a/packages/api/src/features/resume/public-pdf.ts b/packages/api/src/features/resume/public-pdf.ts index 9cd9fe5ad..6eb6a5bb9 100644 --- a/packages/api/src/features/resume/public-pdf.ts +++ b/packages/api/src/features/resume/public-pdf.ts @@ -29,7 +29,7 @@ export type PublicResumePdfDependencies = { }; const findResume = async ({ username, slug }: Pick) => { - const [{ db }, schema, { and, eq }] = await Promise.all([ + const [{ db }, schema, { and, eq, isNull }] = await Promise.all([ import("@reactive-resume/db/client"), import("@reactive-resume/db/schema"), import("drizzle-orm"), @@ -44,7 +44,7 @@ const findResume = async ({ username, slug }: Pick ({ showDownloadButtons: "show_download_buttons", isLocked: "is_locked", password: "password", + trashedAt: "trashed_at", updatedAt: "updated_at", createdAt: "created_at", }, @@ -1147,6 +1148,13 @@ describe("statistics.recordDownload", () => { }, ); + it("does not look in Trash for the resume", async () => { + const where = vi.fn((_condition: unknown) => Promise.resolve([])); + dbMock.select.mockReturnValueOnce({ from: () => ({ innerJoin: () => ({ where }) }) }); + await expect(resumeService.statistics.recordDownload(input)).rejects.toMatchObject({ code: "NOT_FOUND" }); + expect(where.mock.calls[0]?.[0]).toContainEqual(["trashed_at"]); + }); + it("requires current password access before recording a download", async () => { selectResume([{ ...publicResume, passwordHash: "hash" }]); hasResumeAccessMock.mockReturnValueOnce(false); diff --git a/packages/api/src/features/resume/service.ts b/packages/api/src/features/resume/service.ts index f5e62af97..3491b174b 100644 --- a/packages/api/src/features/resume/service.ts +++ b/packages/api/src/features/resume/service.ts @@ -191,7 +191,13 @@ const statistics = { }) .from(schema.resume) .innerJoin(schema.user, eq(schema.resume.userId, schema.user.id)) - .where(and(eq(schema.resume.slug, input.slug), eq(schema.user.username, input.username))); + .where( + and( + eq(schema.resume.slug, input.slug), + eq(schema.user.username, input.username), + isNull(schema.resume.trashedAt), + ), + ); if (!resume) throw new ORPCError("NOT_FOUND"); const viewer = input.currentUserId ? { id: input.currentUserId } : null; diff --git a/packages/api/src/features/resume/social-meta.test.ts b/packages/api/src/features/resume/social-meta.test.ts new file mode 100644 index 000000000..eae2370a9 --- /dev/null +++ b/packages/api/src/features/resume/social-meta.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it, vi } from "vitest"; +import { sampleResumeData } from "@reactive-resume/schema/resume/sample"; +import { getPublicResumeSocialMeta } from "./social-meta"; + +// The real lookup query, run against a stand-in `pg` client that records the SQL and returns `rows`. +const pg = vi.hoisted(() => ({ queries: [] as { text: string; params: unknown[] }[], rows: [] as unknown[][] })); +vi.mock("@reactive-resume/db/client", async () => { + const { drizzle } = await import("drizzle-orm/node-postgres"); + const client = { + query: ({ text }: { text: string }, params: unknown[]) => { + pg.queries.push({ text, params }); + return Promise.resolve({ rows: pg.rows }); + }, + }; + return { db: drizzle({ client: client as never }) }; +}); + +describe("getPublicResumeSocialMeta", () => { + it("cards only public, password-free resumes outside Trash, showing what an anonymous visitor sees", async () => { + const data = structuredClone(sampleResumeData); + data.basics.name = ""; + data.summary.hidden = true; + pg.rows = [["Senior Eng @ Foo — final draft", data]]; + + const meta = await getPublicResumeSocialMeta({ username: "jane", slug: "resume" }); + + const [query] = pg.queries; + expect(query?.text).toContain('"resume"."is_public" = $3'); + expect(query?.params[2]).toBe(true); + expect(query?.text).toContain('"resume"."password" is null'); + expect(query?.text).toContain('"resume"."trashed_at" is null'); + expect(meta).toMatchObject({ name: "Resume", description: data.basics.headline }); + }); +}); diff --git a/packages/api/src/features/resume/social-meta.ts b/packages/api/src/features/resume/social-meta.ts index fed24c79e..88af444ae 100644 --- a/packages/api/src/features/resume/social-meta.ts +++ b/packages/api/src/features/resume/social-meta.ts @@ -1,12 +1,13 @@ import type { ResumeSocialMeta } from "@reactive-resume/resume/social-meta"; import { getResumeSocialMeta } from "@reactive-resume/resume/social-meta"; +import { redactResumeForViewer } from "./access-policy"; import { parseStoredResumeData } from "./resume-data-validation"; export type PublicResumeSocialMetaInput = { username: string; slug: string }; -// Only public, password-free resumes are matched. Password-protected resumes must not leak their -// summary to an unauthenticated crawler, and this read deliberately skips the view counting and -// access gating in resumeService.getBySlug — a card render is not a visit. +// Only public, password-free resumes outside Trash are matched. Password-protected resumes must not +// leak their summary to an unauthenticated crawler, and this read deliberately skips the view counting +// and access gating in resumeService.getBySlug — a card render is not a visit. const findResume = async ({ username, slug }: PublicResumeSocialMetaInput) => { const [{ db }, schema, { and, eq, isNull }] = await Promise.all([ import("@reactive-resume/db/client"), @@ -23,6 +24,7 @@ const findResume = async ({ username, slug }: PublicResumeSocialMetaInput) => { eq(schema.user.username, username), eq(schema.resume.isPublic, true), isNull(schema.resume.password), + isNull(schema.resume.trashedAt), ), ); @@ -33,5 +35,7 @@ export async function getPublicResumeSocialMeta(input: PublicResumeSocialMetaInp const resume = await findResume(input); if (!resume) return null; - return getResumeSocialMeta(parseStoredResumeData(resume.data), resume.name); + // The card shows what an anonymous visitor gets: no dashboard title, nothing the author hid. + const visible = redactResumeForViewer({ name: resume.name, data: parseStoredResumeData(resume.data) }, false); + return getResumeSocialMeta(visible.data, visible.name); }