@msal95/fileguard 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,86 @@
1
+ import busboy from 'busboy'
2
+ import { validateFile } from '../core/validator.js'
3
+ import { getStorageAdapter } from '../storage/index.js'
4
+ import { failResult } from '../errors/UploadError.js'
5
+
6
+ /**
7
+ * Create an Express middleware that handles multipart file uploads.
8
+ *
9
+ * Attaches the result to `req.uploadResult` and always calls `next()`.
10
+ * On an unexpected internal error, calls `next(err)`.
11
+ *
12
+ * Usage:
13
+ * app.post('/upload', createExpressMiddleware(config), (req, res) => {
14
+ * if (!req.uploadResult.success) return res.status(422).json(req.uploadResult)
15
+ * res.json(req.uploadResult)
16
+ * })
17
+ *
18
+ * @param {object} [config] - fileguard config (storage, allowedExtensions, etc.)
19
+ * @returns {Function} Express middleware (req, res, next) => void
20
+ */
21
+ export function createExpressMiddleware(config = {}) {
22
+ return function uploadMiddleware(req, res, next) {
23
+ let bb
24
+ try {
25
+ bb = busboy({ headers: req.headers, limits: { files: 1 } })
26
+ } catch (err) {
27
+ // Malformed Content-Type header (e.g. not a multipart request)
28
+ req.uploadResult = failResult('STORAGE_ERROR', `Could not parse request: ${err.message}`)
29
+ return next()
30
+ }
31
+
32
+ let fileData = null
33
+ let done = false
34
+ const finish = (result) => {
35
+ if (done) return
36
+ done = true
37
+ req.uploadResult = result
38
+ next()
39
+ }
40
+
41
+ bb.on('file', (fieldname, fileStream, info) => {
42
+ const { filename, mimeType } = info
43
+ const chunks = []
44
+ fileStream.on('data', (chunk) => chunks.push(chunk))
45
+ fileStream.on('end', () => {
46
+ fileData = { buffer: Buffer.concat(chunks), filename, mimeType }
47
+ })
48
+ fileStream.on('error', (err) => next(err))
49
+ })
50
+
51
+ bb.on('finish', async () => {
52
+ try {
53
+ if (!fileData) {
54
+ return finish(failResult('STORAGE_ERROR', 'No file found in request'))
55
+ }
56
+
57
+ const { buffer, filename, mimeType } = fileData
58
+ const validation = await validateFile(
59
+ { buffer, filename, mimeType, size: buffer.length },
60
+ config
61
+ )
62
+
63
+ if (!validation.success) return finish(validation)
64
+
65
+ const store = await getStorageAdapter(config.storage ?? 'local')
66
+ const result = await store(
67
+ {
68
+ buffer,
69
+ filename,
70
+ sanitizedFilename: validation.sanitizedFilename,
71
+ size: buffer.length,
72
+ mimeType,
73
+ },
74
+ config
75
+ )
76
+
77
+ finish(result)
78
+ } catch (err) {
79
+ if (!done) { done = true; next(err) }
80
+ }
81
+ })
82
+
83
+ bb.on('error', (err) => { if (!done) { done = true; next(err) } })
84
+ req.pipe(bb)
85
+ }
86
+ }
@@ -0,0 +1,105 @@
1
+ import busboy from 'busboy'
2
+ import { validateFile } from '../core/validator.js'
3
+ import { getStorageAdapter } from '../storage/index.js'
4
+ import { failResult } from '../errors/UploadError.js'
5
+
6
+ /**
7
+ * Parse a raw Node.js http.IncomingMessage as a multipart upload using busboy.
8
+ * @param {import('http').IncomingMessage} rawRequest
9
+ * @param {object} config
10
+ * @returns {Promise<object>} fileguard result object
11
+ */
12
+ async function processMultipart(rawRequest, config) {
13
+ return new Promise((resolve, reject) => {
14
+ let bb
15
+ try {
16
+ bb = busboy({ headers: rawRequest.headers, limits: { files: 1 } })
17
+ } catch (err) {
18
+ return resolve(failResult('STORAGE_ERROR', `Could not parse request: ${err.message}`))
19
+ }
20
+
21
+ let fileData = null
22
+
23
+ bb.on('file', (fieldname, fileStream, info) => {
24
+ const { filename, mimeType } = info
25
+ const chunks = []
26
+ fileStream.on('data', (chunk) => chunks.push(chunk))
27
+ fileStream.on('end', () => {
28
+ fileData = { buffer: Buffer.concat(chunks), filename, mimeType }
29
+ })
30
+ fileStream.on('error', reject)
31
+ })
32
+
33
+ bb.on('finish', async () => {
34
+ try {
35
+ if (!fileData) {
36
+ return resolve(failResult('STORAGE_ERROR', 'No file found in request'))
37
+ }
38
+
39
+ const { buffer, filename, mimeType } = fileData
40
+ const validation = await validateFile(
41
+ { buffer, filename, mimeType, size: buffer.length },
42
+ config
43
+ )
44
+
45
+ if (!validation.success) return resolve(validation)
46
+
47
+ const store = await getStorageAdapter(config.storage ?? 'local')
48
+ const result = await store(
49
+ {
50
+ buffer,
51
+ filename,
52
+ sanitizedFilename: validation.sanitizedFilename,
53
+ size: buffer.length,
54
+ mimeType,
55
+ },
56
+ config
57
+ )
58
+ resolve(result)
59
+ } catch (err) {
60
+ reject(err)
61
+ }
62
+ })
63
+
64
+ bb.on('error', reject)
65
+ rawRequest.pipe(bb)
66
+ })
67
+ }
68
+
69
+ /**
70
+ * Create a Fastify plugin for file uploads.
71
+ *
72
+ * Registers a content-type parser for multipart/form-data and adds an
73
+ * `uploadGuard()` decorator that returns a preHandler function.
74
+ *
75
+ * Usage:
76
+ * await fastify.register(createFastifyPlugin({ storage: 'local' }))
77
+ * fastify.post('/upload', { preHandler: fastify.uploadGuard() }, async (req, reply) => {
78
+ * reply.send(req.uploadResult)
79
+ * })
80
+ *
81
+ * @param {object} [config] - fileguard config
82
+ * @returns {Function} Fastify plugin async (fastify, opts) => void
83
+ */
84
+ export function createFastifyPlugin(config = {}) {
85
+ return async function fastifyUploadPlugin(fastify) {
86
+ // Prevent Fastify from trying to JSON-parse multipart bodies.
87
+ fastify.addContentTypeParser('multipart/form-data', (_req, payload, done) => {
88
+ done(null, payload)
89
+ })
90
+
91
+ /**
92
+ * Returns a preHandler that validates + stores the upload and attaches
93
+ * the result to `request.uploadResult`.
94
+ * @param {object} [overrides] - per-route config overrides
95
+ * @returns {Function} async (request, reply) => void
96
+ */
97
+ fastify.decorate('uploadGuard', function uploadGuard(overrides = {}) {
98
+ const mergedConfig = { ...config, ...overrides }
99
+
100
+ return async function preHandler(request) {
101
+ request.uploadResult = await processMultipart(request.raw, mergedConfig)
102
+ }
103
+ })
104
+ }
105
+ }
@@ -0,0 +1,3 @@
1
+ export { createExpressMiddleware } from './express.js'
2
+ export { createNextHandler } from './nextjs.js'
3
+ export { createFastifyPlugin } from './fastify.js'
@@ -0,0 +1,74 @@
1
+ import { validateFile } from '../core/validator.js'
2
+ import { getStorageAdapter } from '../storage/index.js'
3
+ import { failResult } from '../errors/UploadError.js'
4
+
5
+ /**
6
+ * Create a Next.js App Router route handler for file uploads.
7
+ *
8
+ * Usage in app/api/upload/route.js:
9
+ * import { createNextHandler } from 'fileguard/nextjs'
10
+ * export const POST = createNextHandler({ storage: 'local', localPath: './uploads' })
11
+ *
12
+ * @param {object} [config] - fileguard config
13
+ * @returns {Function} async (request: Request) => Response
14
+ */
15
+ export function createNextHandler(config = {}) {
16
+ const fieldName = config.fieldName ?? 'file'
17
+
18
+ return async function handler(request) {
19
+ try {
20
+ let formData
21
+ try {
22
+ formData = await request.formData()
23
+ } catch {
24
+ return jsonResponse(failResult('STORAGE_ERROR', 'Failed to parse form data'), 400)
25
+ }
26
+
27
+ const fileEntry = formData.get(fieldName)
28
+ if (!fileEntry || typeof fileEntry === 'string') {
29
+ return jsonResponse(failResult('STORAGE_ERROR', `No file found in field "${fieldName}"`), 400)
30
+ }
31
+
32
+ const buffer = Buffer.from(await fileEntry.arrayBuffer())
33
+ const filename = fileEntry.name ?? 'upload'
34
+ const mimeType = fileEntry.type ?? 'application/octet-stream'
35
+
36
+ const validation = await validateFile(
37
+ { buffer, filename, mimeType, size: buffer.length },
38
+ config
39
+ )
40
+
41
+ if (!validation.success) {
42
+ return jsonResponse(validation, 422)
43
+ }
44
+
45
+ const store = await getStorageAdapter(config.storage ?? 'local')
46
+ const result = await store(
47
+ {
48
+ buffer,
49
+ filename,
50
+ sanitizedFilename: validation.sanitizedFilename,
51
+ size: buffer.length,
52
+ mimeType,
53
+ },
54
+ config
55
+ )
56
+
57
+ return jsonResponse(result, result.success ? 200 : 500)
58
+ } catch (err) {
59
+ return jsonResponse(failResult('STORAGE_ERROR', err.message ?? 'Internal error'), 500)
60
+ }
61
+ }
62
+ }
63
+
64
+ /**
65
+ * @param {object} body
66
+ * @param {number} status
67
+ * @returns {Response}
68
+ */
69
+ function jsonResponse(body, status) {
70
+ return new Response(JSON.stringify(body), {
71
+ status,
72
+ headers: { 'Content-Type': 'application/json' },
73
+ })
74
+ }
@@ -0,0 +1,32 @@
1
+ import { appendFile, mkdir } from 'fs/promises'
2
+ import path from 'path'
3
+
4
+ /**
5
+ * Create an audit logger that appends JSON-newline entries to a log file.
6
+ * If logging fails, the error is printed but the upload is NOT blocked.
7
+ *
8
+ * @param {{ logPath?: string, enabled?: boolean }} [config]
9
+ * @returns {{ log: (event: object) => Promise<void> }}
10
+ */
11
+ export function createLogger(config = {}) {
12
+ const { logPath = './logs/uploads.log', enabled = true } = config
13
+
14
+ return {
15
+ /**
16
+ * Append an audit event to the log file.
17
+ * @param {object} event - Arbitrary key/value data to record
18
+ */
19
+ async log(event) {
20
+ if (!enabled) return
21
+
22
+ const entry = JSON.stringify({ timestamp: new Date().toISOString(), ...event }) + '\n'
23
+
24
+ try {
25
+ await mkdir(path.dirname(path.resolve(logPath)), { recursive: true })
26
+ await appendFile(logPath, entry, 'utf8')
27
+ } catch (err) {
28
+ console.error('[fileguard audit] Failed to write log entry:', err.message)
29
+ }
30
+ },
31
+ }
32
+ }
@@ -0,0 +1,55 @@
1
+ import { failResult } from '../errors/UploadError.js'
2
+
3
+ /**
4
+ * Create a per-key in-memory rate limiter.
5
+ * Expired windows are cleaned up automatically on each check.
6
+ *
7
+ * @param {{ maxUploads?: number, windowMs?: number }} [config]
8
+ * @returns {{ check: (key: string) => object, reset: (key: string) => void }}
9
+ */
10
+ export function createRateLimiter(config = {}) {
11
+ const { maxUploads = 10, windowMs = 60_000 } = config
12
+ const store = new Map()
13
+
14
+ function cleanup(now) {
15
+ for (const [key, entry] of store) {
16
+ if (now >= entry.resetAt) store.delete(key)
17
+ }
18
+ }
19
+
20
+ return {
21
+ /**
22
+ * Check and increment the counter for a given key.
23
+ * @param {string} key - User ID or IP address
24
+ * @returns {{ success: true } | { success: false, error: string, message: string }}
25
+ */
26
+ check(key) {
27
+ const now = Date.now()
28
+ cleanup(now)
29
+
30
+ const entry = store.get(key)
31
+ if (!entry || now >= entry.resetAt) {
32
+ if (maxUploads <= 0) {
33
+ return failResult('RATE_LIMIT_EXCEEDED', 'Upload rate limit exceeded. Try again later.')
34
+ }
35
+ store.set(key, { count: 1, resetAt: now + windowMs })
36
+ return { success: true }
37
+ }
38
+
39
+ if (entry.count >= maxUploads) {
40
+ return failResult('RATE_LIMIT_EXCEEDED', `Upload rate limit exceeded. Try again later.`)
41
+ }
42
+
43
+ entry.count++
44
+ return { success: true }
45
+ },
46
+
47
+ /**
48
+ * Remove the counter for a key (useful in tests).
49
+ * @param {string} key
50
+ */
51
+ reset(key) {
52
+ store.delete(key)
53
+ },
54
+ }
55
+ }
@@ -0,0 +1,62 @@
1
+ import path from 'path'
2
+ import { v4 as uuidv4 } from 'uuid'
3
+
4
+ const MAX_LENGTH = 255
5
+
6
+ // Windows reserved device names — treated as unsafe regardless of extension.
7
+ const RESERVED = new Set([
8
+ 'CON', 'PRN', 'AUX', 'NUL',
9
+ 'COM1', 'COM2', 'COM3', 'COM4', 'COM5', 'COM6', 'COM7', 'COM8', 'COM9',
10
+ 'LPT1', 'LPT2', 'LPT3', 'LPT4', 'LPT5', 'LPT6', 'LPT7', 'LPT8', 'LPT9',
11
+ ])
12
+
13
+ /**
14
+ * Return true when the filename contains characters or patterns that indicate
15
+ * path traversal, null bytes, or otherwise unsafe input.
16
+ * @param {string} filename
17
+ * @returns {boolean}
18
+ */
19
+ function isUnsafe(filename) {
20
+ return (
21
+ filename.includes('..') ||
22
+ filename.includes('/') ||
23
+ filename.includes('\\') ||
24
+ filename.includes('\0') ||
25
+ /[\x00-\x1f\x7f]/.test(filename)
26
+ )
27
+ }
28
+
29
+ /**
30
+ * Sanitize a filename for safe storage.
31
+ *
32
+ * - Path traversal, null bytes, and control characters → replaced with UUID-based name.
33
+ * - Windows reserved names → replaced with UUID-based name.
34
+ * - Remaining special characters stripped from the stem.
35
+ * - Result truncated to 255 characters.
36
+ * - Original extension is always preserved (lowercased).
37
+ *
38
+ * Never rejects — always returns a safe filename.
39
+ *
40
+ * @param {string} filename - Original filename from the upload
41
+ * @returns {{ sanitizedFilename: string, wasUnsafe: boolean }}
42
+ */
43
+ export function sanitizeFilename(filename) {
44
+ const ext = path.extname(filename).toLowerCase()
45
+ const stem = path.basename(filename, ext)
46
+
47
+ const unsafe = isUnsafe(filename) || RESERVED.has(stem.toUpperCase())
48
+ if (unsafe) {
49
+ return { sanitizedFilename: `${uuidv4()}${ext}`, wasUnsafe: true }
50
+ }
51
+
52
+ // Strip remaining problematic characters from the stem only.
53
+ const cleanStem = stem
54
+ .replace(/[^\w\-. ]/g, '') // keep word chars, hyphens, dots, spaces
55
+ .replace(/\s+/g, '_') // spaces → underscores
56
+ .replace(/^\.+/, '') // no leading dots
57
+
58
+ const safeName = cleanStem ? `${cleanStem}${ext}` : `${uuidv4()}${ext}`
59
+ const truncated = safeName.slice(0, MAX_LENGTH)
60
+
61
+ return { sanitizedFilename: truncated, wasUnsafe: false }
62
+ }
@@ -0,0 +1,163 @@
1
+ import path from 'path'
2
+ import { failResult } from '../errors/UploadError.js'
3
+ import { validateMagicBytes } from '../scanners/magicBytes.js'
4
+ import { checkZipBomb } from '../scanners/zipBomb.js'
5
+ import { checkPolyglot } from '../scanners/polyglot.js'
6
+ import { sanitizeFilename } from './sanitizer.js'
7
+ import { scanWithClamAV } from '../scanners/clamav.js'
8
+ import { scanWithVirusTotal } from '../scanners/virustotal.js'
9
+
10
+ // MIME types that should trigger ZIP bomb detection.
11
+ const ARCHIVE_MIME_TYPES = new Set([
12
+ 'application/zip',
13
+ 'application/x-zip-compressed',
14
+ 'application/x-zip',
15
+ 'application/java-archive',
16
+ ])
17
+
18
+ /**
19
+ * Default configuration values.
20
+ */
21
+ export const defaultConfig = {
22
+ maxFileSize: 10 * 1024 * 1024,
23
+ allowedExtensions: ['jpg', 'jpeg', 'png', 'gif', 'webp', 'pdf', 'doc', 'docx', 'xls', 'xlsx'],
24
+ allowedMimeTypes: [
25
+ 'image/jpeg',
26
+ 'image/png',
27
+ 'image/gif',
28
+ 'image/webp',
29
+ 'application/pdf',
30
+ ],
31
+ storage: 'local',
32
+ localPath: './uploads',
33
+ scan: {
34
+ magicBytes: true,
35
+ zipBomb: true,
36
+ polyglot: true,
37
+ clamav: false,
38
+ virustotal: false,
39
+ },
40
+ rateLimit: {
41
+ enabled: false,
42
+ maxUploads: 10,
43
+ windowMs: 60 * 1000,
44
+ },
45
+ audit: {
46
+ enabled: false,
47
+ logPath: './logs/uploads.log',
48
+ },
49
+ sanitizeFilename: true,
50
+ }
51
+
52
+ /**
53
+ * Merge user config over defaults (shallow for top-level, deep for nested objects).
54
+ * @param {object} userConfig
55
+ * @returns {object}
56
+ */
57
+ export function mergeConfig(userConfig = {}) {
58
+ return {
59
+ ...defaultConfig,
60
+ ...userConfig,
61
+ scan: { ...defaultConfig.scan, ...(userConfig.scan ?? {}) },
62
+ rateLimit: { ...defaultConfig.rateLimit, ...(userConfig.rateLimit ?? {}) },
63
+ audit: { ...defaultConfig.audit, ...(userConfig.audit ?? {}) },
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Step 1: Validate file size.
69
+ * @param {number} size - File size in bytes
70
+ * @param {number} maxFileSize - Max allowed size in bytes
71
+ */
72
+ export function checkFileSize(size, maxFileSize) {
73
+ if (size > maxFileSize) {
74
+ return failResult(
75
+ 'FILE_TOO_LARGE',
76
+ `File size ${size} bytes exceeds the limit of ${maxFileSize} bytes`
77
+ )
78
+ }
79
+ return { success: true }
80
+ }
81
+
82
+ /**
83
+ * Step 2: Validate file extension against the allowlist.
84
+ * @param {string} filename
85
+ * @param {string[]} allowedExtensions
86
+ */
87
+ export function checkExtension(filename, allowedExtensions) {
88
+ const ext = path.extname(filename).toLowerCase().replace(/^\./, '')
89
+ if (!ext) {
90
+ return failResult('INVALID_EXTENSION', 'File has no extension')
91
+ }
92
+ if (!allowedExtensions.map((e) => e.toLowerCase()).includes(ext)) {
93
+ return failResult('INVALID_EXTENSION', `Extension ".${ext}" is not allowed`)
94
+ }
95
+ return { success: true }
96
+ }
97
+
98
+ /**
99
+ * Step 3: Validate declared MIME type against the allowlist.
100
+ * @param {string} mimeType
101
+ * @param {string[]} allowedMimeTypes
102
+ */
103
+ export function checkMimeType(mimeType, allowedMimeTypes) {
104
+ if (!allowedMimeTypes.includes(mimeType)) {
105
+ return failResult('INVALID_MIME_TYPE', `MIME type "${mimeType}" is not allowed`)
106
+ }
107
+ return { success: true }
108
+ }
109
+
110
+ /**
111
+ * Run the full validation pipeline on a file.
112
+ * Order: size → extension → MIME → magic bytes → ZIP bomb → polyglot → sanitize filename
113
+ *
114
+ * @param {{ buffer: Buffer, filename: string, mimeType: string, size: number }} file
115
+ * @param {object} [config]
116
+ * @returns {Promise<{ success: true, sanitizedFilename: string } | { success: false, error: string, message: string }>}
117
+ */
118
+ export async function validateFile(file, config = {}) {
119
+ if (!file || !Buffer.isBuffer(file.buffer) || !file.filename || !file.mimeType || typeof file.size !== 'number') {
120
+ return failResult('STORAGE_ERROR', 'Invalid file input: buffer, filename, mimeType, and size are required')
121
+ }
122
+
123
+ const cfg = mergeConfig(config)
124
+ const { buffer, filename, mimeType, size } = file
125
+
126
+ const sizeCheck = checkFileSize(size, cfg.maxFileSize)
127
+ if (!sizeCheck.success) return sizeCheck
128
+
129
+ const extCheck = checkExtension(filename, cfg.allowedExtensions)
130
+ if (!extCheck.success) return extCheck
131
+
132
+ const mimeCheck = checkMimeType(mimeType, cfg.allowedMimeTypes)
133
+ if (!mimeCheck.success) return mimeCheck
134
+
135
+ const magicCheck = await validateMagicBytes(buffer, mimeType, cfg.allowedMimeTypes)
136
+ if (!magicCheck.success) return magicCheck
137
+
138
+ if (cfg.scan.zipBomb && ARCHIVE_MIME_TYPES.has(magicCheck.detectedMime)) {
139
+ const bombCheck = checkZipBomb(buffer)
140
+ if (!bombCheck.success) return bombCheck
141
+ }
142
+
143
+ if (cfg.scan.polyglot) {
144
+ const polyglotCheck = checkPolyglot(buffer)
145
+ if (!polyglotCheck.success) return polyglotCheck
146
+ }
147
+
148
+ const { sanitizedFilename } = cfg.sanitizeFilename
149
+ ? sanitizeFilename(filename)
150
+ : { sanitizedFilename: filename }
151
+
152
+ if (cfg.scan.clamav) {
153
+ const clamResult = await scanWithClamAV(buffer, cfg.clamavOptions)
154
+ if (!clamResult.success) return clamResult
155
+ }
156
+
157
+ if (cfg.scan.virustotal) {
158
+ const vtResult = await scanWithVirusTotal(buffer, cfg.virustotalOptions)
159
+ if (!vtResult.success) return vtResult
160
+ }
161
+
162
+ return { success: true, sanitizedFilename }
163
+ }
@@ -0,0 +1,43 @@
1
+ export const ERROR_CODES = {
2
+ FILE_TOO_LARGE: 'FILE_TOO_LARGE',
3
+ INVALID_EXTENSION: 'INVALID_EXTENSION',
4
+ INVALID_MIME_TYPE: 'INVALID_MIME_TYPE',
5
+ INVALID_MAGIC_BYTES: 'INVALID_MAGIC_BYTES',
6
+ ZIP_BOMB_DETECTED: 'ZIP_BOMB_DETECTED',
7
+ POLYGLOT_DETECTED: 'POLYGLOT_DETECTED',
8
+ UNSAFE_FILENAME: 'UNSAFE_FILENAME',
9
+ VIRUS_DETECTED: 'VIRUS_DETECTED',
10
+ RATE_LIMIT_EXCEEDED: 'RATE_LIMIT_EXCEEDED',
11
+ STORAGE_ERROR: 'STORAGE_ERROR',
12
+ }
13
+
14
+ export class UploadError extends Error {
15
+ /**
16
+ * @param {string} code - One of ERROR_CODES
17
+ * @param {string} message - Human-readable description
18
+ */
19
+ constructor(code, message) {
20
+ super(message)
21
+ this.name = 'UploadError'
22
+ this.code = code
23
+ }
24
+ }
25
+
26
+ /**
27
+ * Build a standard failure result object.
28
+ * @param {string} code
29
+ * @param {string} message
30
+ * @returns {{ success: false, error: string, message: string }}
31
+ */
32
+ export function failResult(code, message) {
33
+ return { success: false, error: code, message }
34
+ }
35
+
36
+ /**
37
+ * Build a standard success result object.
38
+ * @param {{ url: string, filename: string, size: number, mimeType: string, storage: string }} data
39
+ * @returns {{ success: true, data: object }}
40
+ */
41
+ export function successResult(data) {
42
+ return { success: true, data }
43
+ }
package/src/index.js ADDED
@@ -0,0 +1,65 @@
1
+ import { validateFile, mergeConfig } from './core/validator.js'
2
+ import { failResult } from './errors/UploadError.js'
3
+ import { getStorageAdapter } from './storage/index.js'
4
+ import { createRateLimiter } from './core/rateLimiter.js'
5
+ import { createLogger } from './audit/logger.js'
6
+
7
+ /**
8
+ * Create a configured fileguard instance.
9
+ * @param {object} [config] - Optional configuration overrides
10
+ * @returns {{ process: Function }}
11
+ */
12
+ export function createGuard(config = {}) {
13
+ const cfg = mergeConfig(config)
14
+ const rateLimiter = cfg.rateLimit.enabled ? createRateLimiter(cfg.rateLimit) : null
15
+ const logger = cfg.audit.enabled ? createLogger(cfg.audit) : null
16
+
17
+ return {
18
+ /**
19
+ * Validate and store a file upload end-to-end.
20
+ * @param {{ buffer: Buffer, filename: string, mimeType: string, size: number }} file
21
+ * @param {{ key?: string }} [meta] - Optional meta (e.g. user IP for rate limiting)
22
+ * @returns {Promise<{ success: boolean, data?: object, error?: string, message?: string }>}
23
+ */
24
+ async process(file, meta = {}) {
25
+ try {
26
+ if (rateLimiter) {
27
+ const rl = rateLimiter.check(meta.key ?? 'anonymous')
28
+ if (!rl.success) return rl
29
+ }
30
+
31
+ const validation = await validateFile(file, cfg)
32
+ if (!validation.success) {
33
+ await logger?.log({ event: 'upload.rejected', error: validation.error, filename: file.filename })
34
+ return validation
35
+ }
36
+
37
+ const store = await getStorageAdapter(cfg.storage)
38
+ const result = await store(
39
+ {
40
+ buffer: file.buffer,
41
+ filename: file.filename,
42
+ sanitizedFilename: validation.sanitizedFilename,
43
+ size: file.size,
44
+ mimeType: file.mimeType,
45
+ },
46
+ cfg
47
+ )
48
+
49
+ await logger?.log({
50
+ event: result.success ? 'upload.success' : 'upload.error',
51
+ filename: file.filename,
52
+ size: file.size,
53
+ storage: cfg.storage,
54
+ ...(result.success ? { url: result.data.url } : { error: result.error }),
55
+ })
56
+
57
+ return result
58
+ } catch (err) {
59
+ return failResult('STORAGE_ERROR', err.message ?? 'An unexpected error occurred')
60
+ }
61
+ },
62
+ }
63
+ }
64
+
65
+ export { createGuard as fileguard }