     1	/**
     2	 * File Handling Plugin for JSON REST API
     3	 *
     4	 * This plugin provides automatic file upload handling based on schema definitions.
     5	 * It works with any protocol connector (HTTP, Express, WebSocket, etc.) by using
     6	 * a detector registry pattern.
     7	 *
     8	 * Features:
     9	 * - Schema-driven: Detects file fields from type: 'file' in schemas
    10	 * - Protocol-agnostic: Works with any connector that registers a detector
    11	 * - Storage pluggable: Different fields can use different storage backends
    12	 * - Zero configuration: Just define file fields in your schema
    13	 *
    14	 * Usage:
    15	 * ```javascript
    16	 * // 1. Define schema with file fields
    17	 * const imageSchema = {
    18	 *   title: { type: 'string' },
    19	 *   uploadedImage: {
    20	 *     type: 'file',
    21	 *     storage: S3Storage,
    22	 *     accepts: ['image/jpeg', 'image/png']
    23	 *   }
    24	 * };
    25	 *
    26	 * // 2. Use plugins (order matters - file-handling depends on rest-api)
    27	 * api.use(RestApiPlugin);
    28	 * api.use(FileHandlingPlugin);
    29	 * api.use(ExpressPlugin); // Or any other connector
    30	 *
    31	 * // 3. Files are automatically handled!
    32	 * ```
    33	 */
    34	
    35	import { RestApiValidationError } from '../../lib/rest-api-errors.js'
    36	
    37	export const FileHandlingPlugin = {
    38	  name: 'file-handling',
    39	  dependencies: ['rest-api'],
    40	
    41	  install ({ addHook, helpers, scopes, log, vars, on, api }) {
    42	    // Track which scopes have file fields
    43	    const fileScopes = new Map() // scopeName -> fileField[]
    44	
    45	    // Registry of file detectors from various protocols
    46	    const detectorRegistry = []
    47	
    48	    /**
    49	     * Register a file detector from a protocol plugin
    50	     *
    51	     * @param {Object} detector - The detector object
    52	     * @param {string} detector.name - Name of the detector (e.g., 'http-multipart')
    53	     * @param {Function} detector.detect - Function to check if this detector applies
    54	     * @param {Function} detector.parse - Function to parse files from the request
    55	     */
    56	    api.rest.registerFileDetector = (detector) => {
    57	      if (!detector || !detector.name || !detector.detect || !detector.parse) {
    58	        throw new Error('File detector must have name, detect(), and parse() properties')
    59	      }
    60	
    61	      detectorRegistry.push(detector)
    62	      log.debug(`Registered file detector: ${detector.name}`)
    63	    }
    64	
    65	    // Store detectors array for inspection
    66	    api.rest.fileDetectors = detectorRegistry
    67	
    68	    /**
    69	     * Analyze a scope's schema to find file fields
    70	     */
    71	    const analyzeScopeSchema = (scopeName, scopeOptions) => {
    72	      const schema = scopeOptions?.schema
    73	      if (!schema) return
    74	
    75	      const fileFields = []
    76	
    77	      // Look for fields with type: 'file'
    78	      for (const [fieldName, fieldConfig] of Object.entries(schema)) {
    79	        if (fieldConfig && fieldConfig.type === 'file') {
    80	          fileFields.push({
    81	            field: fieldName,
    82	            storage: fieldConfig.storage,
    83	            accepts: fieldConfig.accepts || ['*'],
    84	            maxSize: fieldConfig.maxSize,
    85	            required: fieldConfig.required || false
    86	          })
    87	        }
    88	      }
    89	
    90	      if (fileFields.length > 0) {
    91	        fileScopes.set(scopeName, fileFields)
    92	        log.info(`Scope '${scopeName}' has ${fileFields.length} file field(s): ${fileFields.map(f => f.field).join(', ')}`)
    93	      }
    94	    }
    95	
    96	    // Analyze existing scopes
    97	    for (const [scopeName, scope] of Object.entries(scopes)) {
    98	      if (scope._scopeOptions) {
    99	        analyzeScopeSchema(scopeName, scope._scopeOptions)
   100	      }
   101	    }
   102	
   103	    // Listen for new scopes being added
   104	    addHook('scope:added', 'analyzeFileFields', {}, ({ context }) => {
   105	      const { scopeName, scopeOptions } = context
   106	      analyzeScopeSchema(scopeName, scopeOptions)
   107	    })
   108	
   109	    /**
   110	     * Process files for a scope if it has file fields
   111	     */
   112	    const processFiles = async (scopeName, params, context) => {
   113	      const fileFields = fileScopes.get(scopeName)
   114	      if (!fileFields || fileFields.length === 0) {
   115	        return // This scope doesn't have file fields
   116	      }
   117	
   118	      // Try each detector to see if we have files
   119	      let parsed = null
   120	      let detectorUsed = null
   121	
   122	      for (const detector of detectorRegistry) {
   123	        try {
   124	          if (await detector.detect(params, context)) {
   125	            log.debug(`Detector '${detector.name}' matched for scope '${scopeName}'`)
   126	            parsed = await detector.parse(params, context)
   127	            detectorUsed = detector.name
   128	            break
   129	          }
   130	        } catch (error) {
   131	          log.warn(`Detector '${detector.name}' failed:`, error)
   132	        }
   133	      }
   134	
   135	      if (!parsed) {
   136	        // No files detected - check if any were required
   137	        for (const fieldConfig of fileFields) {
   138	          if (fieldConfig.required && !params.inputRecord?.data?.attributes?.[fieldConfig.field]) {
   139	            throw new RestApiValidationError(
   140	              `Required file field '${fieldConfig.field}' is missing`,
   141	              {
   142	                fields: [fieldConfig.field],
   143	                violations: [{
   144	                  field: fieldConfig.field,
   145	                  message: 'This field is required'
   146	                }]
   147	              }
   148	            )
   149	          }
   150	        }
   151	        return
   152	      }
   153	
   154	      log.debug(`Processing files with detector '${detectorUsed}'`)
   155	      const { fields, files } = parsed
   156	
   157	      // Process each file field defined in schema
   158	      for (const fieldConfig of fileFields) {
   159	        const file = files[fieldConfig.field]
   160	
   161	        if (!file) {
   162	          if (fieldConfig.required) {
   163	            throw new RestApiValidationError(
   164	              `Required file field '${fieldConfig.field}' is missing`,
   165	              {
   166	                fields: [fieldConfig.field],
   167	                violations: [{
   168	                  field: fieldConfig.field,
   169	                  message: 'This field is required'
   170	                }]
   171	              }
   172	            )
   173	          }
   174	          continue
   175	        }
   176	
   177	        // Validate mime type
   178	        if (fieldConfig.accepts[0] !== '*') {
   179	          const acceptable = fieldConfig.accepts.some(pattern => {
   180	            if (pattern.endsWith('/*')) {
   181	              // e.g., 'image/*'
   182	              const prefix = pattern.slice(0, -2)
   183	              return file.mimetype.startsWith(prefix + '/')
   184	            }
   185	            return file.mimetype === pattern
   186	          })
   187	
   188	          if (!acceptable) {
   189	            throw new RestApiValidationError(
   190	              `Invalid file type for field '${fieldConfig.field}'`,
   191	              {
   192	                fields: [fieldConfig.field],
   193	                violations: [{
   194	                  field: fieldConfig.field,
   195	                  message: `Expected ${fieldConfig.accepts.join(' or ')}, got ${file.mimetype}`
   196	                }]
   197	              }
   198	            )
   199	          }
   200	        }
   201	
   202	        // Validate file size
   203	        if (fieldConfig.maxSize) {
   204	          const maxBytes = parseSize(fieldConfig.maxSize)
   205	          if (file.size > maxBytes) {
   206	            throw new RestApiValidationError(
   207	              `File too large for field '${fieldConfig.field}'`,
   208	              {
   209	                fields: [fieldConfig.field],
   210	                violations: [{
   211	                  field: fieldConfig.field,
   212	                  message: `Maximum size is ${fieldConfig.maxSize}, got ${formatSize(file.size)}`
   213	                }]
   214	              }
   215	            )
   216	          }
   217	        }
   218	
   219	        // Upload to storage
   220	        if (!fieldConfig.storage) {
   221	          throw new Error(`No storage configured for file field '${fieldConfig.field}'`)
   222	        }
   223	
   224	        try {
   225	          const storedUrl = await fieldConfig.storage.upload(file)
   226	          fields[fieldConfig.field] = storedUrl
   227	          log.debug(`Uploaded file for field '${fieldConfig.field}' to: ${storedUrl}`)
   228	        } catch (error) {
   229	          // Cleanup if file has cleanup function
   230	          if (file.cleanup) {
   231	            try {
   232	              await file.cleanup()
   233	            } catch (cleanupError) {
   234	              log.warn('Failed to cleanup file after upload error:', cleanupError)
   235	            }
   236	          }
   237	
   238	          throw new RestApiValidationError(
   239	            `Failed to upload file for field '${fieldConfig.field}': ${error.message}`,
   240	            {
   241	              fields: [fieldConfig.field],
   242	              violations: [{
   243	                field: fieldConfig.field,
   244	                message: error.message
   245	              }]
   246	            }
   247	          )
   248	        }
   249	      }
   250	
   251	      // Replace inputRecord with processed data
   252	      if (!params.inputRecord) {
   253	        params.inputRecord = { data: { attributes: {} } }
   254	      }
   255	      if (!params.inputRecord.data) {
   256	        params.inputRecord.data = { attributes: {} }
   257	      }
   258	      if (!params.inputRecord.data.attributes) {
   259	        params.inputRecord.data.attributes = {}
   260	      }
   261	
   262	      // Merge fields into attributes
   263	      Object.assign(params.inputRecord.data.attributes, fields)
   264	
   265	      // Cleanup any remaining temp files
   266	      for (const file of Object.values(files)) {
   267	        if (file.cleanup) {
   268	          try {
   269	            await file.cleanup()
   270	          } catch (error) {
   271	            log.warn('Failed to cleanup temp file:', error)
   272	          }
   273	        }
   274	      }
   275	    }
   276	
   277	    /**
   278	     * Hook into REST API methods to process files
   279	     */
   280	    addHook('beforeProcessing', 'processFiles', {}, async ({ context }) => {
   281	      const method = context.method
   282	      const scopeName = context.scopeName
   283	      const params = context.params
   284	
   285	      // Only process for mutation methods
   286	      if (!['post', 'put', 'patch'].includes(method)) {
   287	        return
   288	      }
   289	
   290	      // Process files if this scope has file fields
   291	      await processFiles(scopeName, params, context)
   292	    })
   293	
   294	    log.info('File handling plugin initialized successfully')
   295	  }
   296	}
   297	
   298	/**
   299	 * Parse size string to bytes
   300	 * @param {string} size - Size string like '10mb', '1.5GB'
   301	 * @returns {number} Size in bytes
   302	 */
   303	function parseSize (size) {
   304	  const units = {
   305	    b: 1,
   306	    kb: 1024,
   307	    mb: 1024 * 1024,
   308	    gb: 1024 * 1024 * 1024
   309	  }
   310	
   311	  const match = size.toLowerCase().match(/^(\d+(?:\.\d+)?)\s*([a-z]+)$/)
   312	  if (!match) {
   313	    throw new Error(`Invalid size format: ${size}`)
   314	  }
   315	
   316	  const [, num, unit] = match
   317	  const multiplier = units[unit]
   318	
   319	  if (!multiplier) {
   320	    throw new Error(`Unknown size unit: ${unit}`)
   321	  }
   322	
   323	  return parseFloat(num) * multiplier
   324	}
   325	
   326	/**
   327	 * Format bytes to human readable size
   328	 * @param {number} bytes - Size in bytes
   329	 * @returns {string} Human readable size
   330	 */
   331	function formatSize (bytes) {
   332	  const units = ['B', 'KB', 'MB', 'GB']
   333	  let size = bytes
   334	  let unitIndex = 0
   335	
   336	  while (size >= 1024 && unitIndex < units.length - 1) {
   337	    size /= 1024
   338	    unitIndex++
   339	  }
   340	
   341	  return `${size.toFixed(1)}${units[unitIndex]}`
   342	}
