File Uploads — Multer, Cloud Storage, and Angular Progress Tracking

File uploads are a fundamental feature of task management applications — attaching screenshots to bug reports, uploading design files to creative tasks, or adding PDFs to project tasks. The full-stack upload flow involves three participants: the Angular client that creates a FormData object and tracks progress, the Express API that receives and processes the file with Multer, and a storage backend (local disk in development, cloud storage in production). Getting this integration right — with progress indicators, validation, security, and proper error handling — creates a polished experience that users notice.

File Upload Architecture

Layer Responsibility Key Technology
Angular component File input, preview, progress bar, drag-and-drop zone FormData, HttpRequest with reportProgress
Angular interceptor Skip JSON content-type header for multipart uploads HttpRequest detection
Express Multer Parse multipart/form-data, validate file type/size multer with storage engine
Storage Save file — disk in dev, S3/Cloudinary in production Multer DiskStorage or multer-s3
Database Store file metadata (URL, name, size, MIME type) Task.attachments array
Note: When uploading files with FormData, do NOT set a Content-Type: application/json header — Angular’s HttpClient interceptors may set it by default. The browser automatically sets Content-Type: multipart/form-data; boundary=... when you pass a FormData object. If you manually set Content-Type, the boundary is missing and the server cannot parse the file. If your auth interceptor adds JSON content type, detect FormData uploads and skip that header.
Tip: Generate a unique filename server-side using crypto.randomUUID() or Date.now() + '-' + Math.random() — never trust the client-provided filename. User-provided filenames may contain path traversal characters (../../../etc/passwd), special characters that break shell commands, or simply collide with existing files. Preserve the original filename only as display metadata in the database — use the UUID as the actual storage key.
Warning: Always validate file types on the server — never trust the client-side type check or the file extension. A malicious user can rename a PHP script to image.jpg and bypass extension checks. Use Multer’s fileFilter to check file.mimetype (which is read from the HTTP headers, not the file content). For stronger validation, use the file-type npm package to read the actual file magic bytes — the first few bytes that identify the true file format.

Complete File Upload Implementation

// ── Express: Multer configuration ────────────────────────────────────────
const multer = require('multer');
const path   = require('path');
const crypto = require('crypto');

// Development: disk storage
const diskStorage = multer.diskStorage({
    destination: (req, file, cb) => cb(null, 'uploads/'),
    filename:    (req, file, cb) => {
        const ext   = path.extname(file.originalname).toLowerCase();
        const name  = crypto.randomUUID();
        cb(null, `${name}${ext}`);
    },
});

// Production: memory storage (then upload to S3/Cloudinary)
const memoryStorage = multer.memoryStorage();

const ALLOWED_MIME_TYPES = [
    'image/jpeg', 'image/png', 'image/webp', 'image/gif',
    'application/pdf',
    'text/plain', 'text/csv',
    'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
    'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
];

const upload = multer({
    storage:   process.env.NODE_ENV === 'production' ? memoryStorage : diskStorage,
    limits:    { fileSize: 10 * 1024 * 1024 },   // 10 MB max
    fileFilter: (req, file, cb) => {
        if (ALLOWED_MIME_TYPES.includes(file.mimetype)) {
            cb(null, true);
        } else {
            cb(new Error(`File type ${file.mimetype} is not allowed`), false);
        }
    },
});

// ── Upload route ──────────────────────────────────────────────────────────
const router = require('express').Router();
const { uploadToCloudinary } = require('../services/storage.service');

router.post(
    '/:taskId/attachments',
    verifyAccessToken,
    upload.single('file'),   // 'file' matches the FormData field name
    asyncHandler(async (req, res) => {
        if (!req.file) {
            return res.status(400).json({ message: 'No file uploaded' });
        }

        // Verify task ownership
        const task = await Task.findOne({ _id: req.params.taskId, user: req.user.sub });
        if (!task) return res.status(404).json({ message: 'Task not found' });

        // Check attachment limit
        if (task.attachments.length >= 10) {
            return res.status(400).json({ message: 'Maximum 10 attachments per task' });
        }

        let url;
        if (process.env.NODE_ENV === 'production') {
            // Upload buffer to Cloudinary
            url = await uploadToCloudinary(req.file.buffer, {
                folder:   `task-attachments/${req.params.taskId}`,
                public_id: crypto.randomUUID(),
            });
        } else {
            url = `/uploads/${req.file.filename}`;
        }

        const attachment = {
            filename:  req.file.originalname,
            url,
            size:      req.file.size,
            mimeType:  req.file.mimetype,
            uploadedAt:new Date(),
        };

        task.attachments.push(attachment);
        await task.save();

        res.status(201).json({ success: true, data: attachment });
    })
);

