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
4 changes: 2 additions & 2 deletions app/api/explain-upload/route.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { NextResponse } from "next/server";

import { createExplanationStream } from "../../../lib/explanation/openai-response.server";
import { checkRateLimit } from "../../../lib/explanation/rate-limit.server";
import { checkRateLimit, trustedRateLimitIdentity } from "../../../lib/explanation/rate-limit.server";
import { buildUploadedExplanationContext } from "../../../lib/uploads/context.server";
import { uploadedExplainRequestSchema } from "../../../lib/uploads/schema";

Expand All @@ -19,7 +19,7 @@ export async function POST(request: Request) {
if (!parsed.success) return NextResponse.json({ code: "INVALID_REQUEST", message: "The uploaded context is invalid." }, { status: 400 });
const apiKey = process.env.OPENAI_API_KEY;
if (!apiKey) return NextResponse.json({ code: "MODEL_ERROR", message: "AI explanations are not configured on this deployment.", isRetryable: false }, { status: 503 });
const key = `upload:${request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || request.headers.get("x-real-ip") || "local"}`;
const key = `upload:${trustedRateLimitIdentity(request)}`;
const configured = Number(process.env.EXPLAIN_RATE_LIMIT_PER_HOUR ?? 30);
const rateLimit = checkRateLimit(key, { limit: Number.isInteger(configured) && configured > 0 ? configured : 30, windowMs: 60 * 60 * 1_000 });
if (!rateLimit.allowed) return NextResponse.json({ code: "RATE_LIMITED", message: "The demo explanation limit has been reached." }, { status: 429, headers: { "Retry-After": String(rateLimit.retryAfterSeconds) } });
Expand Down
12 changes: 2 additions & 10 deletions app/api/explain/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { NextResponse } from "next/server";

import { buildExplanationContext, ExplanationContextError } from "../../../lib/explanation/build-context.server";
import { createExplanationStream } from "../../../lib/explanation/openai-response.server";
import { checkRateLimit } from "../../../lib/explanation/rate-limit.server";
import { checkRateLimit, trustedRateLimitIdentity } from "../../../lib/explanation/rate-limit.server";
import { explainRequestSchema } from "../../../lib/explanation/request-schema";

export const runtime = "edge";
Expand All @@ -18,14 +18,6 @@ function publicError(
return NextResponse.json({ code, message }, { status, headers });
}

function requestKey(request: Request): string {
return (
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
request.headers.get("x-real-ip") ||
"local-demo"
);
}

export async function POST(request: Request) {
const declaredLength = Number(request.headers.get("content-length") ?? 0);
if (Number.isFinite(declaredLength) && declaredLength > MAX_REQUEST_BYTES) {
Expand Down Expand Up @@ -62,7 +54,7 @@ export async function POST(request: Request) {
}

const configuredLimit = Number(process.env.EXPLAIN_RATE_LIMIT_PER_HOUR ?? 30);
const rateLimit = checkRateLimit(requestKey(request), {
const rateLimit = checkRateLimit(trustedRateLimitIdentity(request), {
limit: Number.isInteger(configuredLimit) && configuredLimit > 0 ? configuredLimit : 30,
windowMs: 60 * 60 * 1_000,
});
Expand Down
8 changes: 8 additions & 0 deletions components/paper-reader/pdf-paper-reader.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,14 @@
text-align: center;
}

.selectionError {
margin: 0;
color: var(--destructive);
font-size: 0.875rem;
font-weight: 650;
text-align: center;
}

.mobileHelp {
display: none;
}
Expand Down
10 changes: 9 additions & 1 deletion components/paper-reader/pdf-paper-reader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ export function PdfPaperReader({
const [renderScale, setRenderScale] = useState<number>()
const [status, setStatus] = useState<ReaderStatus>("loading-document")
const [errorMessage, setErrorMessage] = useState("")
const [selectionError, setSelectionError] = useState("")
const [retryKey, setRetryKey] = useState(0)

useEffect(() => {
Expand Down Expand Up @@ -215,9 +216,15 @@ export function PdfPaperReader({
const rect = browserSelection.getRangeAt(0).getBoundingClientRect()
if (!rect.width && !rect.height) return

const blockIds = resolvePageBlockIds(validated.selectedText, pageBlocks)
if (blockIds.length === 0) {
setSelectionError("That passage could not be matched to the indexed paper text. Try a smaller selection.")
return
Comment on lines +221 to +222

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Dismiss the stale selection on unmatched text

When a user already has a selection menu open and then highlights a passage that resolvePageBlockIds cannot match, this early return only sets the status text; the parent selection is never cleared because onSelection is not called. The old menu stays active and its buttons submit the previous passage, so the explanation can be for text the user no longer selected; notify the parent to dismiss/clear the selection before returning.

Useful? React with 👍 / 👎.

}
setSelectionError("")
onSelection({
selectedText: validated.selectedText,
blockIds: resolvePageBlockIds(validated.selectedText, pageBlocks),
blockIds,
clientRect: {
top: rect.top,
left: rect.left,
Expand Down Expand Up @@ -301,6 +308,7 @@ export function PdfPaperReader({
) : null}
</div>
</div>
{selectionError ? <p className={styles.selectionError} role="status">{selectionError}</p> : null}
<p className={styles.help} id="paper-scroll-help">
<span className={styles.desktopHelp}>
Select text directly on the page for a contextual explanation. Use the page buttons to navigate.
Expand Down
9 changes: 9 additions & 0 deletions lib/explanation/rate-limit.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,15 @@ export type RateLimitResult = {
retryAfterSeconds: number;
};

/**
* Cloudflare overwrites this header on direct visitor requests. Do not fall
* back to client-controlled forwarding headers; deployments without the
* trusted edge header share one conservative anonymous bucket.
*/
export function trustedRateLimitIdentity(request: Request): string {
return request.headers.get("cf-connecting-ip")?.trim() || "shared-anonymous";
}

// This store is intentionally process-local. It protects a single server instance
// from bursts, but it is not a substitute for a shared limiter in a multi-instance
// deployment.
Expand Down
4 changes: 1 addition & 3 deletions lib/explanation/serialize-context.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,9 +154,7 @@ async function resolvePaperSelection(
const canonicalText = blocks.map(({ text }) => text).join("\n\n");
assertSelectionMatches(request.selectedText, canonicalText);
const sourceId = blocks[0].id;
const mapping =
loaded.manifest.mappings.find((item) => item.paper.sourceId === sourceId) ??
loaded.manifest.mappings.find((item) => item.paper.pages.includes(page.number));
const mapping = loaded.manifest.mappings.find((item) => item.paper.sourceId === sourceId);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Search all selected blocks for a mapping

For paper selections that start in an unmapped block but include a mapped block—e.g. selecting a heading together with the theorem paragraph—blocks[0] becomes the heading, so this lookup drops the mapping and sends no Lean counterpart even though one of the selected block IDs is mapped. Resolve the mapping from any selected block, or from the block containing the matched text, rather than only the first block.

Useful? React with 👍 / 👎.

const label = `Paper page ${page.number}${mapping?.paper.heading ? ` · ${mapping.paper.heading}` : ""}`;
return {
kind: "paper",
Expand Down
202 changes: 171 additions & 31 deletions lib/uploads/lean-files.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { unzipSync } from "fflate";
import { strFromU8, Unzip, UnzipInflate } from "fflate";

import { UPLOAD_LIMITS } from "./schema";

Expand All @@ -8,6 +8,92 @@ export type UploadedLeanFile = {
bytes: number;
};

const ARCHIVE_INPUT_CHUNK_BYTES = 4 * 1024;
const END_OF_CENTRAL_DIRECTORY = 0x06054b50;
const CENTRAL_DIRECTORY_FILE_HEADER = 0x02014b50;
const LOCAL_FILE_HEADER = 0x04034b50;
const MAX_ZIP_COMMENT_BYTES = 65_535;

function invalidZip(): never {
throw new Error("The ZIP archive could not be safely extracted.");
}

function equalBytes(left: Uint8Array, right: Uint8Array): boolean {
return left.byteLength === right.byteLength && left.every((byte, index) => byte === right[index]);
}

function validateZipContainer(data: Uint8Array): string[] {
if (data.byteLength < 22) invalidZip();
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
const searchStart = Math.max(0, data.byteLength - 22 - MAX_ZIP_COMMENT_BYTES);
let endOffset = -1;
for (let offset = data.byteLength - 22; offset >= searchStart; offset -= 1) {
if (view.getUint32(offset, true) === END_OF_CENTRAL_DIRECTORY) {
const commentLength = view.getUint16(offset + 20, true);
if (offset + 22 + commentLength === data.byteLength) {
endOffset = offset;
break;
}
}
}
if (endOffset < 0) invalidZip();

const disk = view.getUint16(endOffset + 4, true);
const centralDisk = view.getUint16(endOffset + 6, true);
const diskEntries = view.getUint16(endOffset + 8, true);
const totalEntries = view.getUint16(endOffset + 10, true);
const centralSize = view.getUint32(endOffset + 12, true);
const centralOffset = view.getUint32(endOffset + 16, true);
if (
disk !== 0 ||
centralDisk !== 0 ||
diskEntries !== totalEntries ||
totalEntries === 0xffff ||
centralSize === 0xffffffff ||
centralOffset === 0xffffffff ||
centralOffset + centralSize !== endOffset
) invalidZip();

let cursor = centralOffset;
const entryNames: string[] = [];
for (let entry = 0; entry < totalEntries; entry += 1) {
if (cursor + 46 > endOffset || view.getUint32(cursor, true) !== CENTRAL_DIRECTORY_FILE_HEADER) {
invalidZip();
}
const flags = view.getUint16(cursor + 8, true);
const compressedSize = view.getUint32(cursor + 20, true);
const originalSize = view.getUint32(cursor + 24, true);
const nameLength = view.getUint16(cursor + 28, true);
const extraLength = view.getUint16(cursor + 30, true);
const commentLength = view.getUint16(cursor + 32, true);
const startDisk = view.getUint16(cursor + 34, true);
const localOffset = view.getUint32(cursor + 42, true);
if (
(flags & 1) !== 0 ||
startDisk !== 0 ||
compressedSize === 0xffffffff ||
originalSize === 0xffffffff ||
localOffset === 0xffffffff ||
localOffset + 30 > centralOffset ||
view.getUint32(localOffset, true) !== LOCAL_FILE_HEADER
) invalidZip();
const localNameLength = view.getUint16(localOffset + 26, true);
const localExtraLength = view.getUint16(localOffset + 28, true);
const dataOffset = localOffset + 30 + localNameLength + localExtraLength;
const centralName = data.subarray(cursor + 46, cursor + 46 + nameLength);
const localName = data.subarray(localOffset + 30, localOffset + 30 + localNameLength);
if (
dataOffset + compressedSize > centralOffset ||
localNameLength !== nameLength ||
!equalBytes(localName, centralName)
) invalidZip();
entryNames.push(strFromU8(centralName, (flags & 0x0800) === 0));
cursor += 46 + nameLength + extraLength + commentLength;
}
if (cursor !== endOffset) invalidZip();
return entryNames;
}

function safeLeanPath(path: string): boolean {
return (
path.endsWith(".lean") &&
Expand Down Expand Up @@ -50,6 +136,89 @@ function validateCollection(files: UploadedLeanFile[]): UploadedLeanFile[] {
return files.sort((left, right) => left.path.localeCompare(right.path));
}

function joinChunks(chunks: Uint8Array[], byteLength: number): Uint8Array {
const joined = new Uint8Array(byteLength);
let offset = 0;
for (const chunk of chunks) {
joined.set(chunk, offset);
offset += chunk.byteLength;
}
return joined;
}

function readLeanArchive(data: Uint8Array, existing: UploadedLeanFile[]): UploadedLeanFile[] {
const validatedEntryNames = validateZipContainer(data);
const extracted: UploadedLeanFile[] = [];
const existingBytes = existing.reduce((sum, source) => sum + source.bytes, 0);
let archiveBytes = 0;
let archiveFiles = 0;
let streamedEntries = 0;
let failure: Error | null = null;

const unzip = new Unzip((file) => {
if (file.name !== validatedEntryNames[streamedEntries]) {
failure = new Error("The ZIP archive could not be safely extracted.");
Comment on lines +159 to +160

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Compare ZIP entries by local offset

When a valid ZIP's central directory is not in the same order as the local file headers, validatedEntryNames is in central-directory order while Unzip emits entries as it streams the local headers. This check then rejects the upload as unsafe even though each central record points at a valid local entry; reconcile the validated entries by localOffset (or by offset) instead of assuming both orders are identical.

Useful? React with 👍 / 👎.

return;
}
streamedEntries += 1;
if (failure || !file.name.toLowerCase().endsWith(".lean")) return;
if (!safeLeanPath(file.name)) {
failure = new Error(`Unsafe Lean file path: ${file.name}`);
return;
}
archiveFiles += 1;
if (existing.length + archiveFiles > UPLOAD_LIMITS.leanFiles) {
failure = new Error(`Choose at most ${UPLOAD_LIMITS.leanFiles} Lean files.`);
return;
}

const chunks: Uint8Array[] = [];
let fileBytes = 0;
file.ondata = (error, chunk, final) => {
if (failure) return;
if (error) {
failure = error;
return;
}
fileBytes += chunk.byteLength;
archiveBytes += chunk.byteLength;
if (fileBytes > UPLOAD_LIMITS.leanFileBytes) {
failure = new Error(`${file.name} is larger than 256 KiB.`);
file.terminate();
return;
}
if (existingBytes + archiveBytes > UPLOAD_LIMITS.leanTotalBytes) {
failure = new Error("The extracted Lean sources are larger than 1 MiB in total.");
file.terminate();
return;
}
chunks.push(chunk);
if (final) extracted.push(decodeLean(file.name, joinChunks(chunks, fileBytes)));
};
try {
file.start();
} catch (error) {
failure = error instanceof Error ? error : new Error(`Could not extract ${file.name}.`);
}
});
unzip.register(UnzipInflate);

try {
if (data.byteLength === 0) unzip.push(data, true);
for (let offset = 0; offset < data.byteLength; offset += ARCHIVE_INPUT_CHUNK_BYTES) {
if (failure) throw failure;
const end = Math.min(data.byteLength, offset + ARCHIVE_INPUT_CHUNK_BYTES);
unzip.push(data.subarray(offset, end), end === data.byteLength);
}
if (failure) throw failure;
if (streamedEntries !== validatedEntryNames.length) invalidZip();
} catch (error) {
if (error instanceof Error && /Unsafe Lean file path|larger than|Choose at most|extracted Lean sources/.test(error.message)) throw error;
throw new Error("The ZIP archive could not be safely extracted.");
}
return extracted;
}

export async function readLeanUploads(input: FileList | File[]): Promise<UploadedLeanFile[]> {
const sourceFiles = Array.from(input);
const result: UploadedLeanFile[] = [];
Expand All @@ -66,36 +235,7 @@ export async function readLeanUploads(input: FileList | File[]): Promise<Uploade
if (file.size > UPLOAD_LIMITS.leanArchiveBytes) {
throw new Error(`${file.name} is larger than 5 MiB.`);
}
let archive: ReturnType<typeof unzipSync>;
try {
let archiveLeanFiles = 0;
let archiveLeanBytes = 0;
archive = unzipSync(new Uint8Array(await file.arrayBuffer()), {
filter: (entry) => {
if (!entry.name.toLowerCase().endsWith(".lean")) return false;
if (!safeLeanPath(entry.name)) throw new Error(`Unsafe Lean file path: ${entry.name}`);
if (entry.originalSize > UPLOAD_LIMITS.leanFileBytes) throw new Error(`${entry.name} is larger than 256 KiB.`);
archiveLeanFiles += 1;
archiveLeanBytes += entry.originalSize;
if (result.length + archiveLeanFiles > UPLOAD_LIMITS.leanFiles) throw new Error(`Choose at most ${UPLOAD_LIMITS.leanFiles} Lean files.`);
if (result.reduce((sum, source) => sum + source.bytes, 0) + archiveLeanBytes > UPLOAD_LIMITS.leanTotalBytes) {
throw new Error("The extracted Lean sources are larger than 1 MiB in total.");
}
return true;
},
});
} catch (error) {
if (error instanceof Error && /Unsafe Lean file path|larger than|Choose at most|extracted Lean sources/.test(error.message)) throw error;
throw new Error(`${file.name} is not a readable ZIP archive.`);
}
for (const [path, data] of Object.entries(archive)) {
if (path.endsWith("/")) continue;
if (!safeLeanPath(path)) {
if (path.toLowerCase().endsWith(".lean")) throw new Error(`Unsafe Lean file path: ${path}`);
continue;
}
result.push(decodeLean(path, data));
}
result.push(...readLeanArchive(new Uint8Array(await file.arrayBuffer()), result));
}

return validateCollection(result);
Expand Down
6 changes: 3 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,8 @@
"wrangler": "4.92.0"
},
"overrides": {
"postcss": "8.5.19"
"postcss": "8.5.19",
"ws": "8.21.1"
},
"type": "module"
}
Loading
Loading