Skip to content
Open
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
28 changes: 19 additions & 9 deletions sites/mainweb/app/(portal)/admin/resumes/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { useEffect, useMemo, useState } from "react";
import { trpc } from "@/lib/trpc";
import { LiquidGlass } from "@/components/portal/LiquidGlass";
import { ResumePreview } from "@/components/portal/ResumePreview";
import {
BookOpen,
ChevronLeft,
Expand Down Expand Up @@ -276,19 +277,28 @@ export default function AdminResumesPage() {
<p className="text-xs font-bold uppercase tracking-widest text-[var(--text-muted)]">
{preview.name}
</p>
<button
onClick={() => setPreview(null)}
aria-label="Close preview"
className="p-1.5 rounded-sm text-[var(--text-subtle)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-secondary)] transition-ui"
>
<X className="w-4 h-4" />
</button>
<div className="flex items-center gap-1">
<a
href={`/api/resume/${preview.userId}`}
target="_blank"
rel="noopener noreferrer"
className="px-2 py-1.5 rounded-sm text-xs font-bold uppercase tracking-widest text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-secondary)] transition-ui"
>
Open
</a>
<button
onClick={() => setPreview(null)}
aria-label="Close preview"
className="p-1.5 rounded-sm text-[var(--text-subtle)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-secondary)] transition-ui"
>
<X className="w-4 h-4" />
</button>
</div>
</div>
<iframe
<ResumePreview
key={preview.userId}
src={`/api/resume/${preview.userId}`}
title={`${preview.name} resume`}
className="w-full h-[70vh] min-h-[420px] rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-secondary)]"
/>
</div>
)}
Expand Down
11 changes: 5 additions & 6 deletions sites/mainweb/app/(portal)/api/resume/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,13 +81,11 @@ export async function POST(request: NextRequest) {
);
}

