Scanning multiple file uploads in a single request

Many upload interfaces let users select several files at once — a photo gallery, a batch of documents, or an email with multiple attachments. Multer supports this with upload.array() and upload.fields(). Scanning each file individually with pompelmi and running the scans concurrently keeps total latency close to the time it takes to scan the single largest file.

Multer array upload

upload.array('files', maxCount) writes each uploaded file to a temp path and populates req.files as an array of Express.Multer.File objects:

const multer = require('multer');
const os     = require('os');

const upload = multer({
  dest:   os.tmpdir(),
  limits: {
    fileSize: 20 * 1024 * 1024,  // 20 MB per file
    files:    10,                  // max 10 files per request
  },
});

Each entry in req.files has a .path property — the temp path that pompelmi will scan.

Concurrent scanning with Promise.all

Scan all files in parallel using Promise.all. Each call to scan() spawns an independent clamscan process (or opens an independent TCP connection in clamd mode), so they run concurrently:

const { scan, Verdict } = require('pompelmi');

async function scanAll(files) {
  return Promise.all(
    files.map(async (file) => {
      const verdict = await scan(file.path);
      return { file, verdict };
    })
  );
}

For 5 files each taking 300 ms to scan, concurrent scanning takes ~300 ms total instead of ~1500 ms sequential.

Promise.all spawns all scans simultaneously. On a machine with limited cores, ClamAV processes will compete for CPU. If you have dozens of files, consider a concurrency limiter (e.g. p-limit) to scan at most N files at a time.

Fail-fast vs report-all

Two strategies for handling a malicious file in a batch:

  • Fail-fast: reject the entire request as soon as any file is malicious. Simpler to implement and the safer default.
  • Report-all: scan all files, return a per-file verdict, and let the client decide what to do. Useful for developer tools or admin dashboards where the operator wants to see exactly which files were malicious.

The complete example below uses the fail-fast strategy. To switch to report-all, remove the early return and return the full results array including verdicts.

Complete Express example

const express = require('express');
const multer  = require('multer');
const { scan, Verdict } = require('pompelmi');
const fs = require('fs');
const os = require('os');

const app    = express();
const upload = multer({
  dest:   os.tmpdir(),
  limits: { fileSize: 20 * 1024 * 1024, files: 10 },
});

app.post('/upload/batch', upload.array('files', 10), async (req, res) => {
  const files = req.files;

  if (!files || files.length === 0) {
    return res.status(400).json({ error: 'No files provided.' });
  }

  const tmpPaths = files.map((f) => f.path);

  try {
    // Scan all files concurrently
    const results = await Promise.all(
      files.map(async (file) => {
        const verdict = await scan(file.path);
        return {
          originalName: file.originalname,
          verdict,
          rejected: verdict !== Verdict.Clean,
        };
      })
    );

    // Fail-fast: reject everything if any file is not clean
    const rejected = results.filter((r) => r.rejected);
    if (rejected.length > 0) {
      return res.status(400).json({
        error:    'One or more files failed scanning. All uploads rejected.',
        rejected: rejected.map((r) => ({
          name:   r.originalName,
          reason: r.verdict.description,
        })),
      });
    }

    // All clean — persist files to storage
    return res.json({
      status:   'ok',
      uploaded: results.map((r) => r.originalName),
    });

  } catch (err) {
    return res.status(500).json({ error: err.message });

  } finally {
    // Always clean up temp files
    tmpPaths.forEach((p) => {
      if (fs.existsSync(p)) fs.unlinkSync(p);
    });
  }
});

Mixed field uploads

If your form has multiple file fields with different names (e.g. a thumbnail and multiple attachments), use upload.fields():

const uploadFields = multer({ dest: os.tmpdir() }).fields([
  { name: 'thumbnail',   maxCount: 1  },
  { name: 'attachments', maxCount: 5  },
]);

app.post('/upload/post', uploadFields, async (req, res) => {
  const thumbnail   = req.files['thumbnail']   ?? [];
  const attachments = req.files['attachments'] ?? [];
  const allFiles    = [...thumbnail, ...attachments];
  const tmpPaths    = allFiles.map((f) => f.path);

  try {
    const results = await Promise.all(
      allFiles.map(async (file) => ({
        name:    file.fieldname + '/' + file.originalname,
        verdict: await scan(file.path),
      }))
    );

    const malicious = results.filter((r) => r.verdict !== Verdict.Clean);
    if (malicious.length > 0) {
      return res.status(400).json({
        error:    'Malware detected.',
        rejected: malicious.map((r) => r.name),
      });
    }

    return res.json({ status: 'ok' });

  } finally {
    tmpPaths.forEach((p) => { if (fs.existsSync(p)) fs.unlinkSync(p); });
  }
});

Next steps