     1	/**
     2	 * Local File Storage Adapter
     3	 *
     4	 * Production-ready local storage with secure filename handling
     5	 */
     6	
     7	import { promises as fs } from 'fs'
     8	import path from 'path'
     9	import crypto from 'crypto'
    10	
    11	export class LocalStorage {
    12	  constructor (options = {}) {
    13	    this.directory = path.resolve(options.directory || './uploads')
    14	    this.fileBaseUrl = options.fileBaseUrl || '/uploads'
    15	    this.nameStrategy = options.nameStrategy || 'hash' // 'hash', 'timestamp', 'original', 'custom'
    16	    this.nameGenerator = options.nameGenerator // Custom function
    17	    this.preserveExtension = options.preserveExtension !== false
    18	    this.allowedExtensions = options.allowedExtensions // Whitelist
    19	    this.maxFilenameLength = options.maxFilenameLength || 255
    20	  }
    21	
    22	  /**
    23	   * Generate a safe filename
    24	   */
    25	  async generateFilename (file) {
    26	    let basename, extension
    27	
    28	    // Extract and validate extension
    29	    const originalExt = path.extname(file.filename).toLowerCase()
    30	    if (this.preserveExtension && originalExt) {
    31	      // Validate extension against whitelist if provided
    32	      if (this.allowedExtensions && !this.allowedExtensions.includes(originalExt)) {
    33	        throw new Error(`File extension '${originalExt}' is not allowed`)
    34	      }
    35	      extension = originalExt
    36	    } else {
    37	      // Determine extension from MIME type for safety
    38	      extension = this.getExtensionFromMimeType(file.mimetype)
    39	    }
    40	
    41	    // Generate basename based on strategy
    42	    switch (this.nameStrategy) {
    43	      case 'hash':
    44	        // Cryptographically secure random name
    45	        basename = crypto.randomBytes(16).toString('hex')
    46	        break
    47	
    48	      case 'timestamp':
    49	        // Timestamp + random suffix to prevent collisions
    50	        basename = `${Date.now()}_${crypto.randomBytes(4).toString('hex')}`
    51	        break
    52	
    53	      case 'original':
    54	        // Sanitized original filename
    55	        basename = this.sanitizeFilename(
    56	          path.basename(file.filename, originalExt)
    57	        )
    58	        break
    59	
    60	      case 'custom':
    61	        if (!this.nameGenerator) {
    62	          throw new Error('nameGenerator function required for custom strategy')
    63	        }
    64	        basename = await this.nameGenerator(file)
    65	        break
    66	
    67	      default:
    68	        throw new Error(`Unknown name strategy: ${this.nameStrategy}`)
    69	    }
    70	
    71	    // Combine basename and extension
    72	    let filename = extension ? `${basename}${extension}` : basename
    73	
    74	    // Ensure filename doesn't exceed max length
    75	    if (filename.length > this.maxFilenameLength) {
    76	      // Truncate basename to fit
    77	      const maxBasename = this.maxFilenameLength - (extension?.length || 0)
    78	      basename = basename.substring(0, maxBasename)
    79	      filename = extension ? `${basename}${extension}` : basename
    80	    }
    81	
    82	    // Handle duplicates
    83	    filename = await this.ensureUnique(filename)
    84	
    85	    return filename
    86	  }
    87	
    88	  /**
    89	   * Sanitize filename for safe storage
    90	   */
    91	  sanitizeFilename (filename) {
    92	    return filename
    93	      // Remove path traversal attempts
    94	      .replace(/\.\./g, '')
    95	      .replace(/[\/\\]/g, '')
    96	      // Remove control characters and special chars
    97	      .replace(/[^\w\s.-]/g, '_')
    98	      // Remove leading/trailing dots and spaces
    99	      .replace(/^[\s.]+|[\s.]+$/g, '')
   100	      // Collapse multiple underscores
   101	      .replace(/_+/g, '_') ||
   102	      // Default if empty
   103	      'unnamed'
   104	  }
   105	
   106	  /**
   107	   * Ensure filename is unique
   108	   */
   109	  async ensureUnique (filename) {
   110	    const filepath = path.join(this.directory, filename)
   111	
   112	    try {
   113	      await fs.access(filepath)
   114	      // File exists, generate unique name
   115	      const ext = path.extname(filename)
   116	      const base = path.basename(filename, ext)
   117	      let counter = 1
   118	      let newFilename
   119	
   120	      do {
   121	        newFilename = `${base}_${counter}${ext}`
   122	        counter++
   123	      } while (await this.fileExists(path.join(this.directory, newFilename)))
   124	
   125	      return newFilename
   126	    } catch (error) {
   127	      // File doesn't exist, name is unique
   128	      return filename
   129	    }
   130	  }
   131	
   132	  /**
   133	   * Check if file exists
   134	   */
   135	  async fileExists (filepath) {
   136	    try {
   137	      await fs.access(filepath)
   138	      return true
   139	    } catch {
   140	      return false
   141	    }
   142	  }
   143	
   144	  /**
   145	   * Get safe extension from MIME type
   146	   */
   147	  getExtensionFromMimeType (mimetype) {
   148	    // Safe mappings only
   149	    const mimeToExt = {
   150	      'image/jpeg': '.jpg',
   151	      'image/png': '.png',
   152	      'image/gif': '.gif',
   153	      'image/webp': '.webp',
   154	      'application/pdf': '.pdf',
   155	      'text/plain': '.txt',
   156	      'application/json': '.json',
   157	      'video/mp4': '.mp4',
   158	      'audio/mpeg': '.mp3',
   159	      'application/zip': '.zip'
   160	    }
   161	
   162	    return mimeToExt[mimetype] || '.bin'
   163	  }
   164	
   165	  /**
   166	   * Upload file with secure filename
   167	   */
   168	  async upload (file) {
   169	    // Ensure directory exists
   170	    await fs.mkdir(this.directory, { recursive: true })
   171	
   172	    // Generate secure filename
   173	    const filename = await this.generateFilename(file)
   174	    const filepath = path.join(this.directory, filename)
   175	
   176	    // Ensure we're not writing outside our directory (defense in depth)
   177	    const resolvedPath = path.resolve(filepath)
   178	    const resolvedDir = path.resolve(this.directory)
   179	    if (!resolvedPath.startsWith(resolvedDir)) {
   180	      throw new Error('Invalid file path')
   181	    }
   182	
   183	    // Write file
   184	    if (file.data) {
   185	      await fs.writeFile(filepath, file.data)
   186	    } else if (file.filepath) {
   187	      await fs.rename(file.filepath, filepath)
   188	    } else {
   189	      throw new Error('File has no data or filepath')
   190	    }
   191	
   192	    // Return public URL
   193	    return `${this.fileBaseUrl}/${filename}`
   194	  }
   195	
   196	  /**
   197	   * Delete a file
   198	   */
   199	  async delete (url) {
   200	    const filename = path.basename(url)
   201	    const filepath = path.join(this.directory, filename)
   202	
   203	    // Security check
   204	    const resolvedPath = path.resolve(filepath)
   205	    const resolvedDir = path.resolve(this.directory)
   206	    if (!resolvedPath.startsWith(resolvedDir)) {
   207	      throw new Error('Invalid file path')
   208	    }
   209	
   210	    try {
   211	      await fs.unlink(filepath)
   212	    } catch (error) {
   213	      if (error.code !== 'ENOENT') {
   214	        throw error
   215	      }
   216	    }
   217	  }
   218	}
