controller-sets v3.3.0

Setup#

  1. Install

    npm install multer @aws-sdk/client-s3
    npm install sharp            # optional, for image compression
  2. Point it at a bucket

    Any S3-compatible storage works. Load these before handling requests — the library reads process.env but never loads .env itself.

    S3_ENDPOINT=https://s3.us-east-1.amazonaws.com
    S3_SPACES_KEY=your-access-key
    S3_SPACES_SECRET=your-secret-key
    S3_BUCKET_NAME=your-bucket
    S3_REGION=us-east-1          # optional, default us-east-1

    Per-provider values (AWS, R2, DigitalOcean, MinIO): Environment variables → S3.

  3. Pick where files arrive

    On a CRUD router, add upload (createRouter → S3 uploads). On any other route, use fileUploadMiddleware.

Files on a CRUD router#

For CRUD with files, pass upload to createRouter. The full option list, a complete example and how clients send files are on createRouter → S3 uploads.

app.use('/api/products', createRouter({
  model: Product,
  upload: { fields: [{ name: 'cover', maxCount: 1 }] },
  allowedFields: ['name', 'cover'],          // file fields must be writable
}));

Files on your own routes#

For an avatar endpoint, a file inbox, or anything that isn't CRUD, put fileUploadMiddleware in front of your handler:

import { fileUploadMiddleware } from 'express-controller-sets';

const avatarUpload = (req, res, next) =>
  fileUploadMiddleware(req, res, next, {
    uploadPath: 'avatars/',                  // note: uploadPath here, path on a router
    fields: [{ name: 'avatar', maxCount: 1 }],
    imgOptimizations: 'medium',
    acl: 'public-read',
    allowedMimeTypes: ['image/jpeg', 'image/png', 'image/webp'],
    maxFileSize: 2 * 1024 * 1024,
  });

app.post('/api/me/avatar', auth.requireAuth, avatarUpload, async (req, res) => {
  await User.updateOne({ _id: req.auth.userId }, { avatar: req.body.avatar });
  res.json({ success: true, data: { avatar: req.body.avatar } });
});
After it runsContains
req.body[field]The file URL — or an array, or { url }, by the same rules as the router.
req.file / req.filesMulter's file objects, plus key (the S3 key) and location (the URL).
other form fieldsOn req.body, as strings.

It takes the same options as a router's upload, except the folder is uploadPath instead of path.

What happens to a file#

  1. Buffered in memory

    No temp files. maxFileSize and maxFiles apply here.

  2. Identified from its bytes

    The file's signature is read; the Content-Type the client sent is ignored. Unrecognised files are a 400.

  3. Checked against allowedMimeTypes

    Renaming payload.html to photo.png changes nothing.

  4. Optionally compressed

    JPEG, PNG and WebP, if imgOptimizations is set.

  5. Uploaded with a safe key

    <path>/<timestamp>-<random>.<ext>, with the extension and Content-Type derived from the final bytes, your ACL, and Content-Disposition: attachment for anything not safe to render inline.

  6. URL placed in req.body

    The handler then saves it like any other field.

TypeAllowed by defaultServed inlineNotes
JPEG, PNG, WebP✔✔The only formats that can be compressed.
GIF, AVIF✔✔Stored untouched.
PDF✔✘Downloaded rather than rendered.
HTML, SVG, XML, JavaScript✘✘Active content: always attachment, even if you allow it.
MP4, HEIC, ZIP, MP3✘✘Recognised; allow them explicitly.
Anything unrecognised✘✘400.

Why the client's Content-Type is never trusted. An HTML file labelled image/png, stored with that label and a public ACL, renders as a page on your bucket's origin — stored cross-site scripting through your own upload form. Byte sniffing, forced attachment for active content and a private default ACL each close part of that.

Image compression#

Set a level and the image is re-encoded toward a target size, binary-searching quality (25–95, at most seven attempts). You get predictable output sizes instead of one fixed quality.

LevelTargetGood for
'low'~75–80% of the originalPhotography, product shots.
'medium' / 'med'~60–65%Avatars and content images — the usual choice.
'high'Under 1 MB: ~40–45%. Over 1 MB: from ~400–450 KB at 1 MB up to ~600–700 KB at 5 MB.Phone camera uploads.
  • JPEG, PNG and WebP are re-encoded in the same format. GIF, AVIF and PDF are uploaded as received.
  • If sharp is missing or fails, a warning is logged and the original is uploaded.
  • To compress a buffer yourself: await compressImage(buffer, 'image/jpeg', 'medium').

Keep allowClientImageOptions off on public routes. A level drives up to seven re-encodes of a multi-megabyte buffer; letting clients choose high turns an upload endpoint into CPU amplification. If enabled, the level is read from ?imgOptimizations=, the body, or the x-img-optimizations header, and is still allowlisted.

When it fails#

FailureResponse
S3 variables missing503. The server log names the missing ones.
Too large, too many files, unexpected field name400 "File upload error: …"
File type not allowed or unrecognised400
Uploaded file URL returns 403 in a browserThe object is private (the default). Serve it through presigned URLs or a CDN with origin access using file.key, or set acl: 'public-read'.

S3 configuration is read per request, not at import, so an app that loads its environment after importing the package still works.

Esc
↑ ↓ to move↵ to open