// Lossless re-save: takes 5-15% off a text resume, and refuses a PDF that
// will not parse here rather than handing a broken file to a sponsor later.
let stored: Uint8Array;
// Parse to refuse garbage; store the original bytes. pdf-lib's re-save is
// smaller on text resumes and silently broken on a lot of real ones (forms,
// certain fonts), which then fail to open in the browser viewer.
try {
const parsed = await PDFDocument.load(bytes, { ignoreEncryption: true });
const compact = await parsed.save({ useObjectStreams: true });
stored = compact.length < bytes.length ? compact : bytes;
await PDFDocument.load(bytes, { ignoreEncryption: true });
} catch {
return NextResponse.json(
{
Expand All @@ -97,6 +95,7 @@ export async function POST(request: NextRequest) {
{ status: 400 },
);
}
const stored = bytes;

const fileName = uploadedResumeFileName(
request.headers.get("x-resume-filename"),
Expand Down
89 changes: 89 additions & 0 deletions sites/mainweb/components/portal/ResumePreview.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
"use client";

import { useEffect, useState } from "react";
import { looksLikePdf } from "@/lib/resume-file";

/**
* Fetch the PDF as a blob and frame that, rather than pointing the iframe at
* `/api/resume/...` itself. That URL is behind `X-Frame-Options: DENY` from
* the edge proxy, so Chrome's viewer reports "Failed to load PDF document"
* even when the bytes are fine.
*/
export function ResumePreview({ src, title }: { src: string; title: string }) {
const [blobUrl, setBlobUrl] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);

useEffect(() => {
let objectUrl: string | undefined;
let cancelled = false;

setBlobUrl(null);
setError(null);

fetch(src, { credentials: "same-origin" })
.then(async (res) => {
if (!res.ok) {
const body = (await res.json().catch(() => null)) as {
error?: string;
} | null;
throw new Error(body?.error ?? "Could not load that resume.");
}
const bytes = new Uint8Array(await res.arrayBuffer());
if (!looksLikePdf(bytes)) {
throw new Error("Could not load that resume.");
}
// Force the PDF MIME. `res.blob()` keeps whatever Content-Type the
// proxy sent, and Chrome's viewer refuses anything else.
return new Blob([bytes], { type: "application/pdf" });
})
.then((blob) => {
if (cancelled) return;
objectUrl = URL.createObjectURL(blob);
setBlobUrl(objectUrl);
})
.catch((err) => {
if (cancelled) return;
setError(
err instanceof Error ? err.message : "Could not load that resume.",
);
});

return () => {
cancelled = true;
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
}, [src]);

if (error) {
return (
<p className="px-4 py-8 rounded-sm border border-red-500/20 bg-red-500/10 text-red-400 text-sm text-center">
{error}{" "}
<a
href={src}
target="_blank"
rel="noopener noreferrer"
className="underline underline-offset-2 hover:text-red-300"
>
Open in a new tab
</a>
</p>
);
}

if (!blobUrl) {
return (
<div
className="w-full h-[70vh] min-h-[420px] rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-secondary)] animate-pulse"
aria-hidden
/>
);
}

return (
<iframe
src={blobUrl}
title={title}
className="w-full h-[70vh] min-h-[420px] rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-secondary)]"
/>
);
}
19 changes: 11 additions & 8 deletions sites/mainweb/components/portal/ResumeSection.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
"use client";

import { useRef, useState } from "react";
import { FileText, Upload, Trash2, Eye, EyeOff } from "lucide-react";
import { FileText, Upload, Trash2, Eye, EyeOff, ExternalLink } from "lucide-react";
import { trpc } from "@/lib/trpc";
import { MAX_RESUME_BYTES, decodeStoredFileName } from "@/lib/resume-file";
import { ResumePreview } from "@/components/portal/ResumePreview";

function formatSize(bytes: number) {
if (bytes < 1024) return `${bytes} B`;
Expand Down Expand Up @@ -135,6 +136,14 @@ export function ResumeSection() {
</div>

<div className="flex items-center gap-2">
<a
href="/api/resume/me"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 px-4 py-2.5 rounded-sm border border-[var(--border-medium)] bg-[var(--bg-card)] text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:border-[var(--border-hover)] transition-ui text-xs font-bold uppercase tracking-widest"
>
<ExternalLink className="w-3.5 h-3.5" /> Open
</a>
<button
onClick={() => setPreview((open) => !open)}
aria-expanded={preview}
Expand Down Expand Up @@ -168,13 +177,7 @@ export function ResumeSection() {
</div>
</div>

{preview && (
<iframe
src="/api/resume/me"
title="Your resume"
className="w-full h-[70vh] min-h-[420px] rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-secondary)]"
/>
)}
{preview && <ResumePreview src="/api/resume/me" title="Your resume" />}
</>
) : (
<button
Expand Down
9 changes: 7 additions & 2 deletions sites/mainweb/next.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,16 @@ const CSP_DIRECTIVES = [
// 'self' for the resume previews on /settings and /admin/resumes. Without
// it every same-origin frame goes blank the day CSP_ENFORCE flips, and
// report-only means nothing would say so until then.
"frame-src 'self' https://js.stripe.com https://hooks.stripe.com",
// blob: is the resume preview: the PDF is fetched, then framed as an object
// URL so X-Frame-Options on /api/resume cannot blank the viewer.
"frame-src 'self' blob: https://js.stripe.com https://hooks.stripe.com",
"frame-ancestors 'self'",
"base-uri 'self'",
"form-action 'self'",
"object-src 'none'",
// 'self' rather than 'none': Chrome's PDF viewer is an <object>/<embed>
// inside the frame. 'none' is what turns a valid resume into "Failed to
// load PDF document" the day CSP_ENFORCE flips.
"object-src 'self' blob:",
"report-uri /api/csp-report",
].join("; ");

Expand Down
5 changes: 4 additions & 1 deletion sites/mainweb/proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,10 @@ function getCacheControl(pathname: string): string {

const securityHeaders: string[] = [
"X-Content-Type-Options: nosniff",
"X-Frame-Options: DENY",
// SAMEORIGIN, not DENY: /settings and /admin/resumes frame the member's
// resume PDF from /api/resume. DENY makes Chrome report "Failed to load PDF
// document" on a file that downloaded fine.
"X-Frame-Options: SAMEORIGIN",
"X-XSS-Protection: 1; mode=block",
];

Expand Down
Loading