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
25 changes: 16 additions & 9 deletions packages/api/src/routers/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ import {
import { eq, and, count, gte, inArray } from "drizzle-orm";
import { CacheKeys, invalidatePortalContext } from "../middleware/cache";
import { isAdmin, isSuperAdmin } from "../middleware/procedures";
import { currentTerm } from "@query/db/services/membership";
import { isExpiredAdmin } from "../types/portal-context";
import { compareTerms, currentTerm } from "@query/db/services/membership";
import { isExpiredAdmin, isStaffRole } from "../types/portal-context";
import type { DrizzleDB } from "@query/db";

export const adminRouter = createTRPCRouter({
Expand Down Expand Up @@ -49,10 +49,12 @@ export const adminRouter = createTRPCRouter({

const expired = isExpiredAdmin(admin);

const staff = !!admin && !expired && isStaffRole(admin.role);

const result = {
isAdmin: !!admin && !expired,
isAdmin: staff,
role: expired ? null : admin?.role || null,
permissions: expired ? [] : admin?.permissions || [],
permissions: expired || !staff ? [] : admin?.permissions || [],
};

ctx.cache.set(cacheKey, result, 60);
Expand All @@ -61,7 +63,7 @@ export const adminRouter = createTRPCRouter({
}),

analyticsOverview: isAdmin.query(async ({ ctx }) => {
// The analytics page polls this every 5s and stays open all weekend. Five
// The analytics page polls this every 15s and stays open all weekend. Five
// uncached aggregates per poll per tab is a standing load for numbers nobody
// watches change second by second; a 15s entry caps it at one round per 15s.
const cacheKey = "admin:analytics-overview";
Expand Down Expand Up @@ -151,6 +153,7 @@ export const adminRouter = createTRPCRouter({
.select({
createdAt: members.createdAt,
isActive: members.isActive,
membershipEndDate: members.membershipEndDate,
bootcampMember: members.bootcampMember,
bootcampTerm: members.bootcampTerm,
})
Expand Down Expand Up @@ -222,14 +225,18 @@ export const adminRouter = createTRPCRouter({

return {
months,
// Newest term first is how the bootcamp page lists them; the chart
// reverses it so time runs left to right.
// Chronological: localeCompare puts `2026-fall` before `2026-spring`.
terms: [...termCounts.entries()]
.map(([value, enrolled]) => ({ term: value, enrolled }))
.sort((a, b) => a.term.localeCompare(b.term)),
.sort((a, b) => compareTerms(a.term, b.term)),
totals: {
members: rows.length,
activeMembers: rows.filter((row) => row.isActive).length,
activeMembers: rows.filter(
(row) =>
row.isActive &&
row.membershipEndDate &&
row.membershipEndDate > now,
).length,
bootcampAllTime: rows.filter((row) => row.bootcampMember).length,
bootcampThisTerm: termCounts.get(term) ?? 0,
currentTerm: term,
Expand Down
37 changes: 37 additions & 0 deletions packages/api/src/services/resume-list.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { describe, it, expect } from "vitest";
import {
MAX_RESUME_BOOK_IDS,
parseResumeBookIds,
searchNeedle,
} from "./resume-list";

describe("searchNeedle", () => {
it("strips LIKE wildcards so a search cannot match everyone", () => {
expect(searchNeedle("%")).toBeUndefined();
expect(searchNeedle("_")).toBeUndefined();
expect(searchNeedle("100%")).toBe("100");
expect(searchNeedle("C++")).toBe("C++");
});

it("collapses leftover whitespace after stripping", () => {
expect(searchNeedle("Ada % Lovelace")).toBe("Ada Lovelace");
});
});

describe("parseResumeBookIds", () => {
it("dedupes and drops empties", () => {
expect(parseResumeBookIds("a,,a, b")).toEqual(["a", "b"]);
});

it("caps the list so a query string cannot ask for thousands", () => {
const raw = Array.from({ length: MAX_RESUME_BOOK_IDS + 50 }, (_, i) => `u${i}`).join(
",",
);
expect(parseResumeBookIds(raw)).toHaveLength(MAX_RESUME_BOOK_IDS);
});

it("treats a missing param as no filter", () => {
expect(parseResumeBookIds(null)).toBeUndefined();
expect(parseResumeBookIds("")).toBeUndefined();
});
});
26 changes: 25 additions & 1 deletion packages/api/src/services/resume-list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,36 @@ export type ResumeFilters = {
userIds?: string[];
};

/** GET query-string cap; a longer list would blow past URL limits anyway. */
export const MAX_RESUME_BOOK_IDS = 200;

/** `%` and `_` are LIKE wildcards; they are not a search for those characters. */
export function searchNeedle(search: string | undefined) {
const needle = search?.replace(/[%_\\]/g, "").replace(/\s+/g, " ").trim();
return needle || undefined;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Search strips underscore from queries

Medium Severity

searchNeedle deletes _ from the query instead of treating it as a literal. Resume search matches name, email, and major, and emails often contain _. A query like john_doe becomes johndoe and no longer matches the stored address, so staff cannot find those people in the book.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit e3b1640. Configure here.


export function parseResumeBookIds(raw: string | null | undefined) {
if (!raw) return undefined;
const ids = [
...new Set(
raw
.split(",")
.map((id) => id.trim())
.filter(Boolean),
),
];
if (ids.length === 0) return undefined;
return ids.slice(0, MAX_RESUME_BOOK_IDS);
}

/**
* `members` is a paid, unexpired membership — the same rule checkStatus uses.
* `all` is everyone who uploaded.
*/
const whereFor = (filters: ResumeFilters, now: Date) => {
const pattern = filters.search ? `%${filters.search}%` : null;
const needle = searchNeedle(filters.search);
const pattern = needle ? `%${needle}%` : null;

return and(
filters.userIds?.length
Expand Down
9 changes: 9 additions & 0 deletions packages/db/src/services/membership.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, it, expect, vi } from "vitest";
import {
compareTerms,
createOrUpdateMembership,
currentTerm,
isBootcampAddOnOnly,
Expand Down Expand Up @@ -236,6 +237,14 @@ describe("currentTerm", () => {
});
});

describe("compareTerms", () => {
it("orders spring before fall in the same year", () => {
expect(
["2026-fall", "2026-spring", "2025-fall"].sort(compareTerms),
).toEqual(["2025-fall", "2026-spring", "2026-fall"]);
});
});

describe("semesterEndDate", () => {
it("runs spring out at the end of May", () => {
expect(semesterEndDate(new Date("2026-02-10T12:00:00"))).toEqual(
Expand Down
10 changes: 10 additions & 0 deletions packages/db/src/services/membership.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,16 @@ export const currentTerm = (now = new Date()) =>
? `${now.getFullYear()}-spring`
: `${now.getFullYear()}-fall`;

/** Chronological order for `YYYY-spring` / `YYYY-fall` labels. Locale compare puts fall first. */
export const compareTerms = (a: string, b: string) => {
const [ay = "", as = ""] = a.split("-");
const [by = "", bs = ""] = b.split("-");
if (ay !== by) return ay.localeCompare(by);
const rank = (season: string) =>
season === "spring" ? 0 : season === "fall" ? 1 : 2;
return rank(as) - rank(bs);
};

// How long a membership was bought for. A year and a semester are the same
// membership with the same access — only the expiry differs.
export type MembershipPlan = "annual" | "semester";
Expand Down
28 changes: 20 additions & 8 deletions sites/mainweb/app/(portal)/api/resume-book/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,24 @@ const csvCell = (value: unknown) => {
return /[",\r\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
};

/** Resolves only for this entry, so index.csv cannot satisfy a PDF wait. */
function waitForNamedEntry(archive: ZipArchive, name: string) {
return new Promise<void>((resolve, reject) => {
const onEntry = (entry: { name?: string }) => {
if (entry?.name !== name) return;
archive.off("entry", onEntry);
archive.off("error", onError);
resolve();
};
const onError = (error: Error) => {
archive.off("entry", onEntry);
reject(error);
};
archive.on("entry", onEntry);
archive.once("error", onError);
});
}

/**
* GET, not POST: the browser downloads it by navigating, so a 1.5 GB book
* streams to disk. Fetching it would put the whole thing in a Blob in the tab
Expand Down Expand Up @@ -100,11 +118,7 @@ export async function GET(request: NextRequest) {
zipName: uniqueZipName(taken, row.displayName),
}));

// Paired with its own append, so the first file below waits on its own entry
// event rather than on the one this index emits.
const csvWritten = new Promise<void>((resolve) =>
archive.once("entry", () => resolve()),
);
const csvWritten = waitForNamedEntry(archive, "index.csv");

archive.append(
[
Expand Down Expand Up @@ -164,9 +178,7 @@ export async function GET(request: NextRequest) {
continue;
}

const written = new Promise<void>((resolve) =>
archive.once("entry", () => resolve()),
);
const written = waitForNamedEntry(archive, row.zipName);
archive.append(buffer, { name: row.zipName });
await Promise.race([written, failure]);
}
Expand Down
5 changes: 3 additions & 2 deletions sites/mainweb/app/(portal)/api/resume/[userId]/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { NextResponse } from "next/server";
import { db } from "@query/db";
import { loadResume, resumeCaller, resumeFileName } from "@/lib/resume-access";
import { loadResume, resumeCaller } from "@/lib/resume-access";
import { resumeContentDisposition } from "@/lib/resume-file";
import { readResume } from "@/lib/resume-storage";

/** One stored PDF: yours, or anyone's if you are staff. Proxied, not redirected — a signed URL to storage.googleapis.com would leave the origin and CSP frame-src with it. */
Expand Down Expand Up @@ -54,7 +55,7 @@ export async function GET(
headers: {
"content-type": "application/pdf",
"content-length": String(pdf.length),
"content-disposition": `inline; filename="${resumeFileName(resume.displayName)}"`,
"content-disposition": resumeContentDisposition(resume.displayName),
"cache-control": "private, no-store",
"x-content-type-options": "nosniff",
},
Expand Down
16 changes: 11 additions & 5 deletions sites/mainweb/app/(portal)/api/resume/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
looksLikePdf,
resumeCaller,
} from "@/lib/resume-access";
import { uploadedResumeFileName } from "@/lib/resume-file";
import {
deleteResume,
putResume,
Expand Down Expand Up @@ -55,8 +56,13 @@ export async function POST(request: NextRequest) {
);
}

// A claim, so it only saves buffering an oversized body; checked again below.
if (Number(request.headers.get("content-length") ?? 0) > MAX_RESUME_BYTES) {
// A claim, so it only saves buffering an oversized body; required, so a
// missing length cannot turn into an unbounded read.
const declared = Number(request.headers.get("content-length"));
if (!Number.isFinite(declared) || declared < 1) {
return NextResponse.json({ error: "No file received." }, { status: 400 });
}
if (declared > MAX_RESUME_BYTES) {
return NextResponse.json({ error: TOO_LARGE }, { status: 413 });
}

Expand Down Expand Up @@ -92,9 +98,9 @@ export async function POST(request: NextRequest) {
);
}

const fileName = (request.headers.get("x-resume-filename") ?? "resume.pdf")
.replace(/[\r\n]/g, "")
.slice(0, 255);
const fileName = uploadedResumeFileName(
request.headers.get("x-resume-filename"),
);

// Object first. A write that fails leaves the old row pointing at the old
// object, which is a stale resume — a row pointing at nothing is a 404 on a
Expand Down
44 changes: 44 additions & 0 deletions sites/mainweb/lib/resume-file.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ import {
parseResumeIds,
MAX_BOOK_IDS,
MAX_RESUME_BYTES,
uploadedResumeFileName,
displayResumeFileName,
resumeContentDisposition,
} from "./resume-file";

const bytes = (...values: number[]) => new Uint8Array(values);
Expand Down Expand Up @@ -62,6 +65,47 @@ describe("resumeFileName", () => {
});
});

describe("uploadedResumeFileName", () => {
it("decodes a URI-encoded original name", () => {
expect(uploadedResumeFileName(encodeURIComponent("Ada Lovelace.pdf"))).toBe(
"Ada Lovelace.pdf",
);
});

it("does not throw when a 255-char cap splits an escape", () => {
const header = `${"a".repeat(254)}%2F`;
expect(header.length).toBe(257);
const sliced = header.slice(0, 255);
expect(sliced.endsWith("%")).toBe(true);
expect(() => decodeURIComponent(sliced)).toThrow();
expect(uploadedResumeFileName(sliced)).toBe(sliced);
});

it("strips CR/LF from the header", () => {
expect(uploadedResumeFileName("ok.pdf\r\nX-Evil: 1")).toBe("ok.pdf");
});
});

describe("displayResumeFileName", () => {
it("renders a previously stored encoded name", () => {
expect(displayResumeFileName("Wei%20Chen.pdf")).toBe("Wei Chen.pdf");
});

it("leaves a truncated escape in place instead of crashing the page", () => {
expect(displayResumeFileName("file%2")).toBe("file%2");
});
});

describe("resumeContentDisposition", () => {
it("keeps an ASCII fallback and a UTF-8 filename*", () => {
const header = resumeContentDisposition("张伟");
expect(header).toContain('filename="__.pdf"');
expect(header).toContain("filename*=UTF-8''");
expect(header).toContain(encodeURIComponent("张伟.pdf"));
expect(header).not.toMatch(/[\r\n]/);
});
});

describe("uniqueZipName", () => {
it("suffixes duplicates instead of overwriting on extract", () => {
const taken = new Set<string>();
Expand Down
Loading
Loading