@@ -8,50 +8,85 @@ export const upload = new Hono<{
88 Variables : { userId : string } ;
99} > ( ) ;
1010
11+ /** Allowed non-image MIME types for file attachments. */
12+ const ALLOWED_FILE_TYPES = new Set ( [
13+ "application/pdf" ,
14+ "text/plain" ,
15+ "text/csv" ,
16+ "text/markdown" ,
17+ "application/json" ,
18+ "application/zip" ,
19+ "application/gzip" ,
20+ "application/x-tar" ,
21+ "audio/mpeg" , "audio/wav" , "audio/ogg" , "audio/mp4" , "audio/webm" , "audio/aac" ,
22+ "video/mp4" , "video/webm" , "video/quicktime" ,
23+ ] ) ;
24+
25+ /** Safe file extensions for each category. */
26+ const SAFE_EXTENSIONS = new Set ( [
27+ "jpg" , "jpeg" , "png" , "gif" , "webp" , "bmp" , "ico" ,
28+ "pdf" , "txt" , "csv" , "md" , "json" , "zip" , "gz" , "tar" ,
29+ "mp3" , "wav" , "ogg" , "m4a" , "aac" , "webm" ,
30+ "mp4" , "mov" ,
31+ ] ) ;
32+
1133/** POST / — Upload a file to R2 and return a signed URL. */
1234upload . post ( "/" , async ( c ) => {
13- const userId = c . get ( "userId" ) ;
14- const contentType = c . req . header ( "Content-Type" ) ?? "" ;
35+ try {
36+ const userId = c . get ( "userId" ) ;
37+ const contentType = c . req . header ( "Content-Type" ) ?? "" ;
1538
16- if ( ! contentType . includes ( "multipart/form-data" ) ) {
17- return c . json ( { error : "Expected multipart/form-data" } , 400 ) ;
18- }
39+ if ( ! contentType . includes ( "multipart/form-data" ) ) {
40+ return c . json ( { error : "Expected multipart/form-data" } , 400 ) ;
41+ }
1942
20- const formData = await c . req . formData ( ) ;
21- const file = formData . get ( "file" ) as File | null ;
43+ const formData = await c . req . formData ( ) ;
44+ const file = formData . get ( "file" ) as File | null ;
2245
23- if ( ! file ) {
24- return c . json ( { error : "No file provided" } , 400 ) ;
25- }
46+ if ( ! file ) {
47+ return c . json ( { error : "No file provided" } , 400 ) ;
48+ }
2649
27- // Validate file type — only raster images allowed (SVG is an XSS vector)
28- if ( ! file . type . startsWith ( "image/" ) || file . type . includes ( "svg" ) ) {
29- return c . json ( { error : "Only image files are allowed (SVG is not permitted)" } , 400 ) ;
30- }
50+ const fileType = file . type || "" ;
3151
32- // Limit file size to 10 MB
33- const MAX_SIZE = 10 * 1024 * 1024 ;
34- if ( file . size > MAX_SIZE ) {
35- return c . json ( { error : "File too large (max 10 MB)" } , 413 ) ;
36- }
52+ // Block SVG (XSS vector) and executables
53+ if ( fileType . includes ( "svg" ) || fileType . includes ( "executable" ) || fileType . includes ( "javascript" ) ) {
54+ return c . json ( { error : "File type not permitted (SVG, executables, scripts are blocked)" } , 400 ) ;
55+ }
3756
38- // Generate a unique key: media/{userId}/{timestamp}-{random}.{ext}
39- const ext = file . name . split ( "." ) . pop ( ) ?. toLowerCase ( ) ?? "png" ;
40- // SVG is excluded — it can contain <script> tags and is a known XSS vector
41- const safeExt = [ "jpg" , "jpeg" , "png" , "gif" , "webp" , "bmp" , "ico" ] . includes ( ext ) ? ext : "png" ;
42- const filename = `${ Date . now ( ) } -${ randomUUID ( ) . slice ( 0 , 8 ) } .${ safeExt } ` ;
43- const key = `media/${ userId } /${ filename } ` ;
44-
45- // Upload to R2
46- await c . env . MEDIA . put ( key , file . stream ( ) , {
47- httpMetadata : {
48- contentType : file . type ,
49- } ,
50- } ) ;
51-
52- // Return a signed URL (1 hour expiry)
53- const secret = getJwtSecret ( c . env ) ;
54- const url = await signMediaUrl ( userId , filename , secret , 3600 ) ;
55-
56- return c . json ( { url, key } ) ;
57+ // Allow images (except SVG) and a curated set of other file types
58+ const isImage = fileType . startsWith ( "image/" ) ;
59+ const isAllowedFile = ALLOWED_FILE_TYPES . has ( fileType ) ;
60+ if ( ! isImage && ! isAllowedFile ) {
61+ return c . json ( { error : `File type '${ fileType } ' is not supported` } , 400 ) ;
62+ }
63+
64+ // Limit file size to 10 MB
65+ const MAX_SIZE = 10 * 1024 * 1024 ;
66+ if ( file . size > MAX_SIZE ) {
67+ return c . json ( { error : "File too large (max 10 MB)" } , 413 ) ;
68+ }
69+
70+ // Generate a unique key: media/{userId}/{timestamp}-{random}.{ext}
71+ const ext = file . name . split ( "." ) . pop ( ) ?. toLowerCase ( ) ?? "bin" ;
72+ const safeExt = SAFE_EXTENSIONS . has ( ext ) ? ext : ( isImage ? "png" : "bin" ) ;
73+ const filename = `${ Date . now ( ) } -${ randomUUID ( ) . slice ( 0 , 8 ) } .${ safeExt } ` ;
74+ const key = `media/${ userId } /${ filename } ` ;
75+
76+ // Upload to R2
77+ await c . env . MEDIA . put ( key , file . stream ( ) , {
78+ httpMetadata : {
79+ contentType : fileType || "application/octet-stream" ,
80+ } ,
81+ } ) ;
82+
83+ // Return a signed URL (1 hour expiry)
84+ const secret = getJwtSecret ( c . env ) ;
85+ const url = await signMediaUrl ( userId , filename , secret , 3600 ) ;
86+
87+ return c . json ( { url, key } ) ;
88+ } catch ( err ) {
89+ console . error ( "[upload] Error:" , err ) ;
90+ return c . json ( { error : `Upload failed: ${ err instanceof Error ? err . message : String ( err ) } ` } , 500 ) ;
91+ }
5792} ) ;
0 commit comments