@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,85 @@
1
+ import { failResult } from '../errors/UploadError.js'
2
+
3
+ const VT_BASE = 'https://www.virustotal.com/api/v3'
4
+
5
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
6
+
7
+ /**
8
+ * Scan a buffer with the VirusTotal API v3.
9
+ *
10
+ * Behaviour when scanning is unavailable:
11
+ * - apiKey missing or empty → warn + skip (upload proceeds)
12
+ * - upload or poll failure → warn + skip (upload proceeds)
13
+ * - analysis never completes → warn + skip (upload proceeds)
14
+ *
15
+ * @param {Buffer} buffer
16
+ * @param {{ apiKey?: string, pollIntervalMs?: number, maxPolls?: number }} [config]
17
+ * @returns {Promise<{ success: true, skipped?: true } | { success: false, error: string, message: string }>}
18
+ */
19
+ export async function scanWithVirusTotal(buffer, config = {}) {
20
+ const { apiKey, pollIntervalMs = 5_000, maxPolls = 3 } = config
21
+
22
+ if (!apiKey) {
23
+ console.warn('[fileguard] VirusTotal scan skipped: no apiKey configured.')
24
+ return { success: true, skipped: true }
25
+ }
26
+
27
+ const headers = { 'x-apikey': apiKey }
28
+
29
+ // ── Upload ──────────────────────────────────────────────────────────────────
30
+ let analysisId
31
+ try {
32
+ const formData = new FormData()
33
+ formData.append('file', new Blob([buffer]), 'upload')
34
+
35
+ const uploadRes = await fetch(`${VT_BASE}/files`, {
36
+ method: 'POST',
37
+ headers,
38
+ body: formData,
39
+ })
40
+
41
+ if (!uploadRes.ok) {
42
+ const text = await uploadRes.text().catch(() => uploadRes.status)
43
+ console.warn(`[fileguard] VirusTotal upload failed (${uploadRes.status}): ${text}. Skipping scan.`)
44
+ return { success: true, skipped: true }
45
+ }
46
+
47
+ const { data } = await uploadRes.json()
48
+ analysisId = data.id
49
+ } catch (err) {
50
+ console.warn(`[fileguard] VirusTotal upload error: ${err.message}. Skipping scan.`)
51
+ return { success: true, skipped: true }
52
+ }
53
+
54
+ // ── Poll for results ────────────────────────────────────────────────────────
55
+ for (let i = 0; i < maxPolls; i++) {
56
+ try {
57
+ const res = await fetch(`${VT_BASE}/analyses/${analysisId}`, { headers })
58
+
59
+ if (res.ok) {
60
+ const { data } = await res.json()
61
+ const status = data?.attributes?.status
62
+
63
+ if (status === 'completed') {
64
+ const { malicious = 0, suspicious = 0 } = data.attributes.stats ?? {}
65
+ if (malicious > 0 || suspicious > 0) {
66
+ return failResult(
67
+ 'VIRUS_DETECTED',
68
+ `VirusTotal flagged this file (malicious: ${malicious}, suspicious: ${suspicious})`
69
+ )
70
+ }
71
+ return { success: true }
72
+ }
73
+ }
74
+ } catch (err) {
75
+ console.warn(`[fileguard] VirusTotal poll error: ${err.message}. Skipping scan.`)
76
+ return { success: true, skipped: true }
77
+ }
78
+
79
+ // Wait before next poll (skip sleep on the final iteration)
80
+ if (i < maxPolls - 1) await sleep(pollIntervalMs)
81
+ }
82
+
83
+ console.warn('[fileguard] VirusTotal analysis did not complete in time. Skipping scan.')
84
+ return { success: true, skipped: true }
85
+ }
@@ -0,0 +1,85 @@
1
+ import { failResult } from '../errors/UploadError.js'
2
+
3
+ const EOCD_SIG = 0x06054b50
4
+ const CD_SIG = 0x02014b50
5
+
6
+ /**
7
+ * Locate the End of Central Directory record by scanning backwards.
8
+ * @param {Buffer} buf
9
+ * @returns {number} byte offset, or -1 if not found
10
+ */
11
+ function findEOCD(buf) {
12
+ for (let i = buf.length - 22; i >= 0; i--) {
13
+ if (buf.readUInt32LE(i) === EOCD_SIG) return i
14
+ }
15
+ return -1
16
+ }
17
+
18
+ /**
19
+ * Walk the central directory and sum compressed/uncompressed sizes.
20
+ * @param {Buffer} buf
21
+ * @param {number} cdOffset
22
+ * @param {number} cdSize
23
+ * @returns {{ totalCompressed: number, totalUncompressed: number, fileCount: number }}
24
+ */
25
+ function parseCentralDirectory(buf, cdOffset, cdSize) {
26
+ let totalCompressed = 0
27
+ let totalUncompressed = 0
28
+ let fileCount = 0
29
+ let offset = cdOffset
30
+
31
+ while (offset < cdOffset + cdSize && offset + 46 <= buf.length) {
32
+ if (buf.readUInt32LE(offset) !== CD_SIG) break
33
+
34
+ const compressedSize = buf.readUInt32LE(offset + 20)
35
+ const uncompressedSize = buf.readUInt32LE(offset + 24)
36
+ const filenameLen = buf.readUInt16LE(offset + 28)
37
+ const extraLen = buf.readUInt16LE(offset + 30)
38
+ const commentLen = buf.readUInt16LE(offset + 32)
39
+
40
+ totalCompressed += compressedSize
41
+ totalUncompressed += uncompressedSize
42
+ fileCount++
43
+
44
+ offset += 46 + filenameLen + extraLen + commentLen
45
+ }
46
+
47
+ return { totalCompressed, totalUncompressed, fileCount }
48
+ }
49
+
50
+ /**
51
+ * Check a ZIP buffer for zip bomb patterns.
52
+ * Rejects if compression ratio > ratioThreshold or file count > maxFiles.
53
+ *
54
+ * @param {Buffer} buffer
55
+ * @param {{ ratioThreshold?: number, maxFiles?: number }} [options]
56
+ * @returns {{ success: true } | { success: false, error: string, message: string }}
57
+ */
58
+ export function checkZipBomb(buffer, options = {}) {
59
+ const { ratioThreshold = 100, maxFiles = 1000 } = options
60
+
61
+ const eocdOffset = findEOCD(buffer)
62
+ if (eocdOffset === -1) {
63
+ return failResult('ZIP_BOMB_DETECTED', 'Invalid ZIP structure: no end-of-central-directory record found')
64
+ }
65
+
66
+ const cdSize = buffer.readUInt32LE(eocdOffset + 12)
67
+ const cdOffset = buffer.readUInt32LE(eocdOffset + 16)
68
+
69
+ if (cdOffset + cdSize > buffer.length) {
70
+ return failResult('ZIP_BOMB_DETECTED', 'Invalid ZIP structure: central directory out of bounds')
71
+ }
72
+
73
+ const { totalCompressed, totalUncompressed, fileCount } = parseCentralDirectory(buffer, cdOffset, cdSize)
74
+
75
+ if (fileCount > maxFiles) {
76
+ return failResult('ZIP_BOMB_DETECTED', `ZIP contains ${fileCount} files, exceeding the limit of ${maxFiles}`)
77
+ }
78
+
79
+ if (totalCompressed > 0 && totalUncompressed / totalCompressed > ratioThreshold) {
80
+ const ratio = (totalUncompressed / totalCompressed).toFixed(1)
81
+ return failResult('ZIP_BOMB_DETECTED', `Compression ratio ${ratio}x exceeds the limit of ${ratioThreshold}x`)
82
+ }
83
+
84
+ return { success: true }
85
+ }
@@ -0,0 +1,56 @@
1
+ import { failResult, successResult } from '../errors/UploadError.js'
2
+
3
+ /**
4
+ * Upload a file to Cloudinary.
5
+ * Requires the optional peer dependency: cloudinary
6
+ *
7
+ * @param {{ buffer: Buffer, sanitizedFilename: string, size: number, mimeType: string }} file
8
+ * @param {{ cloudName: string, apiKey: string, apiSecret: string, resourceType?: string, folder?: string }} config
9
+ * @returns {Promise<{ success: true, data: object } | { success: false, error: string, message: string }>}
10
+ */
11
+ export async function cloudinaryStore(file, config = {}) {
12
+ let cloudinary
13
+ try {
14
+ const mod = await import('cloudinary')
15
+ cloudinary = mod.v2
16
+ } catch {
17
+ return failResult(
18
+ 'STORAGE_ERROR',
19
+ 'Cloudinary storage requires the cloudinary package. Install it with: npm install cloudinary'
20
+ )
21
+ }
22
+
23
+ const { cloudName, apiKey, apiSecret, resourceType = 'auto', folder } = config
24
+ if (!cloudName || !apiKey || !apiSecret) {
25
+ return failResult('STORAGE_ERROR', 'Cloudinary storage requires cloudName, apiKey, and apiSecret')
26
+ }
27
+
28
+ cloudinary.config({ cloud_name: cloudName, api_key: apiKey, api_secret: apiSecret })
29
+
30
+ try {
31
+ const uploadOptions = {
32
+ resource_type: resourceType,
33
+ use_filename: true,
34
+ unique_filename: false,
35
+ }
36
+ if (folder) uploadOptions.folder = folder
37
+
38
+ const result = await new Promise((resolve, reject) => {
39
+ const stream = cloudinary.uploader.upload_stream(uploadOptions, (err, res) => {
40
+ if (err) reject(err)
41
+ else resolve(res)
42
+ })
43
+ stream.end(file.buffer)
44
+ })
45
+
46
+ return successResult({
47
+ url: result.secure_url,
48
+ filename: file.sanitizedFilename,
49
+ size: file.size,
50
+ mimeType: file.mimeType,
51
+ storage: 'cloudinary',
52
+ })
53
+ } catch (err) {
54
+ return failResult('STORAGE_ERROR', `Cloudinary upload failed: ${err.message}`)
55
+ }
56
+ }
@@ -0,0 +1,27 @@
1
+ export { localStore } from './local.js'
2
+ export { s3Store } from './s3.js'
3
+ export { cloudinaryStore } from './cloudinary.js'
4
+
5
+ /**
6
+ * Return the storage adapter function for the given storage type string.
7
+ * @param {'local'|'s3'|'cloudinary'} type
8
+ * @returns {Function}
9
+ */
10
+ export async function getStorageAdapter(type) {
11
+ switch (type) {
12
+ case 'local': {
13
+ const { localStore } = await import('./local.js')
14
+ return localStore
15
+ }
16
+ case 's3': {
17
+ const { s3Store } = await import('./s3.js')
18
+ return s3Store
19
+ }
20
+ case 'cloudinary': {
21
+ const { cloudinaryStore } = await import('./cloudinary.js')
22
+ return cloudinaryStore
23
+ }
24
+ default:
25
+ throw new Error(`Unknown storage adapter: "${type}". Valid values: local, s3, cloudinary`)
26
+ }
27
+ }
@@ -0,0 +1,39 @@
1
+ import { writeFile, mkdir } from 'fs/promises'
2
+ import path from 'path'
3
+ import { v4 as uuidv4 } from 'uuid'
4
+ import { failResult, successResult } from '../errors/UploadError.js'
5
+
6
+ /**
7
+ * Store a file to the local filesystem.
8
+ *
9
+ * @param {{ buffer: Buffer, sanitizedFilename?: string, filename: string, size: number, mimeType: string }} file
10
+ * @param {{ localPath?: string }} [config]
11
+ * @returns {Promise<{ success: true, data: object } | { success: false, error: string, message: string }>}
12
+ */
13
+ export async function localStore(file, config = {}) {
14
+ const { localPath = './uploads' } = config
15
+ const ext = path.extname(file.filename).toLowerCase()
16
+ const filename = file.sanitizedFilename ?? `${uuidv4()}${ext}`
17
+ const destDir = path.resolve(localPath)
18
+ const destPath = path.join(destDir, filename)
19
+
20
+ // Guard against path traversal: sanitizedFilename must stay under destDir
21
+ if (!destPath.startsWith(destDir + path.sep) && destPath !== destDir) {
22
+ return failResult('STORAGE_ERROR', 'Path traversal detected in filename')
23
+ }
24
+
25
+ try {
26
+ await mkdir(destDir, { recursive: true })
27
+ await writeFile(destPath, file.buffer)
28
+
29
+ return successResult({
30
+ url: destPath,
31
+ filename,
32
+ size: file.size,
33
+ mimeType: file.mimeType,
34
+ storage: 'local',
35
+ })
36
+ } catch (err) {
37
+ return failResult('STORAGE_ERROR', `Local storage failed: ${err.message}`)
38
+ }
39
+ }
@@ -0,0 +1,62 @@
1
+ import { failResult, successResult } from '../errors/UploadError.js'
2
+
3
+ /**
4
+ * Store a file to AWS S3 (or an S3-compatible service).
5
+ * Requires the optional peer dependency: @aws-sdk/client-s3
6
+ *
7
+ * @param {{ buffer: Buffer, sanitizedFilename: string, size: number, mimeType: string }} file
8
+ * @param {{ bucket: string, region?: string, prefix?: string, endpoint?: string }} config
9
+ * @returns {Promise<{ success: true, data: object } | { success: false, error: string, message: string }>}
10
+ */
11
+ export async function s3Store(file, config = {}) {
12
+ let S3Client, PutObjectCommand
13
+ try {
14
+ const sdk = await import('@aws-sdk/client-s3')
15
+ S3Client = sdk.S3Client
16
+ PutObjectCommand = sdk.PutObjectCommand
17
+ } catch {
18
+ return failResult(
19
+ 'STORAGE_ERROR',
20
+ 'S3 storage requires @aws-sdk/client-s3. Install it with: npm install @aws-sdk/client-s3'
21
+ )
22
+ }
23
+
24
+ const { bucket, region = 'us-east-1', prefix = '', endpoint } = config
25
+ if (!bucket) {
26
+ return failResult('STORAGE_ERROR', 'S3 storage requires a bucket name in config')
27
+ }
28
+
29
+ const filename = file.sanitizedFilename
30
+ const key = prefix ? `${prefix}/${filename}` : filename
31
+
32
+ const clientOptions = { region }
33
+ if (endpoint) clientOptions.endpoint = endpoint
34
+
35
+ const client = new S3Client(clientOptions)
36
+
37
+ try {
38
+ await client.send(
39
+ new PutObjectCommand({
40
+ Bucket: bucket,
41
+ Key: key,
42
+ Body: file.buffer,
43
+ ContentType: file.mimeType,
44
+ ContentLength: file.size,
45
+ })
46
+ )
47
+
48
+ const baseUrl = endpoint
49
+ ? `${endpoint.replace(/\/$/, '')}/${bucket}/${key}`
50
+ : `https://${bucket}.s3.${region}.amazonaws.com/${key}`
51
+
52
+ return successResult({
53
+ url: baseUrl,
54
+ filename,
55
+ size: file.size,
56
+ mimeType: file.mimeType,
57
+ storage: 's3',
58
+ })
59
+ } catch (err) {
60
+ return failResult('STORAGE_ERROR', `S3 upload failed: ${err.message}`)
61
+ }
62
+ }