diff --git a/packages/api/src/routers/admin.ts b/packages/api/src/routers/admin.ts index fc200cb7..0bee00d2 100644 --- a/packages/api/src/routers/admin.ts +++ b/packages/api/src/routers/admin.ts @@ -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({ @@ -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); @@ -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"; @@ -151,6 +153,7 @@ export const adminRouter = createTRPCRouter({ .select({ createdAt: members.createdAt, isActive: members.isActive, + membershipEndDate: members.membershipEndDate, bootcampMember: members.bootcampMember, bootcampTerm: members.bootcampTerm, }) @@ -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, diff --git a/packages/api/src/services/resume-list.test.ts b/packages/api/src/services/resume-list.test.ts new file mode 100644 index 00000000..0a4da7e0 --- /dev/null +++ b/packages/api/src/services/resume-list.test.ts @@ -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(); + }); +}); diff --git a/packages/api/src/services/resume-list.ts b/packages/api/src/services/resume-list.ts index ae0747f5..049465b7 100644 --- a/packages/api/src/services/resume-list.ts +++ b/packages/api/src/services/resume-list.ts @@ -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; +} + +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 diff --git a/packages/db/src/services/membership.test.ts b/packages/db/src/services/membership.test.ts index 460b3f9d..3e0bef9f 100644 --- a/packages/db/src/services/membership.test.ts +++ b/packages/db/src/services/membership.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, vi } from "vitest"; import { + compareTerms, createOrUpdateMembership, currentTerm, isBootcampAddOnOnly, @@ -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( diff --git a/packages/db/src/services/membership.ts b/packages/db/src/services/membership.ts index 3ea0da4e..341c2600 100644 --- a/packages/db/src/services/membership.ts +++ b/packages/db/src/services/membership.ts @@ -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"; diff --git a/sites/mainweb/app/(portal)/api/resume-book/route.ts b/sites/mainweb/app/(portal)/api/resume-book/route.ts index ee8a046b..2a76e9af 100644 --- a/sites/mainweb/app/(portal)/api/resume-book/route.ts +++ b/sites/mainweb/app/(portal)/api/resume-book/route.ts @@ -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((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 @@ -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((resolve) => - archive.once("entry", () => resolve()), - ); + const csvWritten = waitForNamedEntry(archive, "index.csv"); archive.append( [ @@ -164,9 +178,7 @@ export async function GET(request: NextRequest) { continue; } - const written = new Promise((resolve) => - archive.once("entry", () => resolve()), - ); + const written = waitForNamedEntry(archive, row.zipName); archive.append(buffer, { name: row.zipName }); await Promise.race([written, failure]); } diff --git a/sites/mainweb/app/(portal)/api/resume/[userId]/route.ts b/sites/mainweb/app/(portal)/api/resume/[userId]/route.ts index 7a2fa88d..c2174f3f 100644 --- a/sites/mainweb/app/(portal)/api/resume/[userId]/route.ts +++ b/sites/mainweb/app/(portal)/api/resume/[userId]/route.ts @@ -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. */ @@ -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", }, diff --git a/sites/mainweb/app/(portal)/api/resume/route.ts b/sites/mainweb/app/(portal)/api/resume/route.ts index 9c947f89..20c139cf 100644 --- a/sites/mainweb/app/(portal)/api/resume/route.ts +++ b/sites/mainweb/app/(portal)/api/resume/route.ts @@ -10,6 +10,7 @@ import { looksLikePdf, resumeCaller, } from "@/lib/resume-access"; +import { uploadedResumeFileName } from "@/lib/resume-file"; import { deleteResume, putResume, @@ -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 }); } @@ -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 diff --git a/sites/mainweb/lib/resume-file.test.ts b/sites/mainweb/lib/resume-file.test.ts index d159229a..e7d63f91 100644 --- a/sites/mainweb/lib/resume-file.test.ts +++ b/sites/mainweb/lib/resume-file.test.ts @@ -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); @@ -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(); diff --git a/sites/mainweb/lib/resume-file.ts b/sites/mainweb/lib/resume-file.ts index 6a0845dc..9ca49a1a 100644 --- a/sites/mainweb/lib/resume-file.ts +++ b/sites/mainweb/lib/resume-file.ts @@ -30,6 +30,42 @@ export function resumeFileName(name: string | null | undefined) { return `${safe || "resume"}.pdf`; } +/** Decode a URI-encoded header without throwing on a truncated `%xx`. */ +function decodeHeader(value: string) { + try { + return decodeURIComponent(value); + } catch { + return value; + } +} + +/** + * The original filename from `x-resume-filename`. Stored decoded so the + * settings page can render it; a sliced `%xx` must not crash `decodeURIComponent`. + */ +export function uploadedResumeFileName(header: string | null | undefined) { + const raw = (header ?? "resume.pdf").split(/[\r\n]/)[0] ?? "resume.pdf"; + const cleaned = decodeHeader(raw).trim().slice(0, 255); + return cleaned || "resume.pdf"; +} + +/** Labels already on file, including ones stored URI-encoded before this fix. */ +export function decodeStoredFileName(name: string) { + return decodeHeader(name); +} + +export const displayResumeFileName = decodeStoredFileName; + +/** RFC 5987 so a Unicode display name survives Content-Disposition. */ +export function resumeContentDisposition( + displayName: string, + kind: "inline" | "attachment" = "inline", +) { + const fileName = resumeFileName(displayName); + const ascii = fileName.replace(/[^\x20-\x7E]/g, "_"); + return `${kind}; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(fileName)}`; +} + /** * Names inside the ZIP. Two people called Chen get `Chen.pdf` and * `Chen (2).pdf` rather than one silently overwriting the other on extract. @@ -44,28 +80,19 @@ export function uniqueZipName(taken: Set, displayName: string) { return candidate; } -/** - * A stored file name is whatever the uploader's browser encoded. A hand-rolled - * POST can leave a malformed escape in there, and `decodeURIComponent` throws - * on one — inside a render that takes the whole settings page down. - */ -export function decodeStoredFileName(name: string) { - try { - return decodeURIComponent(name); - } catch { - return name; - } -} - /** Enough for every hand-picked selection the table can build, and a bound on the IN list a crafted URL can ask for. */ export const MAX_BOOK_IDS = 1000; /** The `ids` query parameter: deduplicated, capped, or undefined for "no explicit set". */ export function parseResumeIds(raw: string | null | undefined) { if (!raw) return undefined; - const ids = [...new Set(raw.split(",").filter(Boolean))].slice( - 0, - MAX_BOOK_IDS, - ); + const ids = [ + ...new Set( + raw + .split(",") + .map((id) => id.trim()) + .filter(Boolean), + ), + ].slice(0, MAX_BOOK_IDS); return ids.length > 0 ? ids : undefined; }