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.
This commit is contained in:
Amruth Pillai
2026-09-29 22:09:50 +02:00
parent c6c51e5b23
commit 23ea18241b
8 changed files with 113 additions and 19 deletions
@@ -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 = {
@@ -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<T extends { name: string; data: ResumeData
viewerIsOwner: boolean,
): T {
if (viewerIsOwner) return resume;
return {
...resume,
name: "Resume",
data: {
...resume.data,
metadata: {
...resume.data.metadata,
notes: "",
check: undefined,
},
},
};
const data = structuredClone(resume.data);
data.metadata.notes = "";
data.metadata.check = undefined;
if (data.picture.hidden) data.picture.url = "";
if (data.summary.hidden) data.summary.content = "";
data.customSections = data.customSections.filter((section) => !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 };
}
/**
@@ -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 = [
@@ -29,7 +29,7 @@ export type PublicResumePdfDependencies = {
};
const findResume = async ({ username, slug }: Pick<CreatePublicResumePdfInput, "username" | "slug">) => {
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<CreatePublicResumePdfInput, "
})
.from(schema.resume)
.innerJoin(schema.user, eq(schema.resume.userId, schema.user.id))
.where(and(eq(schema.resume.slug, slug), eq(schema.user.username, username)));
.where(and(eq(schema.resume.slug, slug), eq(schema.user.username, username), isNull(schema.resume.trashedAt)));
return resume ?? null;
};
@@ -34,6 +34,7 @@ vi.mock("@reactive-resume/db/schema", () => ({
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);
+7 -1
View File
@@ -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;
@@ -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 });
});
});
@@ -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);
}