@@ -2,11 +2,31 @@ import { Router, Request, Response } from "express";
22import multer from "multer" ;
33import path from "path" ;
44import fs from "fs/promises" ;
5- import { DATA_DIR } from "../services/fileSystem" ;
5+ import { DATA_DIR as DEFAULT_DATA_DIR } from "../services/fileSystem" ;
66import { fileTransferLimiter , fileOperationLimiter } from "../middleware/rateLimiters" ;
77
88const router = Router ( ) ;
99
10+ const getDataDir = ( ) : string => {
11+ return process . env . TEST_DATA_DIR || process . env . DATA_DIR || DEFAULT_DATA_DIR ;
12+ } ;
13+
14+ const getFolderQueryParam = ( req : Request ) : string => {
15+ const folderQuery = req . query . folder ;
16+ if ( typeof folderQuery === "string" ) {
17+ return folderQuery ;
18+ }
19+
20+ // Fallback: parse raw URL so '+' is treated as space (x-www-form-urlencoded semantics)
21+ // This matches how browsers/axios tend to encode URL query parameters.
22+ try {
23+ const url = new URL ( req . originalUrl , "http://localhost" ) ;
24+ return url . searchParams . get ( "folder" ) || "" ;
25+ } catch {
26+ return "" ;
27+ }
28+ } ;
29+
1030/**
1131 * Validates and sanitizes a path to prevent path traversal attacks.
1232 * Ensures the resolved path is within the allowed base directory.
@@ -33,10 +53,12 @@ const validatePath = (basePath: string, ...userPath: string[]): string => {
3353const storage = multer . diskStorage ( {
3454 destination : async ( req , file , cb ) => {
3555 try {
36- const folderPath = ( req . query . folder as string ) || "" ;
56+ const folderPath = getFolderQueryParam ( req ) ;
57+
58+ const dataDir = getDataDir ( ) ;
3759
3860 // Validate path to prevent traversal
39- const attachmentsDir = validatePath ( DATA_DIR , folderPath , ".attachments" ) ;
61+ const attachmentsDir = validatePath ( dataDir , folderPath , ".attachments" ) ;
4062
4163 // Create .attachments directory if it doesn't exist
4264 await fs . mkdir ( attachmentsDir , { recursive : true } ) ;
@@ -83,10 +105,12 @@ router.post("/", fileTransferLimiter, upload.single("file"), async (req: Request
83105// List attachments for a folder
84106router . get ( "/" , async ( req : Request , res : Response ) => {
85107 try {
86- const folderPath = ( req . query . folder as string ) || "" ;
108+ const folderPath = getFolderQueryParam ( req ) ;
109+
110+ const dataDir = getDataDir ( ) ;
87111
88112 // Validate path to prevent traversal
89- const attachmentsDir = validatePath ( DATA_DIR , folderPath , ".attachments" ) ;
113+ const attachmentsDir = validatePath ( dataDir , folderPath , ".attachments" ) ;
90114
91115 try {
92116 const files = await fs . readdir ( attachmentsDir ) ;
@@ -120,7 +144,7 @@ router.get("/", async (req: Request, res: Response) => {
120144// Delete attachment
121145router . delete ( "/:filename" , fileOperationLimiter , async ( req : Request , res : Response ) => {
122146 try {
123- const folderPath = ( req . query . folder as string ) || "" ;
147+ const folderPath = getFolderQueryParam ( req ) ;
124148 const filenameParam = req . params . filename ;
125149
126150 if ( ! filenameParam || Array . isArray ( filenameParam ) ) {
@@ -129,8 +153,10 @@ router.delete("/:filename", fileOperationLimiter, async (req: Request, res: Resp
129153
130154 const filename = filenameParam ;
131155
156+ const dataDir = getDataDir ( ) ;
157+
132158 // Validate path to prevent traversal - this validates both folderPath and filename
133- const filePath = validatePath ( DATA_DIR , folderPath , ".attachments" , filename ) ;
159+ const filePath = validatePath ( dataDir , folderPath , ".attachments" , filename ) ;
134160
135161 await fs . unlink ( filePath ) ;
136162 res . json ( { success : true } ) ;
@@ -149,7 +175,7 @@ router.delete("/:filename", fileOperationLimiter, async (req: Request, res: Resp
149175// Get/download attachment
150176router . get ( "/:filename" , fileTransferLimiter , async ( req : Request , res : Response ) => {
151177 try {
152- const folderPath = ( req . query . folder as string ) || "" ;
178+ const folderPath = getFolderQueryParam ( req ) ;
153179 const filenameParam = req . params . filename ;
154180
155181 if ( ! filenameParam || Array . isArray ( filenameParam ) ) {
@@ -158,12 +184,39 @@ router.get("/:filename", fileTransferLimiter, async (req: Request, res: Response
158184
159185 const filename = filenameParam ;
160186
187+ const dataDir = getDataDir ( ) ;
188+
161189 // Validate path to prevent traversal - this validates both folderPath and filename
162- const filePath = validatePath ( DATA_DIR , folderPath , ".attachments" , filename ) ;
190+ const filePath = validatePath ( dataDir , folderPath , ".attachments" , filename ) ;
163191
164192 // nosemgrep: javascript.express.security.audit.express-res-sendfile.express-res-sendfile
165193 // Safe: filePath has been validated by validatePath() to prevent directory traversal
166- res . sendFile ( filePath ) ;
194+ res . sendFile ( filePath , { dotfiles : "allow" } , ( error ) => {
195+ if ( ! error ) {
196+ return ;
197+ }
198+
199+ // `send` defaults to ignoring dotfiles; our attachments live under `.attachments`.
200+ // Explicitly allow dotfiles above; remaining errors indicate missing/forbidden files.
201+ if ( ( error as any ) . code === "ENOENT" || ( error as any ) . statusCode === 404 ) {
202+ if ( ! res . headersSent ) {
203+ res . status ( 404 ) . json ( { error : "File not found" } ) ;
204+ }
205+ return ;
206+ }
207+
208+ if ( ( error as any ) . code === "EACCES" || ( error as any ) . statusCode === 403 ) {
209+ if ( ! res . headersSent ) {
210+ res . status ( 403 ) . json ( { error : "Forbidden" } ) ;
211+ }
212+ return ;
213+ }
214+
215+ console . error ( "Get attachment sendFile error:" , error ) ;
216+ if ( ! res . headersSent ) {
217+ res . status ( 500 ) . json ( { error : "Failed to retrieve file" } ) ;
218+ }
219+ } ) ;
167220 } catch ( error : any ) {
168221 if ( error . message === "Path traversal detected" ) {
169222 return res . status ( 403 ) . json ( { error : "Invalid file path" } ) ;
0 commit comments