11import type { Application , Request , Response } from "express" ;
22import { EventEmitter } from "events" ;
33import rateLimit , { ipKeyGenerator } from "express-rate-limit" ;
4- import { mkdir , readFile } from "fs/promises" ;
5- import { join , resolve } from "path" ;
4+ import { randomUUID } from "node:crypto" ;
5+ import { mkdir , readFile , rm } from "fs/promises" ;
6+ import { basename , dirname , join , resolve } from "path" ;
67
78import { applyOutputFormat , type OutputFormat } from "../formatter.js" ;
89import { parseGitHubUrl } from "../ingest.js" ;
@@ -55,20 +56,54 @@ const MAX_JOBS = 100; // Prevent unbounded memory growth
5556
5657let pruneTimer : NodeJS . Timeout | null = null ;
5758
58- function pruneExpiredJobs ( ) : void {
59- const now = Date . now ( ) ;
59+ async function removeJob ( id : string , job : AnalysisJob ) : Promise < void > {
60+ jobs . delete ( id ) ;
61+
62+ if ( ! job . result ?. outputDir ) {
63+ return ;
64+ }
65+
66+ const outputRoot = resolve ( process . cwd ( ) , ".bootcamp-output" ) ;
67+ const jobRoot = resolve ( dirname ( job . result . outputDir ) ) ;
68+ if (
69+ jobRoot === outputRoot ||
70+ ! isPathInsideDir ( outputRoot , jobRoot ) ||
71+ basename ( jobRoot ) !== job . id
72+ ) {
73+ console . error ( `[web] Refusing to remove unexpected job output path for ${ job . id } ` ) ;
74+ return ;
75+ }
76+
77+ try {
78+ await rm ( jobRoot , { recursive : true , force : true } ) ;
79+ } catch ( error : unknown ) {
80+ console . error (
81+ `[web] Failed to remove output for expired job ${ job . id } : ${ getErrorMessage ( error , "Unknown cleanup error" ) } `
82+ ) ;
83+ }
84+ }
85+
86+ export async function pruneExpiredJobs ( now : number = Date . now ( ) ) : Promise < void > {
87+ const removals : Promise < void > [ ] = [ ] ;
6088 for ( const [ id , job ] of jobs ) {
6189 if ( job . completedAt && now - job . completedAt > JOB_TTL_MS ) {
62- jobs . delete ( id ) ;
90+ removals . push ( removeJob ( id , job ) ) ;
6391 }
6492 }
93+ await Promise . all ( removals ) ;
6594}
6695
6796export function startJobPruner ( ) : void {
6897 if ( pruneTimer ) {
6998 return ;
7099 }
71- pruneTimer = setInterval ( pruneExpiredJobs , JOB_TTL_MS ) ;
100+ pruneTimer = setInterval ( ( ) => {
101+ void pruneExpiredJobs ( ) . catch ( ( error : unknown ) => {
102+ console . error (
103+ `[web] Failed to prune expired jobs: ${ getErrorMessage ( error , "Unknown cleanup error" ) } `
104+ ) ;
105+ } ) ;
106+ } , JOB_TTL_MS ) ;
72107 pruneTimer . unref ( ) ;
73108}
74109
@@ -84,7 +119,7 @@ export function stopJobPruner(): void {
84119 * Generate unique job ID
85120 */
86121function generateJobId ( ) : string {
87- return `job_${ Date . now ( ) } _ ${ Math . random ( ) . toString ( 36 ) . substring ( 7 ) } ` ;
122+ return `job_${ randomUUID ( ) } ` ;
88123}
89124
90125// Client-supplied analysis options are validated/allowlisted here rather than
@@ -313,7 +348,7 @@ async function runAnalysis(job: AnalysisJob, options: Record<string, unknown>):
313348}
314349
315350export function registerRoutes ( app : Application ) : void {
316- const limiterKeyPrefix = `web-${ Date . now ( ) } - ${ Math . random ( ) . toString ( 36 ) . slice ( 2 , 10 ) } ` ;
351+ const limiterKeyPrefix = `web-${ randomUUID ( ) } ` ;
317352 const analysisEndpointRateLimit = rateLimit ( {
318353 windowMs : 15 * 60 * 1000 ,
319354 limit : 5 ,
@@ -360,8 +395,10 @@ export function registerRoutes(app: Application): void {
360395
361396 try {
362397 parseGitHubUrl ( repoUrl ) ; // Validate URL
363- } catch ( error : unknown ) {
364- res . status ( 400 ) . json ( { error : getErrorMessage ( error , "Invalid repository URL" ) } ) ;
398+ } catch {
399+ res . status ( 400 ) . json ( {
400+ error : "Enter a public GitHub, GitLab, or Bitbucket repository." ,
401+ } ) ;
365402 return ;
366403 }
367404
@@ -384,7 +421,10 @@ export function registerRoutes(app: Application): void {
384421 }
385422 }
386423 if ( oldestCompletedId ) {
387- jobs . delete ( oldestCompletedId ) ;
424+ const oldestCompletedJob = jobs . get ( oldestCompletedId ) ;
425+ if ( oldestCompletedJob ) {
426+ await removeJob ( oldestCompletedId , oldestCompletedJob ) ;
427+ }
388428 } else {
389429 // All jobs are still running — reject to prevent OOM
390430 res . status ( 503 ) . json ( { error : "Server at capacity, try again later" } ) ;
@@ -504,10 +544,7 @@ export function registerRoutes(app: Application): void {
504544 res . setHeader ( "Content-Type" , "text/plain; charset=utf-8" ) ;
505545 res . send ( content ) ;
506546 } catch ( error : unknown ) {
507- console . error (
508- `[web] Failed to read generated file "${ filename } " for job ${ jobId } :` ,
509- error
510- ) ;
547+ console . error ( `[web] Failed to read generated file "${ filename } " for job ${ jobId } :` , error ) ;
511548 res . status ( 500 ) . json ( { error : "Failed to read generated file" } ) ;
512549 }
513550 }
0 commit comments