Fullstack CourseLearn by building
Back to week 3

Topic

File handling: Node fs & Nest uploads

Definition

File handling on a Node API means accepting an uploaded file over multipart/form-data, validating it, and persisting its bytes to durable storage while recording a reference (a storage key) in the database — using Node’s fs/promises for disk I/O and Nest’s FileInterceptor to parse the upload.

In simpler words

The bytes of a file go to disk or object storage; the database only keeps a pointer (a path or key) plus metadata like name, type, and size.

A typical Nest upload slice ties these together: a controller accepts an upload with FileInterceptor, a service writes the bytes with fs/promises, and an attachment entity stores the storage_key and metadata.

After this you can

  • Parse an upload with FileInterceptor and read it with @UploadedFile
  • Write bytes safely with fs/promises and store only a reference in the DB
  • Validate file type and size, and serve a file back as a download

Node fs basics: writing and reading bytes

Definition

Node’s fs/promises module exposes async file operations — mkdir with recursive, writeFile, readFile, and streams for large files — while the path module builds filesystem paths portably; a Buffer holds the raw bytes in memory before they are written.

In simpler words

You get a Buffer of bytes, make sure the target directory exists, then write the file — and for big files you stream instead of holding it all in memory.

mkdir(dir, { recursive: true }) is safe to call repeatedly; writeFile(path, buffer) persists the bytes; join(dir, name) builds the path without hardcoding separators.

writeFile/readFile load the whole file into memory — fine for small attachments, but for large files prefer createReadStream/createWriteStream so memory stays flat.

Ensuring the dir and writing bytes

import { mkdir, writeFile } from 'fs/promises';
import { join } from 'path';

await mkdir(env.UPLOAD_DIR, { recursive: true });
const storageKey = join(env.UPLOAD_DIR, safeName);
await writeFile(storageKey, file.buffer);

The bytes land on disk under UPLOAD_DIR; storageKey is the reference the database will remember.

Nest uploads: FileInterceptor and @UploadedFile

Definition

Nest parses multipart/form-data through FileInterceptor (backed by Multer), which populates the file argument exposed by the @UploadedFile decorator; memoryStorage keeps the bytes in a Buffer for the handler, while limits.fileSize rejects oversized uploads before they are fully read.

In simpler words

FileInterceptor turns the raw multipart body into a tidy file object with originalname, mimetype, size, and buffer that your handler receives.

A typical upload route uses FileInterceptor with memoryStorage and a 5MB fileSize limit, so the handler gets file.buffer directly and anything over 5MB is refused.

memoryStorage suits small files handed straight to writeFile; for large files, stream to disk or object storage instead of buffering the whole thing.

Upload route

@Post(':id/attachments')
@UseInterceptors(FileInterceptor('file', {
  storage: memoryStorage(),
  limits: { fileSize: 5 * 1024 * 1024 },
}))
upload(
  @Param('id', ParseUUIDPipe) id: string,
  @CurrentUser() user: User,
  @UploadedFile() file: { originalname: string; mimetype: string; size: number; buffer: Buffer },
) {
  return this.notesService.addAttachment(id, user, file);
}

The form field name (file) must match the FileInterceptor argument, or @UploadedFile is undefined.

Validate, store a reference, and serve safely

Definition

Safe file handling validates the declared type and size, sanitizes or replaces the client filename so it cannot escape the storage directory, stores only a reference plus metadata in the database, and serves downloads through a controlled path rather than trusting client-supplied paths.

In simpler words

Never trust the uploaded filename or type blindly, never put the bytes in a DB column, and never build a download path straight from user input.

A safe implementation replaces the filename with a random-UUID prefix so a name like ../../etc/passwd cannot traverse out of UPLOAD_DIR, and stores storage_key, mime_type, and size on an attachment row — the blob stays on disk, not in Postgres.

mimetype from the client is a hint, not proof; for untrusted uploads verify by content and constrain accepted types. Serve files back with StreamableFile from a key you looked up, never from a raw client path.

Sanitized name + metadata row

const safeName = `${randomUUID()}-${file.originalname.replace(/[^a-zA-Z0-9._-]/g, '_')}`;
const storageKey = join(env.UPLOAD_DIR, safeName);
await writeFile(storageKey, file.buffer);

await this.attachmentsRepo.save({ noteId, storageKey, mimeType: file.mimetype, size: file.size });

The DB row points at the file; the bytes never enter Postgres, keeping the table small and backups sane.

Mistake: trusting the client path

// Wrong — path traversal: client controls the location
await writeFile(join(UPLOAD_DIR, file.originalname), file.buffer);

// Right — generate your own safe, unique name
await writeFile(join(UPLOAD_DIR, `${randomUUID()}-${sanitized}`), file.buffer);

A crafted originalname like ../../server.js could overwrite files if you write it verbatim.

Keep in mind

  • Keep bytes in files or object storage; keep only a storage key + metadata in the DB.
  • Set a fileSize limit on FileInterceptor and validate type before persisting.
  • Generate your own unique, sanitized filename — never write the client name verbatim.
  • Stream large files instead of buffering them entirely in memory.

Test

Check your understanding

At least 10 questions — mix of concept, syntax, practical, and logic. Score ≥80% (enforced by the API) to save progress.

Checking your session…

14 questions · concept 4 · syntax 3 · practical 4 · logic 3

1. Where should uploaded file bytes be stored?
Concept
2. What does the database keep for an attachment?
Syntax
3. Which Nest piece parses a multipart upload?
Practical
4. What does @UploadedFile give the handler?
Logic
5. Why generate your own filename instead of using originalname?
Concept
6. What does limits.fileSize on FileInterceptor do?
Practical
7. Is the client-provided mimetype trustworthy?
Syntax
8. When should you stream instead of buffering a file?
Logic
9. What does mkdir with recursive: true guarantee?
Concept
10. How should a stored file be served back?
Practical
11. What is unsafe here?
Conceptadvanced
await writeFile(join(UPLOAD_DIR, file.originalname), file.buffer);
12. What does this produce?
Syntaxintermediate
const safeName = `${randomUUID()}-${file.originalname.replace(/[^a-zA-Z0-9._-]/g, '_')}`;
13. What does this route accept?
Practicalintermediate
@Post(':id/attachments')
@UseInterceptors(FileInterceptor('file', { storage: memoryStorage(), limits: { fileSize: 5 * 1024 * 1024 } }))
14. Where do the bytes go in this code?
Logicintermediate
await writeFile(storageKey, file.buffer);
await this.attachmentsRepo.save({ noteId, storageKey, mimeType: file.mimetype, size: file.size });

Checking your session…