Nik-Blog , Typescript

08: Image uploads to S3 — least-privilege IAM, memory-buffered uploads

08: Image uploads to S3 — least-privilege IAM, memory-buffered uploads

What this does

Admin-only image uploads for projects and notes: a file picked in the admin form goes straight to a private S3 bucket (nikblog-images, publicly readable objects only), and the resulting URL is stored on the project/note row.

backend/services/s3Service.js        — uploadImage(), deleteImageByUrl()
backend/middleware/uploadImage.js    — multer config: memory storage, type/size limits
backend/controllers/adminController.js — uploadImage handler
backend/routes/admin.js              — POST /admin/upload, with multer error handling
frontend/components/NoteForm.tsx     — file input, uploads immediately on choose, shows a preview

The S3 setup itself (AWS console work, not code)

Why multer.memoryStorage(), not disk storage

const upload = multer({
  storage: multer.memoryStorage(),
  limits: { fileSize: 5 * 1024 * 1024 },
  fileFilter(req, file, cb) { ... },
});

memoryStorage keeps the uploaded file as a Buffer in RAM (req.file.buffer) instead of writing it to a temp file on disk first. At this scale (small images, low traffic) that's simpler and faster — s3Service.uploadImage hands the buffer straight to S3's PutObjectCommand with no temp file to create or clean up afterward. Disk storage would matter at a much larger scale (huge files, needing to stream rather than hold everything in memory at once); not a concern here.

Random filenames, not the original one

function randomFileName(originalName) {
  const ext = originalName.includes('.') ? originalName.split('.').pop() : '';
  const random = crypto.randomBytes(16).toString('hex');
  return ext ? `${random}.${ext}` : random;
}

Two reasons: collision avoidance (two different uploads both named photo.jpg would otherwise overwrite each other in S3), and not leaking the uploader's original local filename onto a public URL.

Handling a multer error without a broken response

function handleUpload(req, res, next) {
  uploadImage(req, res, (err) => {
    if (err) {
      return res.status(400).json({ status: 'error', message: err.message });
    }
    next();
  });
}

When fileFilter rejects a file (wrong type) or the file exceeds the size limit, multer calls next(err) rather than throwing — left alone, that error falls through to Express's default error handler, which renders an HTML error page. Every other endpoint in this API returns JSON, so this wrapper catches that error explicitly and turns it into the same { status: 'error', message } shape as everywhere else.

FormData, not JSON, for the upload request

const formData = new FormData();
formData.append('image', file);

const res = await fetch(`${API_URL}/admin/upload`, {
  method: 'POST',
  credentials: 'include',
  body: formData,
});

No Content-Type header is set manually here — the browser sets multipart/form-data with a random boundary string itself, and that boundary is required for the backend (multer) to correctly split the request body back into fields and file data. Setting the header by hand would omit the boundary and break parsing entirely.

Verified live

One-line interview answer

"The upload flow uses a dedicated IAM user scoped to exactly PutObject/DeleteObject on one bucket — not a broad managed policy — so a leaked key's blast radius is capped at that one bucket. Files are buffered in memory with multer rather than written to disk first, since nothing here needs to stream huge files, and get a random filename to avoid collisions and avoid leaking the uploader's original filename."