|
| 1 | +import { NextResponse } from "next/server" |
| 2 | +import type { NextRequest } from "next/server" |
| 3 | +import * as fs from "node:fs" |
| 4 | +import * as path from "node:path" |
| 5 | +import archiver from "archiver" |
| 6 | + |
| 7 | +import { findRun, getTasks } from "@roo-code/evals" |
| 8 | + |
| 9 | +export const dynamic = "force-dynamic" |
| 10 | + |
| 11 | +const LOG_BASE_PATH = "/tmp/evals/runs" |
| 12 | + |
| 13 | +// Sanitize path components to prevent path traversal attacks |
| 14 | +function sanitizePathComponent(component: string): string { |
| 15 | + // Remove any path separators, null bytes, and other dangerous characters |
| 16 | + return component.replace(/[/\\:\0*?"<>|]/g, "_") |
| 17 | +} |
| 18 | + |
| 19 | +export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { |
| 20 | + const { id } = await params |
| 21 | + |
| 22 | + try { |
| 23 | + const runId = Number(id) |
| 24 | + |
| 25 | + if (isNaN(runId)) { |
| 26 | + return NextResponse.json({ error: "Invalid run ID" }, { status: 400 }) |
| 27 | + } |
| 28 | + |
| 29 | + // Verify the run exists |
| 30 | + await findRun(runId) |
| 31 | + |
| 32 | + // Get all tasks for this run |
| 33 | + const tasks = await getTasks(runId) |
| 34 | + |
| 35 | + // Filter for failed tasks only |
| 36 | + const failedTasks = tasks.filter((task) => task.passed === false) |
| 37 | + |
| 38 | + if (failedTasks.length === 0) { |
| 39 | + return NextResponse.json({ error: "No failed tasks to export" }, { status: 400 }) |
| 40 | + } |
| 41 | + |
| 42 | + // Create a zip archive |
| 43 | + const archive = archiver("zip", { zlib: { level: 9 } }) |
| 44 | + |
| 45 | + // Collect chunks to build the response |
| 46 | + const chunks: Buffer[] = [] |
| 47 | + |
| 48 | + archive.on("data", (chunk: Buffer) => { |
| 49 | + chunks.push(chunk) |
| 50 | + }) |
| 51 | + |
| 52 | + // Track archive errors |
| 53 | + let archiveError: Error | null = null |
| 54 | + archive.on("error", (err: Error) => { |
| 55 | + archiveError = err |
| 56 | + }) |
| 57 | + |
| 58 | + // Set up the end promise before finalizing (proper event listener ordering) |
| 59 | + const archiveEndPromise = new Promise<void>((resolve, reject) => { |
| 60 | + archive.on("end", resolve) |
| 61 | + archive.on("error", reject) |
| 62 | + }) |
| 63 | + |
| 64 | + // Add each failed task's log file to the archive |
| 65 | + const logDir = path.join(LOG_BASE_PATH, String(runId)) |
| 66 | + let filesAdded = 0 |
| 67 | + |
| 68 | + for (const task of failedTasks) { |
| 69 | + // Sanitize language and exercise to prevent path traversal |
| 70 | + const safeLanguage = sanitizePathComponent(task.language) |
| 71 | + const safeExercise = sanitizePathComponent(task.exercise) |
| 72 | + const logFileName = `${safeLanguage}-${safeExercise}.log` |
| 73 | + const logFilePath = path.join(logDir, logFileName) |
| 74 | + |
| 75 | + // Verify the resolved path is within the expected directory (defense in depth) |
| 76 | + const resolvedPath = path.resolve(logFilePath) |
| 77 | + const expectedBase = path.resolve(LOG_BASE_PATH) |
| 78 | + if (!resolvedPath.startsWith(expectedBase)) { |
| 79 | + continue // Skip files with suspicious paths |
| 80 | + } |
| 81 | + |
| 82 | + if (fs.existsSync(logFilePath)) { |
| 83 | + archive.file(logFilePath, { name: logFileName }) |
| 84 | + filesAdded++ |
| 85 | + } |
| 86 | + } |
| 87 | + |
| 88 | + // Check if any files were actually added |
| 89 | + if (filesAdded === 0) { |
| 90 | + archive.abort() |
| 91 | + return NextResponse.json( |
| 92 | + { error: "No log files found - they may have been cleared from disk" }, |
| 93 | + { status: 404 }, |
| 94 | + ) |
| 95 | + } |
| 96 | + |
| 97 | + // Finalize the archive |
| 98 | + await archive.finalize() |
| 99 | + |
| 100 | + // Wait for all data to be collected |
| 101 | + await archiveEndPromise |
| 102 | + |
| 103 | + // Check for archive errors |
| 104 | + if (archiveError) { |
| 105 | + throw archiveError |
| 106 | + } |
| 107 | + |
| 108 | + // Combine all chunks into a single buffer |
| 109 | + const zipBuffer = Buffer.concat(chunks) |
| 110 | + |
| 111 | + // Return the zip file |
| 112 | + return new NextResponse(zipBuffer, { |
| 113 | + status: 200, |
| 114 | + headers: { |
| 115 | + "Content-Type": "application/zip", |
| 116 | + "Content-Disposition": `attachment; filename="run-${runId}-failed-logs.zip"`, |
| 117 | + "Content-Length": String(zipBuffer.length), |
| 118 | + }, |
| 119 | + }) |
| 120 | + } catch (error) { |
| 121 | + console.error("Error exporting failed logs:", error) |
| 122 | + |
| 123 | + if (error instanceof Error && error.name === "RecordNotFoundError") { |
| 124 | + return NextResponse.json({ error: "Run not found" }, { status: 404 }) |
| 125 | + } |
| 126 | + |
| 127 | + return NextResponse.json({ error: "Failed to export logs" }, { status: 500 }) |
| 128 | + } |
| 129 | +} |
0 commit comments