// Handle multer errors
router.use((err, req, res, next) => {
    if (err instanceof multer.MulterError) {
        if (err.code === 'LIMIT_FILE_SIZE') {
            return res.status(400).json({ message: 'File is too large (max 10 MB)' });
        }
    }
    if (err.message?.includes('not allowed')) {
        return res.status(400).json({ message: err.message });
    }
    next(err);
});

module.exports = router;
// ── Angular: file upload component with progress ─────────────────────────
import {
    Component, Input, Output, EventEmitter, signal, inject,
} from '@angular/core';
import { CommonModule }  from '@angular/common';
import { HttpClient, HttpRequest, HttpEventType } from '@angular/common/http';
import { filter, map }   from 'rxjs/operators';

interface UploadState {
    file:     File;
    progress: number;
    status:   'pending' | 'uploading' | 'done' | 'error';
    url?:     string;
    error?:   string;
}

@Component({
    selector:   'app-file-upload',
    standalone: true,
    imports:    [CommonModule],
    template: `
        <div class="upload-zone"
             [class.upload-zone--drag]="isDragging()"
             (dragover)="onDragOver($event)"
             (dragleave)="isDragging.set(false)"
             (drop)="onDrop($event)">

            <input #fileInput type="file"
                   [accept]="accept"
                   [multiple]="multiple"
                   (change)="onFileSelect($event)"
                   class="upload-input">

            @if (uploads().length === 0) {
                <div class="upload-prompt" (click)="fileInput.click()">
                    <p>Drop files here or click to upload</p>
                    <small>Max {{ maxSizeMB }}MB per file</small>
                </div>
            }

            @for (upload of uploads(); track upload.file.name) {
                <div class="upload-item">
                    <span class="upload-item__name">{{ upload.file.name }}</span>
                    <span class="upload-item__size">{{ formatSize(upload.file.size) }}</span>

                    @if (upload.status === 'uploading') {
                        <div class="upload-progress">
                            <div class="upload-progress__bar"
                                 [style.width.%]="upload.progress"></div>
                        </div>
                        <span>{{ upload.progress }}%</span>
                    }
                    @if (upload.status === 'done') {
                        <span class="upload-done">✓ Uploaded</span>
                    }
                    @if (upload.status === 'error') {
                        <span class="upload-error">✗ {{ upload.error }}</span>
                    }
                </div>
            }
        </div>
    `,
})
export class FileUploadComponent {
    @Input() taskId!:     string;
    @Input() accept = 'image/*,.pdf,.doc,.docx,.xls,.xlsx,.txt,.csv';
    @Input() multiple = true;
    @Input() maxSizeMB = 10;
    @Output() uploaded = new EventEmitter<{ filename: string; url: string }>();

    private http    = inject(HttpClient);
    uploads         = signal<UploadState[]>([]);
    isDragging      = signal(false);

    onDragOver(e: DragEvent): void {
        e.preventDefault();
        this.isDragging.set(true);
    }

    onDrop(e: DragEvent): void {
        e.preventDefault();
        this.isDragging.set(false);
        const files = Array.from(e.dataTransfer?.files ?? []);
        this.uploadFiles(files);
    }

    onFileSelect(e: Event): void {
        const files = Array.from((e.target as HTMLInputElement).files ?? []);
        this.uploadFiles(files);
    }

    private uploadFiles(files: File[]): void {
        files.forEach(file => {
            if (file.size > this.maxSizeMB * 1024 * 1024) {
                this.uploads.update(us => [...us, {
                    file, progress: 0, status: 'error',
                    error: `File too large (max ${this.maxSizeMB}MB)`,
                }]);
                return;
            }

            const state: UploadState = { file, progress: 0, status: 'uploading' };
            this.uploads.update(us => [...us, state]);

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

            const req = new HttpRequest(
                'POST',
                `/api/v1/tasks/${this.taskId}/attachments`,
                form,
                { reportProgress: true }
            );

            this.http.request(req).pipe(
                filter(e =>
                    e.type === HttpEventType.UploadProgress ||
                    e.type === HttpEventType.Response
                ),
            ).subscribe({
                next: event => {
                    if (event.type === HttpEventType.UploadProgress) {
                        const pct = event.total
                            ? Math.round(100 * event.loaded / event.total) : 0;
                        this.uploads.update(us => us.map(u =>
                            u.file === file ? { ...u, progress: pct } : u
                        ));
                    }
                    if (event.type === HttpEventType.Response) {
                        const { url, filename } = (event.body as any).data;
                        this.uploads.update(us => us.map(u =>
                            u.file === file ? { ...u, status: 'done', url } : u
                        ));
                        this.uploaded.emit({ filename, url });
                    }
                },
                error: err => {
                    this.uploads.update(us => us.map(u =>
                        u.file === file ? {
                            ...u, status: 'error',
                            error: err.error?.message ?? 'Upload failed',
                        } : u
                    ));
                },
            });
        });
    }

