Example: File Upload

Streaming multipart file upload with type validation, size limits, and disk storage. The parser uses ≤128 KB of heap regardless of file size.


Controller

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
// src/controllers/upload.controller.ts
import {
  Controller, Post, Get, ApiOperation,
  BadRequestException, container,
} from 'streetjs';
import type { StreetContext } from 'streetjs';
import { UploadService } from '../services/upload.service.js';

const ALLOWED_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp', 'application/pdf']);
const MAX_SIZE_BYTES = 10 * 1024 * 1024;  // 10 MB

@Controller('/api/uploads')
export class UploadController {
  private readonly svc = container.resolve(UploadService);

  @Post('/image')
  @ApiOperation({ summary: 'Upload an image', tags: ['uploads'] })
  async uploadImage(ctx: StreetContext): Promise<void> {
    if (ctx.files.length === 0) {
      throw new BadRequestException('No file provided');
    }

    const file = ctx.files[0]!;

    if (!ALLOWED_TYPES.has(file.mimeType)) {
      throw new BadRequestException(
        `File type ${file.mimeType} not allowed. Allowed: ${[...ALLOWED_TYPES].join(', ')}`
      );
    }

    if (file.size > MAX_SIZE_BYTES) {
      throw new BadRequestException(`File too large. Max size: ${MAX_SIZE_BYTES / 1024 / 1024} MB`);
    }

    const record = await this.svc.save({
      originalName: file.originalName,
      mimeType:     file.mimeType,
      size:         file.size,
      path:         file.path,
    });

    ctx.json({ id: record.id, url: `/api/uploads/${record.id}` }, 201);
  }

  @Post('/avatar')
  @ApiOperation({ summary: 'Upload user avatar', tags: ['uploads'] })
  async uploadAvatar(ctx: StreetContext): Promise<void> {
    if (!ctx.user) throw new BadRequestException('Authentication required');
    if (ctx.files.length === 0) throw new BadRequestException('No file provided');

    const file = ctx.files[0]!;
    if (!file.mimeType.startsWith('image/')) {
      throw new BadRequestException('Only image files are allowed for avatars');
    }

    const record = await this.svc.saveAvatar(ctx.user.id, {
      originalName: file.originalName,
      mimeType:     file.mimeType,
      size:         file.size,
      path:         file.path,
    });

    ctx.json({ avatarUrl: `/api/uploads/${record.id}` });
  }

  @Get('/:id')
  @ApiOperation({ summary: 'Get upload metadata', tags: ['uploads'] })
  async getOne(ctx: StreetContext): Promise<void> {
    const record = await this.svc.findById(ctx.params['id']!);
    ctx.json(record);
  }
}

Service

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
// src/services/upload.service.ts
import { Injectable, container, PgPool, NotFoundException } from 'streetjs';
import { resolve } from 'node:path';

export interface UploadRecord {
  id:           string;
  userId:       string | null;
  originalName: string;
  mimeType:     string;
  size:         number;
  path:         string;
  createdAt:    Date;
}

@Injectable()
export class UploadService {
  private readonly pool = container.resolve(PgPool);

  async save(file: Omit<UploadRecord, 'id'|'userId'|'createdAt'>): Promise<UploadRecord> {
    const record: UploadRecord = {
      id: crypto.randomUUID(), userId: null,
      ...file, createdAt: new Date(),
    };
    await this.pool.query(
      `INSERT INTO uploads (id, user_id, original_name, mime_type, size, path, created_at)
       VALUES ($1,$2,$3,$4,$5,$6,$7)`,
      [record.id, null, record.originalName, record.mimeType,
       record.size, record.path, record.createdAt.toISOString()]
    );
    return record;
  }

  async saveAvatar(userId: string, file: Omit<UploadRecord, 'id'|'userId'|'createdAt'>): Promise<UploadRecord> {
    const record: UploadRecord = {
      id: crypto.randomUUID(), userId,
      ...file, createdAt: new Date(),
    };
    await this.pool.query(
      `INSERT INTO uploads (id, user_id, original_name, mime_type, size, path, created_at)
       VALUES ($1,$2,$3,$4,$5,$6,$7)`,
      [record.id, userId, record.originalName, record.mimeType,
       record.size, record.path, record.createdAt.toISOString()]
    );
    return record;
  }

  async findById(id: string): Promise<UploadRecord> {
    const result = await this.pool.query('SELECT * FROM uploads WHERE id = $1', [id]);
    if (!result.rows[0]) throw new NotFoundException(`Upload ${id} not found`);
    const row = result.rows[0] as Record<string, unknown>;
    return {
      id:           String(row['id']),
      userId:       row['user_id'] ? String(row['user_id']) : null,
      originalName: String(row['original_name']),
      mimeType:     String(row['mime_type']),
      size:         Number(row['size']),
      path:         String(row['path']),
      createdAt:    new Date(String(row['created_at'])),
    };
  }
}

Migration

1
2
3
4
5
6
7
8
9
10
-- migrations/20260101000001_create_uploads.sql
CREATE TABLE uploads (
  id            UUID         PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id       UUID         REFERENCES users(id) ON DELETE SET NULL,
  original_name VARCHAR(255) NOT NULL,
  mime_type     VARCHAR(100) NOT NULL,
  size          BIGINT       NOT NULL,
  path          TEXT         NOT NULL,
  created_at    TIMESTAMPTZ  NOT NULL DEFAULT NOW()
);

Test with curl

1
2
3
4
5
6
7
8
9
10
11
12
13
# Upload an image
curl -X POST http://localhost:3000/api/uploads/image \
  -F 'file=@/path/to/photo.jpg'
# {"id":"...","url":"/api/uploads/..."}

# Upload with wrong type
curl -X POST http://localhost:3000/api/uploads/image \
  -F 'file=@/path/to/script.sh'
# {"error":"File type application/x-sh not allowed...","statusCode":400}

# Get metadata
curl http://localhost:3000/api/uploads/<id>
# {"id":"...","originalName":"photo.jpg","mimeType":"image/jpeg","size":245678,...}