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.
- package/README.md +113 -195
- package/action/Dockerfile +24 -0
- package/action/entrypoint.sh +23 -0
- package/action/scanner.js +89 -0
- package/action.yml +29 -0
- package/llms.txt +22 -99
- package/package.json +1 -1
- package/pr_info.tmp +2 -0
- package/release-notes-v1.4.0.md +25 -0
- package/release-notes-v1.5.0.md +37 -0
- package/src/BufferScanner.js +20 -17
- package/src/ClamAVScanner.js +4 -4
- package/src/ClamdScanner.js +18 -15
- package/src/StreamScanner.js +20 -17
- package/wiki/api-reference.md +268 -0
- package/wiki/cli-usage.md +263 -0
- package/wiki/concurrent-scanning.md +199 -0
- package/wiki/docker-compose-production.md +190 -0
- package/wiki/docker-setup.md +178 -0
- package/wiki/error-handling.md +242 -0
- package/wiki/express-integration.md +227 -0
- package/wiki/fastify-integration.md +207 -0
- package/wiki/home.md +0 -0
- package/wiki/local-vs-tcp-mode.md +179 -0
- package/wiki/multer-memory-storage.md +166 -0
- package/wiki/nestjs-integration.md +228 -0
- package/wiki/nextjs-integration.md +209 -0
- package/wiki/performance.md +178 -0
- package/wiki/quarantine-workflow.md +260 -0
- package/wiki/rest-api-server.md +297 -0
- package/wiki/s3-integration.md +233 -0
- package/wiki/security-considerations.md +192 -0
- package/wiki/typescript-usage.md +239 -0
- package/wiki/verdicts.md +192 -0
- package/wiki/virus-definitions.md +194 -0
|
@@ -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
|
+
```
|