@@ -22,6 +22,11 @@ type UploadOptions = {
2222 maxSize ?: number ;
2323 /** MIME types to allow, validated via magic bytes on the first chunk. */
2424 allowedMimeTypes : readonly string [ ] ;
25+ /**
26+ * Request / timeout signal. On abort, the clamd socket is destroyed and
27+ * the S3 multipart is aborted, so no orphan parts are left behind.
28+ */
29+ signal ?: AbortSignal ;
2530} ;
2631
2732/**
@@ -36,7 +41,8 @@ export type UploadFailureReason =
3641 | "wrong_type"
3742 | "empty"
3843 | "virus"
39- | "scan_unavailable" ;
44+ | "scan_unavailable"
45+ | "aborted" ;
4046
4147export type UploadResult =
4248 | { ok : true ; key : string ; fileId : string }
@@ -60,12 +66,16 @@ export async function handleStreamingUpload(
6066 options : UploadOptions ,
6167) : Promise < UploadResult > {
6268 const maxSize = options . maxSize ?? MAX_FILE_SIZE ;
63- const ext = path . extname ( options . fileName ) || ".bin" ;
69+ const signal = options . signal ;
70+ // Normalise ext to alnum only — `path.extname` rules out `/` traversal
71+ // (basename suffix) but exotic bytes would survive and land in the S3 key.
72+ const rawExt = path . extname ( options . fileName ) ;
73+ const ext = / ^ \. [ A - Z a - z 0 - 9 ] { 1 , 10 } $ / . test ( rawExt ) ? rawExt : ".bin" ;
6474 const fileId = crypto . randomUUID ( ) ;
6575 const key = `${ options . siren } /${ options . year } /${ fileId } ${ ext } ` ;
6676
67- const clamd = createClamdStream ( env . CLAMAV_HOST , env . CLAMAV_PORT ) ;
68- const s3Upload = createMultipartUpload ( key , options . contentType ) ;
77+ const clamd = createClamdStream ( env . CLAMAV_HOST , env . CLAMAV_PORT , signal ) ;
78+ const s3Upload = createMultipartUpload ( key , options . contentType , signal ) ;
6979 await s3Upload . init ( ) ;
7080
7181 let totalBytes = 0 ;
@@ -74,6 +84,7 @@ export async function handleStreamingUpload(
7484
7585 try {
7686 while ( true ) {
87+ if ( signal ?. aborted ) throw new UploadAbortedError ( ) ;
7788 const { done, value } = await reader . read ( ) ;
7889 if ( done ) break ;
7990
@@ -103,19 +114,29 @@ export async function handleStreamingUpload(
103114 s3Upload . sendChunk ( buf ) ,
104115 ] ) ;
105116 if ( clamdResult . status === "rejected" ) {
117+ if ( signal ?. aborted ) throw new UploadAbortedError ( ) ;
106118 throw new ClamdScanError ( clamdResult . reason ) ;
107119 }
108120 if ( s3Result . status === "rejected" ) {
121+ if ( signal ?. aborted ) throw new UploadAbortedError ( ) ;
109122 throw s3Result . reason ;
110123 }
111124 }
112125 } catch ( err ) {
113126 clamd . destroy ( ) ;
114127 await s3Upload . abort ( ) . catch ( ( ) => { } ) ;
128+ await reader . cancel ( ) . catch ( ( ) => { } ) ;
115129
116130 if ( err instanceof FileTooLargeError ) {
117131 return { ok : false , reason : "too_large" , error : FILE_TOO_LARGE_ERROR } ;
118132 }
133+ if ( err instanceof UploadAbortedError || signal ?. aborted ) {
134+ return {
135+ ok : false ,
136+ reason : "aborted" ,
137+ error : "L'upload a été interrompu." ,
138+ } ;
139+ }
119140 if ( err instanceof ClamdScanError ) {
120141 console . error ( "[fileUpload] clamd sendChunk failed" , err . cause ) ;
121142 return {
@@ -144,6 +165,13 @@ export async function handleStreamingUpload(
144165 console . error ( "[fileUpload] clamd scan failed" , err ) ;
145166 clamd . destroy ( ) ;
146167 await s3Upload . abort ( ) . catch ( ( ) => { } ) ;
168+ if ( signal ?. aborted ) {
169+ return {
170+ ok : false ,
171+ reason : "aborted" ,
172+ error : "L'upload a été interrompu." ,
173+ } ;
174+ }
147175 return {
148176 ok : false ,
149177 reason : "scan_unavailable" ,
@@ -180,6 +208,17 @@ class FileTooLargeError extends Error {
180208 }
181209}
182210
211+ /**
212+ * Sentinel thrown when the request-scoped AbortSignal fires (client
213+ * disconnect or request-level timeout). Lets the outer catch distinguish an
214+ * interruption from a genuine scan/S3 failure.
215+ */
216+ class UploadAbortedError extends Error {
217+ constructor ( ) {
218+ super ( "Upload aborted by request signal" ) ;
219+ }
220+ }
221+
183222/**
184223 * Wraps a failure from `clamd.sendChunk()` so the outer catch can distinguish
185224 * an antivirus-availability issue from a generic I/O failure and surface it
0 commit comments