Using pompelmi in a Koa.js middleware pipeline
Koa's middleware model — composable async functions that pass control via
await next() — maps cleanly onto a scanning pipeline. Add an
antivirus middleware before your route handler, and any malicious upload is
rejected before business logic runs.
This guide uses koa-body for multipart parsing with disk storage, and pompelmi for ClamAV-backed scanning.
freshclam run before this will work.
See the Quickstart.
Install
npm install koa koa-body @koa/router pompelmi
koa-body setup
Configure koa-body to write multipart uploads to a directory on disk.
Without formidable.uploadDir, uploads are buffered in memory
and pompelmi would have nothing to scan.
const Koa = require('koa');
const koaBody = require('koa-body');
const os = require('os');
const app = new Koa();
app.use(koaBody({
multipart: true,
formidable: {
uploadDir: os.tmpdir(), // Write uploads to OS temp directory
keepExtensions: true, // Preserve file extension in temp name
maxFileSize: 50 * 1024 * 1024 // 50 MB limit
}
}));
Basic upload route
After koa-body runs, ctx.request.files.file contains a
formidable
File object with a .filepath property.
const { scan, Verdict } = require('pompelmi');
const fs = require('fs');
const path = require('path');
app.use(async (ctx) => {
if (ctx.method !== 'POST' || ctx.path !== '/upload') return;
const file = ctx.request.files && ctx.request.files.file;
if (!file) {
ctx.status = 400;
ctx.body = { error: 'No file provided.' };
return;
}
const tmpPath = file.filepath;
try {
const verdict = await scan(tmpPath);
if (verdict === Verdict.Malicious) {
fs.unlinkSync(tmpPath);
ctx.status = 400;
ctx.body = { error: 'Malware detected. Upload rejected.' };
return;
}
if (verdict === Verdict.ScanError) {
fs.unlinkSync(tmpPath);
ctx.status = 422;
ctx.body = { error: 'Scan incomplete. Rejected as precaution.' };
return;
}
const dest = path.join('/var/app/uploads', file.originalFilename || file.newFilename);
fs.renameSync(tmpPath, dest);
ctx.body = { status: 'ok', file: dest };
} catch (err) {
if (fs.existsSync(tmpPath)) fs.unlinkSync(tmpPath);
ctx.status = 500;
ctx.body = { error: err.message };
}
});
app.listen(3000);
Reusable scan middleware
Extract the scanning logic into a standalone Koa middleware function. This
middleware short-circuits with an error response on rejection; on a clean
result it calls await next() and stores file metadata on the
context state for downstream handlers.
// middleware/scanUpload.js
const { scan, Verdict } = require('pompelmi');
const fs = require('fs');
/**
* Koa middleware — scans ctx.request.files.file with pompelmi.
* Sets ctx.state.uploadedFile on success.
* Calls next() only when the file is Clean.
*/
async function scanUpload(ctx, next) {
const file = ctx.request.files && ctx.request.files.file;
if (!file) {
ctx.status = 400;
ctx.body = { error: 'No file provided.' };
return;
}
let verdict;
try {
verdict = await scan(file.filepath);
} catch (err) {
if (fs.existsSync(file.filepath)) fs.unlinkSync(file.filepath);
ctx.status = 500;
ctx.body = { error: 'Scan failed: ' + err.message };
return;
}
if (verdict === Verdict.Malicious) {
fs.unlinkSync(file.filepath);
ctx.status = 400;
ctx.body = { error: 'Malware detected.' };
return;
}
if (verdict === Verdict.ScanError) {
fs.unlinkSync(file.filepath);
ctx.status = 422;
ctx.body = { error: 'Scan incomplete.' };
return;
}
ctx.state.uploadedFile = file;
await next();
}
module.exports = { scanUpload };
Using with @koa/router
Apply the middleware per-route using @koa/router's route-level
middleware array.
const Router = require('@koa/router');
const { scanUpload } = require('./middleware/scanUpload');
const path = require('path');
const fs = require('fs');
const router = new Router();
router.post('/upload', scanUpload, async (ctx) => {
// scanUpload already verified the file is Clean
const file = ctx.state.uploadedFile;
const dest = path.join('/var/app/uploads', file.originalFilename || file.newFilename);
fs.renameSync(file.filepath, dest);
ctx.body = { status: 'ok', file: dest };
});
router.post('/documents', scanUpload, async (ctx) => {
const file = ctx.state.uploadedFile;
const dest = path.join('/var/app/documents', file.originalFilename || file.newFilename);
fs.renameSync(file.filepath, dest);
ctx.body = { status: 'ok', file: dest };
});
app.use(router.routes());
app.use(router.allowedMethods());
Scanning multiple files
koa-body places multiple files in an array under the field name.
Enable formidable.multiples: true and iterate:
app.use(koaBody({
multipart: true,
formidable: {
uploadDir: os.tmpdir(),
multiples: true, // Allow multiple files per field
keepExtensions: true
}
}));
router.post('/upload-many', async (ctx) => {
const rawFiles = ctx.request.files && ctx.request.files.files;
const files = Array.isArray(rawFiles) ? rawFiles : [rawFiles].filter(Boolean);
if (!files.length) {
ctx.status = 400;
ctx.body = { error: 'No files provided.' };
return;
}
const accepted = [];
for (const file of files) {
const verdict = await scan(file.filepath);
if (verdict !== Verdict.Clean) {
files.forEach(f => { try { fs.unlinkSync(f.filepath); } catch (_) {} });
ctx.status = verdict === Verdict.Malicious ? 400 : 422;
ctx.body = {
error: verdict === Verdict.Malicious
? 'Malware detected in ' + file.originalFilename
: 'Scan incomplete for ' + file.originalFilename
};
return;
}
const dest = path.join('/var/app/uploads', file.originalFilename || file.newFilename);
fs.renameSync(file.filepath, dest);
accepted.push(dest);
}
ctx.body = { status: 'ok', files: accepted };
});