pompelmi 1.5.0 → 1.7.0

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,166 @@
1
+ # multer Memory Storage
2
+
3
+ When you use `multer({ storage: multer.memoryStorage() })`, the uploaded file is never written to disk. It lives entirely in `req.file.buffer` as a Node.js `Buffer`. This page explains why `scan()` does not work in this case and how `scanBuffer()` solves it.
4
+
5
+ ---
6
+
7
+ ## Why `scan()` doesn't work with memoryStorage
8
+
9
+ `scan()` requires a file path — it calls `clamscan <filePath>` or streams the file at that path to clamd. With `memoryStorage`, there is no path:
10
+
11
+ ```js
12
+ const upload = multer({ storage: multer.memoryStorage() });
13
+
14
+ app.post('/upload', upload.single('file'), async (req, res) => {
15
+ console.log(req.file.path); // undefined — no file on disk
16
+ console.log(req.file.buffer); // <Buffer 25 50 44 ...> — file is here
17
+
18
+ // This throws "filePath must be a string"
19
+ await scan(req.file.path);
20
+ });
21
+ ```
22
+
23
+ ---
24
+
25
+ ## Use `scanBuffer()` instead
26
+
27
+ ```js
28
+ const express = require('express');
29
+ const multer = require('multer');
30
+ const { scanBuffer, Verdict } = require('pompelmi');
31
+
32
+ const app = express();
33
+ const upload = multer({
34
+ storage: multer.memoryStorage(),
35
+ limits: { fileSize: 10 * 1024 * 1024 }, // 10 MB
36
+ });
37
+
38
+ const SCAN_OPTS = {
39
+ host: process.env.CLAMAV_HOST,
40
+ port: Number(process.env.CLAMAV_PORT) || 3310,
41
+ timeout: 30_000,
42
+ };
43
+
44
+ app.post('/upload', upload.single('file'), async (req, res) => {
45
+ if (!req.file) {
46
+ return res.status(400).json({ error: 'No file uploaded.' });
47
+ }
48
+
49
+ let result;
50
+ try {
51
+ result = await scanBuffer(req.file.buffer, SCAN_OPTS);
52
+ } catch (err) {
53
+ return res.status(500).json({ error: `Scan failed: ${err.message}` });
54
+ }
55
+
56
+ if (result === Verdict.Malicious) {
57
+ return res.status(422).json({ error: 'Malicious file rejected.' });
58
+ }
59
+
60
+ if (result === Verdict.ScanError) {
61
+ return res.status(422).json({ error: 'Scan incomplete — file rejected.' });
62
+ }
63
+
64
+ // Verdict.Clean — buffer is available for forwarding to S3, DB, etc.
65
+ return res.json({ ok: true, size: req.file.size });
66
+ });
67
+
68
+ app.listen(3000);
69
+ ```
70
+
71
+ ---
72
+
73
+ ## How `scanBuffer()` works in each mode
74
+
75
+ | Mode | Behaviour |
76
+ |------|-----------|
77
+ | **TCP** (`host` set) | Buffer is streamed directly to clamd via INSTREAM — zero disk I/O on the application host. |
78
+ | **Local** (no `host`) | Buffer is written to a temp file in `os.tmpdir()`, scanned with `clamscan`, then deleted in a `finally` block. |
79
+
80
+ For `memoryStorage` workloads, TCP mode is strongly recommended: the whole point of keeping the file in memory is to avoid touching disk, and TCP mode preserves that guarantee.
81
+
82
+ ---
83
+
84
+ ## Multiple files with `upload.array()`
85
+
86
+ ```js
87
+ app.post('/upload-many', upload.array('files', 10), async (req, res) => {
88
+ if (!req.files || req.files.length === 0) {
89
+ return res.status(400).json({ error: 'No files uploaded.' });
90
+ }
91
+
92
+ const scanResults = await Promise.allSettled(
93
+ req.files.map(async (file) => {
94
+ const verdict = await scanBuffer(file.buffer, SCAN_OPTS);
95
+ return { originalname: file.originalname, verdict };
96
+ })
97
+ );
98
+
99
+ const accepted = [];
100
+ const rejected = [];
101
+
102
+ for (const r of scanResults) {
103
+ if (r.status === 'rejected') {
104
+ rejected.push({ originalname: '?', reason: 'scan_failed' });
105
+ continue;
106
+ }
107
+ const { originalname, verdict } = r.value;
108
+ if (verdict === Verdict.Clean) {
109
+ accepted.push(originalname);
110
+ } else {
111
+ rejected.push({ originalname, reason: verdict.description });
112
+ }
113
+ }
114
+
115
+ if (rejected.length > 0) {
116
+ return res.status(422).json({ accepted, rejected });
117
+ }
118
+ return res.json({ ok: true, accepted });
119
+ });
120
+ ```
121
+
122
+ ---
123
+
124
+ ## Memory usage considerations
125
+
126
+ With `memoryStorage`, every uploaded file occupies memory for the duration of the request. For large files or high concurrency, this can exhaust the Node.js heap. Options:
127
+
128
+ 1. **Set a file size limit** — always set `limits.fileSize` on multer.
129
+ 2. **Use disk storage for large files** — fall back to `diskStorage` for files above a threshold.
130
+ 3. **Use `scanStream()` instead** — pipe the multipart stream directly to `scanStream()` without buffering the entire file. This requires bypassing multer and parsing multipart manually (e.g. with `busboy`).
131
+
132
+ ```js
133
+ // scanStream with busboy — no full buffering
134
+ const busboy = require('busboy');
135
+
136
+ app.post('/upload-stream', (req, res) => {
137
+ const bb = busboy({ headers: req.headers });
138
+
139
+ bb.on('file', async (_name, fileStream, _info) => {
140
+ const result = await scanStream(fileStream, SCAN_OPTS);
141
+
142
+ if (result !== Verdict.Clean) {
143
+ return res.status(422).json({ error: result.description });
144
+ }
145
+ return res.json({ ok: true });
146
+ });
147
+
148
+ req.pipe(bb);
149
+ });
150
+ ```
151
+
152
+ ---
153
+
154
+ ## After scanning: forward to S3
155
+
156
+ ```js
157
+ const { PutObjectCommand } = require('@aws-sdk/client-s3');
158
+
159
+ // After confirmed Verdict.Clean
160
+ await s3.send(new PutObjectCommand({
161
+ Bucket: process.env.S3_BUCKET,
162
+ Key: `uploads/${Date.now()}-${req.file.originalname}`,
163
+ Body: req.file.buffer,
164
+ ContentType: req.file.mimetype,
165
+ }));
166
+ ```
@@ -0,0 +1,228 @@
1
+ # NestJS Integration
2
+
3
+ Integrate pompelmi into a NestJS application using a custom `PipeTransform` or `Interceptor` to scan uploaded files before they reach your controller.
4
+
5
+ ---
6
+
7
+ ## Setup
8
+
9
+ ```bash
10
+ npm install pompelmi @nestjs/platform-express multer
11
+ npm install -D @types/multer
12
+ ```
13
+
14
+ ---
15
+
16
+ ## Custom pipe: `FileScanPipe`
17
+
18
+ A `PipeTransform` that receives the `Express.Multer.File` object from `FileInterceptor` and rejects it if ClamAV detects malware. Throw `BadRequestException` or `UnprocessableEntityException` as appropriate.
19
+
20
+ ```ts
21
+ // file-scan.pipe.ts
22
+ import { PipeTransform, Injectable, BadRequestException } from '@nestjs/common';
23
+ import { scan, Verdict } from 'pompelmi';
24
+
25
+ @Injectable()
26
+ export class FileScanPipe implements PipeTransform {
27
+ private readonly opts = {
28
+ host: process.env.CLAMAV_HOST,
29
+ port: Number(process.env.CLAMAV_PORT) || 3310,
30
+ timeout: 30_000,
31
+ };
32
+
33
+ async transform(file: Express.Multer.File): Promise<Express.Multer.File> {
34
+ if (!file) throw new BadRequestException('No file uploaded.');
35
+
36
+ let result: symbol;
37
+ try {
38
+ result = await scan(file.path, this.opts);
39
+ } catch (err) {
40
+ throw new BadRequestException(`Scan failed: ${(err as Error).message}`);
41
+ }
42
+
43
+ if (result === Verdict.Malicious) {
44
+ throw new BadRequestException('Malicious file rejected.');
45
+ }
46
+
47
+ if (result === Verdict.ScanError) {
48
+ throw new BadRequestException('Scan incomplete — file rejected.');
49
+ }
50
+
51
+ return file;
52
+ }
53
+ }
54
+ ```
55
+
56
+ ---
57
+
58
+ ## Using the pipe in a controller
59
+
60
+ ```ts
61
+ // upload.controller.ts
62
+ import {
63
+ Controller,
64
+ Post,
65
+ UploadedFile,
66
+ UseInterceptors,
67
+ } from '@nestjs/common';
68
+ import { FileInterceptor } from '@nestjs/platform-express';
69
+ import { FileScanPipe } from './file-scan.pipe';
70
+ import * as diskStorage from 'multer';
71
+
72
+ @Controller('upload')
73
+ export class UploadController {
74
+ @Post()
75
+ @UseInterceptors(
76
+ FileInterceptor('file', {
77
+ dest: './uploads',
78
+ limits: { fileSize: 10 * 1024 * 1024 },
79
+ }),
80
+ )
81
+ async uploadFile(
82
+ @UploadedFile(FileScanPipe) file: Express.Multer.File,
83
+ ) {
84
+ return { ok: true, filename: file.filename };
85
+ }
86
+ }
87
+ ```
88
+
89
+ `FileInterceptor` writes the file to `dest` before the pipe runs, so `file.path` is available.
90
+
91
+ ---
92
+
93
+ ## Memory storage with `scanBuffer()`
94
+
95
+ If you use `multer.memoryStorage()` the file is in `file.buffer`. Update the pipe:
96
+
97
+ ```ts
98
+ // file-scan-buffer.pipe.ts
99
+ import { PipeTransform, Injectable, BadRequestException } from '@nestjs/common';
100
+ import { scanBuffer, Verdict } from 'pompelmi';
101
+
102
+ @Injectable()
103
+ export class FileScanBufferPipe implements PipeTransform {
104
+ private readonly opts = {
105
+ host: process.env.CLAMAV_HOST,
106
+ port: Number(process.env.CLAMAV_PORT) || 3310,
107
+ };
108
+
109
+ async transform(file: Express.Multer.File): Promise<Express.Multer.File> {
110
+ if (!file) throw new BadRequestException('No file uploaded.');
111
+
112
+ const result = await scanBuffer(file.buffer, this.opts).catch((err) => {
113
+ throw new BadRequestException(`Scan failed: ${err.message}`);
114
+ });
115
+
116
+ if (result !== Verdict.Clean) {
117
+ throw new BadRequestException(`Upload rejected: ${result.description}`);
118
+ }
119
+
120
+ return file;
121
+ }
122
+ }
123
+ ```
124
+
125
+ Controller stays the same; swap `FileScanPipe` for `FileScanBufferPipe`:
126
+
127
+ ```ts
128
+ @UseInterceptors(
129
+ FileInterceptor('file', {
130
+ storage: multer.memoryStorage(),
131
+ limits: { fileSize: 10 * 1024 * 1024 },
132
+ }),
133
+ )
134
+ async uploadFile(@UploadedFile(FileScanBufferPipe) file: Express.Multer.File) {
135
+ // file.buffer is the scanned content
136
+ return { ok: true, size: file.size };
137
+ }
138
+ ```
139
+
140
+ ---
141
+
142
+ ## Module setup
143
+
144
+ Register the pipe as a provider if you want it injectable via DI:
145
+
146
+ ```ts
147
+ // upload.module.ts
148
+ import { Module } from '@nestjs/common';
149
+ import { UploadController } from './upload.controller';
150
+ import { FileScanPipe } from './file-scan.pipe';
151
+
152
+ @Module({
153
+ controllers: [UploadController],
154
+ providers: [FileScanPipe],
155
+ })
156
+ export class UploadModule {}
157
+ ```
158
+
159
+ Or use it directly inline without DI — the `new FileScanPipe()` form works equally well:
160
+
161
+ ```ts
162
+ @UploadedFile(new FileScanPipe())
163
+ ```
164
+
165
+ ---
166
+
167
+ ## Interceptor approach
168
+
169
+ An `NestInterceptor` runs around the entire handler. Use this if you want to clean up the file after the handler completes regardless of outcome.
170
+
171
+ ```ts
172
+ // scan.interceptor.ts
173
+ import {
174
+ Injectable,
175
+ NestInterceptor,
176
+ ExecutionContext,
177
+ CallHandler,
178
+ BadRequestException,
179
+ } from '@nestjs/common';
180
+ import { Observable } from 'rxjs';
181
+ import { scan, Verdict } from 'pompelmi';
182
+
183
+ @Injectable()
184
+ export class ScanInterceptor implements NestInterceptor {
185
+ async intercept(ctx: ExecutionContext, next: CallHandler): Promise<Observable<any>> {
186
+ const req = ctx.switchToHttp().getRequest();
187
+ const file: Express.Multer.File | undefined = req.file;
188
+
189
+ if (!file) return next.handle();
190
+
191
+ const result = await scan(file.path, {
192
+ host: process.env.CLAMAV_HOST,
193
+ port: 3310,
194
+ });
195
+
196
+ if (result !== Verdict.Clean) {
197
+ throw new BadRequestException(`Upload rejected: ${result.description}`);
198
+ }
199
+
200
+ return next.handle();
201
+ }
202
+ }
203
+ ```
204
+
205
+ Apply it at the controller or handler level:
206
+
207
+ ```ts
208
+ @UseInterceptors(FileInterceptor('file', { dest: './uploads' }), ScanInterceptor)
209
+ @Post()
210
+ async uploadFile(@UploadedFile() file: Express.Multer.File) {
211
+ return { ok: true, filename: file.filename };
212
+ }
213
+ ```
214
+
215
+ ---
216
+
217
+ ## Cleanup on rejection
218
+
219
+ When the file is rejected, delete it to avoid accumulating rejected uploads on disk:
220
+
221
+ ```ts
222
+ import * as fs from 'fs';
223
+
224
+ if (result !== Verdict.Clean) {
225
+ try { fs.unlinkSync(file.path); } catch {}
226
+ throw new BadRequestException('Malicious file rejected.');
227
+ }
228
+ ```
@@ -0,0 +1,209 @@
1
+ # Next.js Integration
2
+
3
+ Scan uploaded files in Next.js API routes before writing them to disk or forwarding them to S3. Covers both the App Router (Next.js 13+) and the Pages Router.
4
+
5
+ ---
6
+
7
+ ## App Router (Next.js 13+)
8
+
9
+ ### Scan from `formData()` — scan buffer, upload to S3 if clean
10
+
11
+ In the App Router, request bodies are parsed via the Web Fetch API. Use `request.formData()` to get the file as a `Blob`, convert it to a Node.js `Buffer`, then call `scanBuffer()`.
12
+
13
+ ```ts
14
+ // app/api/upload/route.ts
15
+ import { NextRequest, NextResponse } from 'next/server';
16
+ import { scanBuffer, Verdict } from 'pompelmi';
17
+ import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
18
+
19
+ const s3 = new S3Client({ region: process.env.AWS_REGION });
20
+
21
+ const SCAN_OPTS = {
22
+ host: process.env.CLAMAV_HOST,
23
+ port: Number(process.env.CLAMAV_PORT) || 3310,
24
+ timeout: 30_000,
25
+ };
26
+
27
+ export async function POST(request: NextRequest) {
28
+ const form = await request.formData();
29
+ const file = form.get('file');
30
+
31
+ if (!(file instanceof File)) {
32
+ return NextResponse.json({ error: 'No file uploaded.' }, { status: 400 });
33
+ }
34
+
35
+ const arrayBuffer = await file.arrayBuffer();
36
+ const buffer = Buffer.from(arrayBuffer);
37
+
38
+ let result: symbol;
39
+ try {
40
+ result = await scanBuffer(buffer, SCAN_OPTS);
41
+ } catch (err) {
42
+ return NextResponse.json(
43
+ { error: `Scan failed: ${(err as Error).message}` },
44
+ { status: 500 }
45
+ );
46
+ }
47
+
48
+ if (result === Verdict.Malicious) {
49
+ return NextResponse.json({ error: 'Malicious file rejected.' }, { status: 422 });
50
+ }
51
+
52
+ if (result === Verdict.ScanError) {
53
+ return NextResponse.json(
54
+ { error: 'Scan incomplete — file rejected as precaution.' },
55
+ { status: 422 }
56
+ );
57
+ }
58
+
59
+ // Upload to S3 only after a clean scan
60
+ await s3.send(new PutObjectCommand({
61
+ Bucket: process.env.S3_BUCKET,
62
+ Key: `uploads/${Date.now()}-${file.name}`,
63
+ Body: buffer,
64
+ ContentType: file.type,
65
+ }));
66
+
67
+ return NextResponse.json({ ok: true });
68
+ }
69
+ ```
70
+
71
+ ### Disable Next.js body parsing
72
+
73
+ By default, Next.js App Router does not parse multipart bodies — the `request.formData()` call handles it natively. No special config needed.
74
+
75
+ ---
76
+
77
+ ## Pages Router
78
+
79
+ ### With `formidable` — scan by file path
80
+
81
+ The Pages Router does not handle multipart natively. Use `formidable` to parse the upload, then scan the temp file path.
82
+
83
+ ```bash
84
+ npm install pompelmi formidable
85
+ npm install -D @types/formidable
86
+ ```
87
+
88
+ ```ts
89
+ // pages/api/upload.ts
90
+ import type { NextApiRequest, NextApiResponse } from 'next';
91
+ import formidable, { File as FormidableFile } from 'formidable';
92
+ import fs from 'fs';
93
+ import { scan, Verdict } from 'pompelmi';
94
+
95
+ export const config = {
96
+ api: { bodyParser: false }, // required for multipart
97
+ };
98
+
99
+ const SCAN_OPTS = {
100
+ host: process.env.CLAMAV_HOST,
101
+ port: Number(process.env.CLAMAV_PORT) || 3310,
102
+ };
103
+
104
+ function parseForm(req: NextApiRequest): Promise<{ file: FormidableFile }> {
105
+ return new Promise((resolve, reject) => {
106
+ const form = formidable({ uploadDir: '/tmp', keepExtensions: true });
107
+ form.parse(req, (err, _fields, files) => {
108
+ if (err) return reject(err);
109
+ const file = Array.isArray(files.file) ? files.file[0] : files.file;
110
+ if (!file) return reject(new Error('No file.'));
111
+ resolve({ file });
112
+ });
113
+ });
114
+ }
115
+
116
+ export default async function handler(req: NextApiRequest, res: NextApiResponse) {
117
+ if (req.method !== 'POST') return res.status(405).end();
118
+
119
+ let file: FormidableFile;
120
+ try {
121
+ ({ file } = await parseForm(req));
122
+ } catch (err) {
123
+ return res.status(400).json({ error: 'Upload failed.' });
124
+ }
125
+
126
+ const filePath = file.filepath;
127
+
128
+ try {
129
+ const result = await scan(filePath, SCAN_OPTS);
130
+
131
+ if (result !== Verdict.Clean) {
132
+ fs.unlinkSync(filePath);
133
+ return res.status(422).json({ error: `Upload rejected: ${result.description}` });
134
+ }
135
+
136
+ // Move or store the clean file
137
+ return res.status(200).json({ ok: true });
138
+ } catch (err) {
139
+ try { fs.unlinkSync(filePath); } catch {}
140
+ return res.status(500).json({ error: `Scan failed: ${(err as Error).message}` });
141
+ }
142
+ }
143
+ ```
144
+
145
+ ---
146
+
147
+ ## Client-side upload
148
+
149
+ ```tsx
150
+ // components/UploadForm.tsx
151
+ 'use client';
152
+ import { useState } from 'react';
153
+
154
+ export default function UploadForm() {
155
+ const [status, setStatus] = useState('');
156
+
157
+ async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
158
+ e.preventDefault();
159
+ const form = e.currentTarget;
160
+ const data = new FormData(form);
161
+
162
+ const res = await fetch('/api/upload', { method: 'POST', body: data });
163
+ const json = await res.json();
164
+
165
+ if (!res.ok) {
166
+ setStatus(`Error: ${json.error}`);
167
+ } else {
168
+ setStatus('File uploaded successfully.');
169
+ }
170
+ }
171
+
172
+ return (
173
+ <form onSubmit={handleSubmit}>
174
+ <input type="file" name="file" required />
175
+ <button type="submit">Upload</button>
176
+ {status && <p>{status}</p>}
177
+ </form>
178
+ );
179
+ }
180
+ ```
181
+
182
+ ---
183
+
184
+ ## Environment variables
185
+
186
+ Add to `.env.local`:
187
+
188
+ ```
189
+ CLAMAV_HOST=127.0.0.1
190
+ CLAMAV_PORT=3310
191
+ S3_BUCKET=my-upload-bucket
192
+ AWS_REGION=us-east-1
193
+ ```
194
+
195
+ In production (Docker), set `CLAMAV_HOST` to the clamd service name (e.g. `clamav`).
196
+
197
+ ---
198
+
199
+ ## Notes
200
+
201
+ - **Vercel / serverless:** ClamAV cannot be installed on Vercel's serverless functions. Use TCP mode pointing to a self-hosted clamd instance (fly.io, Railway, EC2) or switch to a dedicated scan microservice.
202
+ - **File size limits:** Next.js has a default request body size limit (4 MB for Pages Router). Increase it via `export const config = { api: { bodyParser: { sizeLimit: '20mb' } } }` or disable parsing for multipart routes.
203
+ - **App Router streaming:** The App Router supports streaming request bodies via `request.body` (`ReadableStream`). To use `scanStream()`, convert with `Readable.fromWeb(request.body)` (Node.js 18+).
204
+
205
+ ```ts
206
+ import { Readable } from 'stream';
207
+ const nodeStream = Readable.fromWeb(request.body as ReadableStream);
208
+ const result = await scanStream(nodeStream, SCAN_OPTS);
209
+ ```