    formatSize(bytes: number): string {
        if (bytes < 1024)       return `${bytes} B`;
        if (bytes < 1048576)    return `${(bytes / 1024).toFixed(1)} KB`;
        return `${(bytes / 1048576).toFixed(1)} MB`;
    }
}

How It Works

Step 1 — FormData Serialises Files for HTTP Transmission

FormData is the browser’s mechanism for encoding multipart/form-data requests — the same format HTML forms use for file inputs. form.append('file', fileObject) adds the file with a field name. When passed to HttpRequest, Angular serialises it correctly and the browser sets the appropriate Content-Type: multipart/form-data; boundary=xyz header. Multer on the server reads this boundary to split the request body into individual parts.

Step 2 — reportProgress: true Enables Progress Events

Adding reportProgress: true to the HttpRequest options makes Angular emit HttpUploadProgressEvent objects as the upload proceeds. Each event has loaded (bytes sent) and total (total bytes). Dividing them and multiplying by 100 gives the percentage. The final HttpResponse event is emitted when the upload is complete. Without reportProgress, the Observable emits only the final response.

Step 3 — Multer Validates and Processes the File on the Server

Multer’s fileFilter function runs before the file is written to disk or memory. It receives the file object including the declared MIME type. Calling cb(null, true) accepts the file; cb(new Error('...'), false) rejects it. The limits.fileSize option automatically rejects files exceeding the specified size. After passing validation, req.file contains the parsed file metadata and (for disk storage) the saved filename.

Step 4 — UUID Filenames Prevent Path Traversal

User-provided filenames like ../../etc/passwd or ../../app/config.js could overwrite system files if used directly. Generating a random UUID for the storage name (crypto.randomUUID() + extension) eliminates this risk entirely — the storage path is independent of the user input. The original filename is stored in the database as display metadata only, never used as a file system path.

Step 5 — Drag-and-Drop Enhances the Upload UX

The HTML Drag and Drop API fires dragover, dragleave, and drop events. Calling event.preventDefault() on dragover is required — without it, the browser opens the file instead of triggering the drop event. The DataTransfer.files property on the drop event contains the same FileList as a file input — the upload logic is identical regardless of whether the user clicked or dragged.

Common Mistakes

Mistake 1 — Setting Content-Type header on FormData uploads

❌ Wrong — overrides the multipart boundary:

const req = new HttpRequest('POST', url, form, {
    headers: new HttpHeaders({ 'Content-Type': 'multipart/form-data' }),
});
// Server receives malformed multipart — cannot parse file!

✅ Correct — let the browser set Content-Type automatically:

const req = new HttpRequest('POST', url, form, { reportProgress: true });
// Browser sets: Content-Type: multipart/form-data; boundary=----WebKit...

Mistake 2 — Trusting client-provided file extension for type validation

❌ Wrong — extension check is bypassable:

if (!req.file.originalname.endsWith('.jpg')) {  // renaming shell.php to shell.jpg bypasses this!
    return res.status(400).json({ message: 'JPG only' });
}

✅ Correct — check MIME type in Multer fileFilter:

fileFilter: (req, file, cb) => {
    cb(null, ['image/jpeg', 'image/png'].includes(file.mimetype));
}

Mistake 3 — Not handling multer errors in Express error middleware

❌ Wrong — MulterError propagates as unhandled 500:

// No multer error handler — LIMIT_FILE_SIZE becomes a 500 error

✅ Correct — catch MulterError explicitly:

router.use((err, req, res, next) => {
    if (err instanceof multer.MulterError && err.code === 'LIMIT_FILE_SIZE')
        return res.status(400).json({ message: 'File too large (max 10MB)' });
    next(err);
});

Quick Reference

Task Code
Multer single file upload.single('fieldName')
Multer multiple files upload.array('files', 10)
File size limit multer({ limits: { fileSize: 10 * 1024 * 1024 } })
File type filter fileFilter: (req, file, cb) => cb(null, ALLOWED.includes(file.mimetype))
Angular FormData const fd = new FormData(); fd.append('file', fileObj)
Upload with progress new HttpRequest('POST', url, fd, { reportProgress: true })
Progress percentage Math.round(100 * event.loaded / event.total)
Unique filename crypto.randomUUID() + path.extname(original)

🧠 Test Yourself

An auth interceptor adds Content-Type: application/json to all POST requests. A file upload using FormData fails with a 400 “unexpected field” error on the server. Why, and what is the fix?