Hết Part 1 hệ thống trừ tiền đúng kể cả khi hai request chạy đồng thời. Nhưng nó vẫn có một lỗ hổng lớn đến mức buồn cười: userId là tham số do client tự gửi. Ai cũng có thể tiêu credit của người khác bằng cách đổi một con số trong body.

Part này bịt lỗ đó, rồi đi tiếp ra lớp ngoài cùng của hệ thống — chỗ request chạm vào trước khi tới NestJS. Xác thực, nginx, giới hạn tần suất, và luồng dữ liệu lớn: upload video 2 GB và phát lại có tua được, cả hai đều không được phép nạp cả file vào RAM.

🔍 Hết Part 2 bạn sẽ có gì
Đăng ký, đăng nhập, access token JWT ngắn hạn, refresh token có xoay vòng và tự phát hiện bị đánh cắp. Một tầng nginx đứng trước ứng dụng lo TLS và chặn file quá khổ. Giới hạn tần suất ở đúng hai nơi khác nhau vì hai mục đích khác nhau. Upload video theo stream không phình bộ nhớ, và phát lại tua được bằng Range mà Node gần như không tốn CPU nào. Kèm hai mốc giao diện React để nhìn thấy thứ mình vừa dựng.

1. Mật khẩu lưu thế nào cho đúng

Bảng users ở Part 1 có cột password_hash. Chữ hash ở đây không phải loại hash bạn dùng để kiểm tra tính toàn vẹn file. SHA-256 được thiết kế để chạy nhanh — đó chính xác là điều bạn không muốn: card đồ hoạ phổ thông thử được hàng tỉ SHA-256 mỗi giây, nên một bảng mật khẩu bị lộ sẽ bị bẻ gần hết trong vài giờ.

Hàm băm mật khẩu là loại được cố ý làm chậm và cố ý tốn bộ nhớ. Bản hiện đại nên dùng là argon2id: nó tốn RAM theo tham số bạn đặt, mà RAM là thứ GPU không nhân bản rẻ được như nhân tính toán.

Cài gói
npm i argon2 @nestjs/jwt
npm i -D @types/express
src/auth/password.service.ts
import { Injectable } from '@nestjs/common';
import * as argon2 from 'argon2';

@Injectable()
export class PasswordService {
  private readonly options = {
    type: argon2.argon2id,
    memoryCost: 19456, // 19 MiB — moi lan bam ton chung nay RAM
    timeCost: 2,
    parallelism: 1,
  } as const;

  hash(plain: string): Promise<string> {
    return argon2.hash(plain, this.options);
  }

  verify(hash: string, plain: string): Promise<boolean> {
    return argon2.verify(hash, plain);
  }
}

Không cần tự sinh salt: argon2 tự sinh và nhét thẳng vào chuỗi kết quả cùng với tham số. Một password_hash trông như thế này, và đọc được từ trái sang: thuật toán, phiên bản, tham số, salt, rồi hash.

Một giá trị password_hash thật
$argon2id$v=19$m=19456,t=2,p=1$c29tZXNhbHR2YWx1ZQ$RdescudvJCsgt3ub+b+dWRWJTmaaJObG
💡 Vì sao tham số nằm ngay trong chuỗi
Hai năm nữa bạn nâng memoryCost lên gấp đôi vì máy chủ mạnh hơn. Những mật khẩu đã băm bằng tham số cũ vẫn phải đăng nhập được — và chúng vẫn kiểm tra được, vì mỗi dòng tự mang theo tham số của chính nó. argon2.needsRehash(hash, options) cho biết dòng nào đang dùng tham số cũ, để bạn băm lại bằng mật khẩu vừa nhập đúng ngay sau lần đăng nhập kế tiếp. Nâng cấp dần, không cần bắt cả triệu người đổi mật khẩu.

Dữ liệu vào từ request phải qua DTO trước khi chạm tới AuthService — đúng quy ước đã đặt từ Part 1:

src/auth/login.dto.ts
import { IsEmail, IsString, MinLength } from 'class-validator';

export class LoginDto {
  @IsEmail()
  email!: string;

  @IsString()
  @MinLength(8)
  password!: string;
}

Các decorator trên LoginDto không tự chạy — chúng chỉ là metadata cho tới khi có một ValidationPipe toàn cục đọc metadata đó trước khi request chạm tới controller. Đăng ký ngay trong src/main.ts, trước dòng đầu tiên gọi app.listen(...):

src/main.ts — bật validate toàn cục
import { ValidationPipe } from '@nestjs/common';
import type { NestExpressApplication } from '@nestjs/platform-express';

const app = await NestFactory.create<NestExpressApplication>(AppModule);
app.useGlobalPipes(
  new ValidationPipe({
    whitelist: true, // xoa nhung field khong khai bao trong DTO thay vi chuyen tiep nguyen si
    forbidNonWhitelisted: true, // co field la khong khai bao -> 400, khong am tham xoa
    transform: true, // "42" tu query string thanh so 42 truoc khi vao ham
  }),
);
⚠️ Thiếu dòng này thì mọi DTO trong cả loạt bài chỉ là trang trí
Không có ValidationPipe toàn cục, NestJS không bao giờ đọc metadata mà @IsEmail(), @IsInt(), @IsIn(...) gắn lên DTO — dữ liệu vào thẳng controller y nguyên, đúng hay sai đều như nhau. Đo thật bằng cách bỏ dòng này rồi gọi POST /billing/charge thiếu hẳn trường amount: không có 400 nào cả, request đi thẳng xuống Postgres với giá trị undefined, và driver trả về nguyên văn error: invalid input syntax for integer: "NaN" kèm mã lỗi 22P02 — một lỗi 500 lộ chi tiết nội bộ, đúng thứ ValidationPipe được sinh ra để chặn trước khi nó xảy ra.

1.1. Đăng nhập sai thì trả lỗi gì

Chỗ này dễ viết sai theo hướng "tử tế với người dùng": email không tồn tại thì báo email không tồn tại, mật khẩu sai thì báo mật khẩu sai. Làm vậy là biến form đăng nhập thành công cụ dò danh sách khách hàng — kẻ tấn công thử một triệu email và biết chính xác email nào có tài khoản ở đây. Với dịch vụ có tính nhạy cảm thì bản thân việc "người này có tài khoản" đã là dữ liệu.

Cả hai trường hợp trả cùng một thông báo. Và còn một rò rỉ tinh vi hơn: nếu email không tồn tại thì hàm trả về ngay, còn email tồn tại thì phải chờ argon2 chạy xong mới trả về. Chênh lệch vài chục mili giây đó đo được từ xa. Cách vá là luôn chạy một lần verify, kể cả khi không có người dùng nào.

src/auth/auth.service.ts — phần xác thực thông tin đăng nhập (khung class + method đầu tiên; constructor bên dưới sẽ thay)
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User } from './user.entity';
import { PasswordService } from './password.service';

// Hash cua mot mat khau khong ai biet. Dung lam moi nhu khi email khong ton tai,
// de thoi gian phan hoi hai truong hop bang nhau.
const DUMMY_HASH =
  '$argon2id$v=19$m=19456,t=2,p=1$c29tZXNhbHR2YWx1ZQ$RdescudvJCsgt3ub+b+dWRWJTmaaJObG';

@Injectable()
export class AuthService {
  constructor(
    @InjectRepository(User) private readonly users: Repository<User>,
    private readonly passwords: PasswordService,
  ) {}

  private async validateCredentials(email: string, plain: string): Promise<User> {
    const user = await this.users.findOne({ where: { email: email.toLowerCase() } });

    const ok = await this.passwords.verify(user?.passwordHash ?? DUMMY_HASH, plain);

    // Mot thong bao duy nhat cho ca hai truong hop.
    if (!ok || !user) {
      throw new UnauthorizedException('Email hoac mat khau khong dung');
    }

    return user;
  }
}

Thứ tự !ok || !user chứ không phải ngược lại là cố ý: nếu kiểm tra !user trước thì TypeScript vẫn hài lòng nhưng ta lại thoát sớm đúng cái nhánh vừa mất công vá. Còn user?.passwordHash ?? DUMMY_HASH đọc được ngay nhờ cờ noUncheckedIndexedAccess và kiểu trả về User | null của findOne — trình biên dịch không cho bạn quên rằng nó có thể rỗng.

📌 Trình biên dịch sẽ báo lỗi ngay bây giờ — đúng như dự kiến
Nếu bạn đang để npm run start:dev chạy nền, ngay khi lưu file này nó sẽ đỏ:

src/auth/auth.service.ts:19:17 - error TS6133: 'validateCredentials' is declared but its value is never read.

Không phải bạn gõ sai. validateCredentialsprivate và chưa có ai gọi nó — người gọi là login(), viết ở mục 3.2. Đây chính là cờ noUnusedLocals mà Part 1 mục 5 bảo bạn bật, đang làm đúng việc của nó: một phương thức private không ai gọi thường là code chết, nên nó bắt bạn để ý.

Lỗi này tự hết khi bạn viết login() ở mục 3.2. Từ giờ tới đó cứ tạo file và đọc tiếp; nếu bạn muốn màn hình sạch trong lúc đọc thì dừng watcher lại, đừng sửa cờ trong tsconfig.json — nới cờ để tắt một cảnh báo đúng là cách nhanh nhất đánh mất giá trị của nó.

2. Access token: JWT và vì sao nó ngắn hạn

Sau khi xác thực xong, server phải phát cho client một thứ để đính vào các request sau. Có hai trường phái. Session trong database: phát một chuỗi ngẫu nhiên, mỗi request tra bảng để biết nó thuộc về ai. Thu hồi tức thì — xoá dòng là xong — nhưng mỗi request tốn một lượt đọc. JWT: nhét thông tin vào chính token và ký nó; server chỉ cần kiểm chữ ký, không tra gì cả. Nhanh, không trạng thái, chạy được ở nhiều instance mà không cần chia sẻ gì.

Cái giá của JWT là không thu hồi được. Đã ký ra rồi thì nó hợp lệ tới lúc hết hạn, kể cả khi bạn vừa khoá tài khoản đó. Đây không phải khiếm khuyết cần vá, mà là bản chất — và cách sống chung với nó là làm cho khoảng thời gian ấy đủ ngắn để thiệt hại chấp nhận được.

🔍 Hai token, hai vai trò
Access token — JWT, sống 15 phút, đính vào mọi request, không lưu ở đâu trên server.
Refresh token — chuỗi ngẫu nhiên, sống 30 ngày, chỉ gửi tới một endpoint duy nhất, có lưu ở database nên thu hồi được ngay.

Nói cách khác: thứ đi khắp nơi thì không thu hồi được nhưng chết nhanh; thứ sống lâu thì đi rất ít chỗ và thu hồi được. Toàn bộ thiết kế xác thực dưới đây chỉ là hệ quả của hai câu này.
src/config/configuration.ts — CHỈ thay khối configSchema, giữ nguyên phần còn lại của file
const configSchema = z.object({
  NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
  PORT: z.coerce.number().int().positive().default(3000),
  DATABASE_URL: z.string().url().startsWith('postgres://'),

  // Doi 32 ky tu tro len. Khoa ngan thi chu ky do duoc bang vet can.
  JWT_SECRET: z.string().min(32),
  ACCESS_TOKEN_TTL: z.string().default('15m'),
  REFRESH_TOKEN_TTL_DAYS: z.coerce.number().int().positive().default(30),

  // Dung cho RedisService (gioi han tan suat o muc 5, hang doi o Part 3).
  REDIS_URL: z.string().url().startsWith('redis://').default('redis://localhost:6379'),
});

Ba trong bốn biến này có .default(...) nên thiếu cũng chạy được. JWT_SECRET thì không — nó bắt buộc, và bỏ qua bước dưới đây thì npm run start:dev dừng ngay với JWT_SECRET: Invalid input: expected string, received undefined. Khoá phải dài ít nhất 32 ký tự, nên đừng gõ tay một chuỗi ngắn:

Terminal
# Sinh khoa ngau nhien 64 ky tu hex va noi thang vao .env
echo "JWT_SECRET=$(openssl rand -hex 32)" >> .env

# Them not vao .env.example de nguoi khac biet la can bien nay
# (KHONG ghi gia tri that vao file duoc commit)
cat >> .env.example <<'EOF'
JWT_SECRET=thay-bang-chuoi-ngau-nhien-tu-openssl-rand-hex-32
ACCESS_TOKEN_TTL=15m
REFRESH_TOKEN_TTL_DAYS=30
REDIS_URL=redis://localhost:6379
EOF

Thêm bốn dòng vào schema là kiểu AppConfig tự có thêm bốn trường — đúng như mục 4 của Part 1 đã nói. Giờ dựng module JWT, đọc khoá qua ConfigService chứ không đụng process.env:

src/auth/auth.module.ts
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import type { AppConfig } from '../config/configuration';
import { User } from './user.entity';
import { RefreshToken } from './refresh-token.entity';
import { AuthService } from './auth.service';
import { PasswordService } from './password.service';

@Module({
  imports: [
    TypeOrmModule.forFeature([User, RefreshToken]),
    JwtModule.registerAsync({
      inject: [ConfigService],
      useFactory: (config: ConfigService<AppConfig, true>) => ({
        secret: config.get('JWT_SECRET', { infer: true }),
        signOptions: {
          expiresIn: config.get('ACCESS_TOKEN_TTL', { infer: true }),
          issuer: 'media-forge',
        },
      }),
    }),
  ],
  // Chua co controller nao: file auth.controller.ts duoc viet o muc 3.5,
  // vi chinh no la cho quyet dinh tra refresh token qua cookie hay qua body.
  providers: [AuthService, PasswordService],
  // JwtModule duoc export lai — ProgressGateway o Part 3 tiem JwtService
  // truc tiep de xac thuc bat tay WebSocket, va no nam o mot module khac
  // import AuthModule chu khong phai chinh AuthModule.
  exports: [AuthService, JwtModule],
})
export class AuthModule {}

Module này chưa biên dịch được ngay, và điều đó là bình thường: nó khai AuthServiceproviders mà file đó tới mục 3.2 mới viết, còn controller thì tới mục 3.5. Cứ tạo file, đừng chạy npm run start:dev vội — chúng ta sẽ cắm nó vào AppModule và chạy thử ở cuối mục 3.5, khi mọi mảnh đã đủ.

2.1. Nội dung token và guard đọc nó

Payload của JWT không được mã hoá, chỉ được ký. Bất kỳ ai cầm token đều đọc được nội dung bằng cách giải base64 — chữ ký chỉ đảm bảo nội dung không bị sửa. Nên trong đó chỉ đặt thứ không ngại lộ: id, vai trò. Không đặt email, không đặt số dư, không đặt gì có thể cũ đi.

src/auth/jwt-payload.ts
import type { Request } from 'express';

export const USER_ROLES = ['user', 'admin'] as const;
export type UserRole = (typeof USER_ROLES)[number];

export const USER_PLANS = ['free', 'pro'] as const;
export type UserPlan = (typeof USER_PLANS)[number];

export interface JwtPayload {
  readonly sub: string; // user id — ten chuan trong RFC 7519
  readonly role: UserRole;
  readonly plan: UserPlan; // dung cho gioi han tan suat o muc 5

  readonly iat: number;
  readonly exp: number;
}

// Kieu request sau khi guard da gan nguoi dung vao.
export interface AuthedRequest extends Request {
  readonly user: JwtPayload;
}
⚠️ Thiếu import này thì AuthedRequest âm thầm sai kiểu
Không có import type { Request } from 'express', TypeScript không hiểu Request là kiểu nào và lặng lẽ suy ra Request toàn cục của Fetch API (có sẵn từ @types/node) thay vì kiểu Request của Express. Hai kiểu trùng tên nhưng khác hẳn nhau: bản Fetch không có cookies, không index được vào headers bằng dấu ngoặc vuông, và không phải một Node.js Readable stream. Hậu quả chỉ lộ ra ở nơi dùng AuthedRequest — ví dụ UploadController ở mục 6 — dưới dạng những lỗi kiểu khó hiểu, không phải ngay tại đây.
src/auth/jwt-auth.guard.ts
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import type { Request } from 'express';
import type { JwtPayload } from './jwt-payload';

@Injectable()
export class JwtAuthGuard implements CanActivate {
  constructor(private readonly jwt: JwtService) {}

  async canActivate(context: ExecutionContext): Promise<boolean> {
    const request = context.switchToHttp().getRequest<Request>();
    const header = request.headers.authorization;

    if (header === undefined || !header.startsWith('Bearer ')) {
      throw new UnauthorizedException('Thieu access token');
    }

    try {
      const payload = await this.jwt.verifyAsync<JwtPayload>(header.slice(7));
      // Gan vao request de controller doc duoc.
      Object.defineProperty(request, 'user', { value: payload, enumerable: true });
      return true;
    } catch {
      // Het han, chu ky sai, token bi cat — deu ve mot cho.
      throw new UnauthorizedException('Access token khong hop le');
    }
  }
}

Controller không nên moi request.user bằng tay ở từng nơi. Một decorator nhỏ vừa gọn hơn vừa giữ được kiểu:

src/auth/current-user.decorator.ts
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import type { AuthedRequest, JwtPayload } from './jwt-payload';

export const CurrentUser = createParamDecorator(
  (_data: unknown, context: ExecutionContext): JwtPayload =>
    context.switchToHttp().getRequest<AuthedRequest>().user,
);

Giờ quay lại chỗ hở ở đầu bài. Endpoint tiêu credit của Part 1 trước và sau:

src/billing/billing.controller.ts
// TRUOC — userId do client gui. Ai cung tieu tien cua ai cung duoc.
@Post('charge')
charge(@Body() dto: ChargeDto) {
  return this.billing.charge(dto.userId, dto.amount, dto.reason);
}

// SAU — userId lay tu chu ky, client khong noi vao duoc.
@Post('charge')
@UseGuards(JwtAuthGuard)
async charge(@CurrentUser() user: JwtPayload, @Body() dto: ChargeDto) {
  await this.billing.charge(user.sub, dto.amount, dto.reason);
  return { balance: await this.billing.getBalance(user.sub) };
}
⚠️ Tên method phải khớp với BillingService thật
BillingService ở Part 1 chỉ có charge()getBalance() — không có spend(). Route và tên method ở đây đổi thành charge để khớp đúng những gì Part 1 đã viết; ChargeDto khai báo amountreason (kiểu CreditReason từ credit-entry.entity.ts), qua class-validator như quy ước ở mục 4 của Part 1.

DTO đi kèm giờ trông thế này — đã bỏ trường userId của bản Part 1, vì giờ nó đến từ JWT chứ không còn tin theo client:

src/billing/charge.dto.ts
import { IsIn, IsInt, IsPositive } from 'class-validator';
import type { CreditReason } from './credit-entry.entity';

const CREDIT_REASONS: readonly CreditReason[] = ['topup', 'transcode', 'refund'];

export class ChargeDto {
  @IsInt()
  @IsPositive()
  amount!: number;

  @IsIn(CREDIT_REASONS)
  reason!: CreditReason;
}

Bọc lại thành một controller hoàn chỉnh — bản "sau" ở trên là method thật, phần còn lại chỉ là khung quen thuộc:

src/billing/billing.controller.ts — khung đầy đủ
import { Body, Controller, Post, UseGuards } from '@nestjs/common';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import type { JwtPayload } from '../auth/jwt-payload';
import { BillingService } from './billing.service';
import { ChargeDto } from './charge.dto';

@Controller('billing')
export class BillingController {
  constructor(private readonly billing: BillingService) {}

  @Post('charge')
  @UseGuards(JwtAuthGuard)
  async charge(@CurrentUser() user: JwtPayload, @Body() dto: ChargeDto): Promise<{ balance: number }> {
    await this.billing.charge(user.sub, dto.amount, dto.reason);
    return { balance: await this.billing.getBalance(user.sub) };
  }
}

Controller xong nhưng chưa chạy được. Thêm @UseGuards(JwtAuthGuard) vào đây là bạn vừa tạo ra một phụ thuộc mới cho BillingModule, và NestJS sẽ từ chối khởi động:

⚠️ Guard được phân giải ở module DÙNG nó, không phải module khai nó
UnknownDependenciesException [Error]: Nest can't resolve dependencies of the JwtAuthGuard (?). Please make sure that the argument JwtService at index [0] is available in the BillingModule module.

Thông báo này rất dễ gây hiểu lầm: nó chỉ vào BillingModule trong khi JwtAuthGuard nằm ở thư mục auth/ và bạn vừa sửa file billing.controller.ts. Lý do là decorator @UseGuards nằm trên controller của BillingModule, nên NestJS đi tìm JwtService trong ngữ cảnh của BillingModule — chứ không phải nơi guard được định nghĩa.

Chỗ dễ nhầm: JwtAuthGuard không hề được export ở đâu cả — nó cũng không nằm trong providers của module nào. Vậy import AuthModule thì giải quyết được gì? Tách ra ba bước sẽ rõ:

Một. @UseGuards(JwtAuthGuard) nhận chính cái class, lấy về bằng một import TypeScript thường. Không có token DI nào ở bước này, nên guard không cần ai export.

Hai. Nhưng Nest phải tự khởi tạo class đó, và nó khởi tạo trong injector của module khai controller — ở đây là BillingModule.

Ba. Muốn khởi tạo thì phải giải được tham số constructor constructor(private readonly jwt: JwtService). JwtService mới là token DI cần tìm, và nó phải giải được trong ngữ cảnh BillingModule.

Mắt xích nằm ở dòng cuối của auth.module.ts: exports: [AuthService, JwtModule]AuthModule export lại nguyên cả JwtModule, nên ai import AuthModule cũng nhận được mọi thứ JwtModule export, trong đó có JwtService. Nói gọn: import AuthModule không phải để lấy guard — mà để lấy nhiên liệu cho guard.

Bạn cũng có thể cho BillingModule import thẳng JwtModule, nhưng phải registerAsync lại với cùng JWT_SECRET — tức nhân đôi cấu hình khoá ở hai chỗ, và đến ngày đổi khoá thì quên một chỗ là hỏng âm thầm.

Đây là quy tắc chung, không phải chuyện riêng của billing: module nào gắn guard thì module đó phải import module cung cấp phụ thuộc của guard. Bạn sẽ gặp lại y hệt ở mục 5 với MediaModule.
src/billing/billing.module.ts — thêm AuthModule
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AuthModule } from '../auth/auth.module';
import { CreditEntry } from './credit-entry.entity';
import { BillingController } from './billing.controller';
import { BillingService } from './billing.service';

@Module({
  // AuthModule: JwtAuthGuard tiem JwtService — guard duoc phan giai trong
  // module DUNG no, khong phai module khai no.
  imports: [TypeOrmModule.forFeature([CreditEntry]), AuthModule],
  controllers: [BillingController],
  providers: [BillingService],
  exports: [BillingService],
})
export class BillingModule {}
💡 Vì sao sub chứ không phải userId
sub (subject) là tên trường chuẩn trong RFC 7519, cùng họ với iat, exp, iss. Dùng tên chuẩn thì mọi thư viện JWT, mọi công cụ gỡ lỗi, mọi API gateway đều hiểu token của bạn mà không cần cấu hình riêng. Đặt tên tự chế thì lúc gắn thêm một thành phần bên thứ ba, bạn sẽ phải đi ánh xạ tay.

2.2. Phân quyền theo vai trò

Xác thực trả lời "anh là ai", phân quyền trả lời "anh được làm gì". Hai câu hỏi khác nhau nên là hai guard khác nhau — trộn vào một là con đường dẫn tới một hàm 200 dòng đầy if.

src/auth/roles.guard.ts
import { CanActivate, ExecutionContext, ForbiddenException, Injectable, SetMetadata } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import type { AuthedRequest, UserRole } from './jwt-payload';

export const ROLES_KEY = 'roles';
export const Roles = (...roles: readonly UserRole[]) => SetMetadata(ROLES_KEY, roles);

@Injectable()
export class RolesGuard implements CanActivate {
  constructor(private readonly reflector: Reflector) {}

  canActivate(context: ExecutionContext): boolean {
    const required = this.reflector.getAllAndOverride<readonly UserRole[] | undefined>(ROLES_KEY, [
      context.getHandler(),
      context.getClass(),
    ]);

    // Khong khai bao @Roles() thi khong rang buoc vai tro.
    if (required === undefined || required.length === 0) return true;

    const { user } = context.switchToHttp().getRequest<AuthedRequest>();
    if (!required.includes(user.role)) {
      throw new ForbiddenException('Khong du quyen');
    }
    return true;
  }
}

Thứ tự khai báo có ý nghĩa: @UseGuards(JwtAuthGuard, RolesGuard) chạy từ trái sang, nên RolesGuard chắc chắn đã có request.user để đọc. Đảo lại thì nó đọc phải undefined và ném ra một lỗi khó hiểu — trình biên dịch không cứu được bạn ở đây vì thứ tự chỉ tồn tại lúc chạy.

Một chỗ dùng thật: đội hỗ trợ cần xem số dư của một user bất kỳ khi xử lý khiếu nại, nhưng chỉ admin được làm việc đó. Quay lại BillingController ở mục 2.1 và thêm một endpoint:

src/billing/billing.controller.ts — endpoint chỉ dành cho admin
@Get(':userId/balance')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('admin')
async getBalanceForUser(@Param('userId') userId: string): Promise<{ balance: number }> {
  return { balance: await this.billing.getBalance(userId) };
}

UserRole ở mục 2.1 đã có sẵn 'admin' trong USER_ROLES, nên @Roles('admin') ở đây không cần khai báo gì thêm.

3. Refresh token: xoay vòng và phát hiện bị đánh cắp

Access token chết sau 15 phút. Nếu không có gì khác thì người dùng phải đăng nhập lại mỗi 15 phút — không ai chịu nổi. Refresh token là thứ đổi lấy access token mới mà không cần nhập lại mật khẩu.

Nhưng nó sống 30 ngày. Một chuỗi sống 30 ngày mà bị lộ thì kẻ trộm dùng thoải mái suốt 30 ngày, và bạn không hề biết. Trừ khi ta thêm một quy tắc: mỗi refresh token chỉ dùng được đúng một lần. Dùng xong, nó chết và server phát ra một cái mới.

Quy tắc đó tạo ra một hệ quả rất đẹp. Nếu một token đã dùng rồi lại được đem dùng lần nữa, chỉ có thể là một trong hai chuyện: hoặc kẻ trộm dùng bản sao cũ, hoặc nạn nhân dùng bản gốc sau khi kẻ trộm đã xoay nó. Trong cả hai trường hợp, kết luận giống nhau — chuỗi này đã bị nhân bản. Và server phản ứng bằng cách thu hồi toàn bộ chuỗi.

Đường đi bình thường — mỗi lần refresh sinh token mới R1 đã dùng R2 đã dùng R3 đang hiệu lực cùng một family_id Kẻ trộm chép được R2 và đem dùng lại R2 dùng lần thứ 2 Báo động thu hồi cả family: R1, R2, R3 Hệ quả: cả nạn nhân lẫn kẻ trộm đều bị đăng xuất. Nạn nhân đăng nhập lại bằng mật khẩu — kẻ trộm thì không. Không có bước này, một refresh token bị lộ = 30 ngày truy cập im lặng.

3.1. Entity và vì sao lưu hash

Bảng refresh_tokens đã có trong migration từ Part 1. Giờ là lúc nhìn kỹ nó, vì mỗi cột đều có lý do:

src/auth/refresh-token.entity.ts
import { Column, CreateDateColumn, Entity, Index, JoinColumn, ManyToOne, PrimaryGeneratedColumn } from 'typeorm';
import { User } from './user.entity';

@Entity('refresh_tokens')
export class RefreshToken {
  @PrimaryGeneratedColumn('uuid')
  id!: string;

  @ManyToOne(() => User, { onDelete: 'CASCADE' })
  @JoinColumn({ name: 'user_id' })
  user!: User;

  @Column({ name: 'user_id' })
  userId!: string;

  // SHA-256 cua token. Khong bao gio luu ban goc.
  @Index({ unique: true })
  @Column({ name: 'token_hash', length: 64 })
  tokenHash!: string;

  // Ca mot chuoi xoay vong dung chung mot family_id.
  @Index()
  @Column({ name: 'family_id' })
  familyId!: string;

  // null = chua dung. Co gia tri = da dung, dung lai la bao dong.
  @Column({ name: 'used_at', type: 'timestamptz', nullable: true })
  usedAt!: Date | null;

  // Token nao da thay the token nay. Dung cho khoang an han o muc 3.3.
  @Column({ name: 'replaced_by_hash', type: 'varchar', length: 64, nullable: true })
  replacedByHash!: string | null;

  @Column({ name: 'expires_at', type: 'timestamptz' })
  expiresAt!: Date;

  @CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
  createdAt!: Date;
}

Cột replaced_by_hash là cột duy nhất chưa có trong migration của Part 1 — nó phục vụ khoảng ân hạn ở mục 3.3. Thêm nó vào entity xong phải sinh và chạy migration, đúng hai bước như Part 1 §7.4; TypeORM tự so sánh entity với schema hiện tại và sinh ra đúng một lệnh ALTER TABLE:

Terminal
npm run migration:generate -- src/database/migrations/AddReplacedByHash
npm run migration:run
⚠️ Bỏ bước này thì đăng nhập trả 500
Entity đã có cột nhưng bảng thì chưa. Lần đăng nhập đầu tiên, issuePair() ghi replacedByHash: null xuống một cột không tồn tại và Postgres từ chối: column "replaced_by_hash" of relation "refresh_tokens" does not exist. Lỗi hiện ra ở /auth/login chứ không phải ở chỗ vừa sửa, nên rất dễ đi tìm nhầm chỗ.
⚠️ type: 'varchar' ở đây không phải thừa
Bỏ type đi và để TypeORM tự suy từ kiểu TypeScript sẽ làm migration:generate chết ngay với DataTypeNotSupportedError: Data type "Object" ... is not supported. Nguyên nhân nằm ở reflect-metadata: TypeScript phát ra kiểu thiết kế (design:type) cho một union như string | null thành Object chứ không phải String — phần | null làm mất thông tin. usedAt: Date | null ở trên không gặp lỗi này vì nó đã khai báo type: 'timestamptz' tường minh; quy tắc chung là mọi cột nullable ngoài kiểu nguyên thuỷ không union đều cần khai type tay, không dựa vào suy luận.

Refresh token cũng là mật khẩu — nó đổi được lấy quyền truy cập. Nhưng ở đây dùng SHA-256 chứ không phải argon2, và đó không phải mâu thuẫn với mục 1. Mật khẩu người dùng thì ngắn, dễ đoán, nên cần hàm chậm để chống dò. Refresh token là 32 byte ngẫu nhiên từ crypto.randomBytes — không gian tìm kiếm lớn đến mức không ai dò nổi, nên vấn đề duy nhất là "database bị đọc trộm thì có dùng lại được không", và SHA-256 giải quyết trọn vẹn điều đó với chi phí gần bằng không cho mỗi request.

🔍 @Index({ unique: true }) trên token_hash
Không chỉ để tra nhanh. Ràng buộc unique là thứ khiến bước xoay vòng ở mục 3.3 an toàn trước cả tình huống đua: nếu hai request cùng lúc thử tạo cùng một dòng, database từ chối một cái. Chỉ mục ở đây vừa là tối ưu vừa là ràng buộc đúng đắn — mà database thì luôn giữ ràng buộc tốt hơn code ứng dụng.

3.2. Phát cặp token

src/auth/auth.service.ts — phát token (thêm vào class ở trên; dòng import lên đầu file)
import { randomBytes, createHash, randomUUID } from 'node:crypto';
import type { EntityManager } from 'typeorm';

export interface TokenPair {
  readonly accessToken: string;
  readonly refreshToken: string;
}

private sha256(value: string): string {
  return createHash('sha256').update(value).digest('hex');
}

// manager tuy chon: khi goi tu trong mot transaction dang mo (xoay vong o
// muc 3.3), insert token moi phai dung CHUNG transaction do voi thao tac
// danh dau token cu — dung repo tiem (khong truyen manager) o day se tach
// hai thao tac ra hai ket noi khac nhau, mat tinh nguyen tu cua ca chuoi.
private async issuePair(user: User, familyId: string, manager?: EntityManager): Promise<TokenPair> {
  const accessToken = await this.jwt.signAsync({ sub: user.id, role: user.role, plan: user.plan });

  // 32 byte ngau nhien tu nguon cua he dieu hanh. Khong dung Math.random().
  const refreshToken = randomBytes(32).toString('base64url');
  const days = this.config.get('REFRESH_TOKEN_TTL_DAYS', { infer: true });
  const repo = manager ? manager.getRepository(RefreshToken) : this.refreshTokens;

  await repo.insert({
    userId: user.id,
    tokenHash: this.sha256(refreshToken),
    familyId,
    expiresAt: new Date(Date.now() + days * 24 * 60 * 60 * 1000),
    usedAt: null,
    replacedByHash: null,
  });

  return { accessToken, refreshToken };
}

async login(email: string, plain: string): Promise<TokenPair> {
  const user = await this.validateCredentials(email, plain);
  // Moi lan dang nhap mo mot family moi — tuc la mot thiet bi.
  return this.issuePair(user, randomUUID());
}
⚠️ Math.random() không dùng được ở đây
Math.random() là bộ sinh giả ngẫu nhiên được tối ưu cho tốc độ, không phải cho việc đoán trước. Biết vài giá trị đầu ra là suy ngược được trạng thái nội bộ và dự đoán các giá trị sau — nghĩa là đoán được refresh token của người khác. randomBytes lấy từ nguồn entropy của hệ điều hành và được thiết kế đúng cho mục đích này. Quy tắc dễ nhớ: bất cứ giá trị ngẫu nhiên nào mà việc đoán được nó gây hại, phải đến từ node:crypto.

Trước khi sang mục sau, để ý dòng cuối cùng của khối trên — nó nhỏ nhưng là chỗ duy nhất trong cả dự án sinh ra một familyId mới: this.issuePair(user, randomUUID()). Từ đó trở đi giá trị này chỉ được chuyền tiếp, không bao giờ tạo mới nữa, và ba quy tắc dưới đây là toàn bộ vòng đời của nó:

  • Đăng nhập mở một family mới. Mỗi lần login() chạy là một randomUUID() mới — nên mỗi thiết bị, mỗi trình duyệt bạn đăng nhập là một chuỗi riêng.
  • Xoay vòng giữ nguyên family. Ở mục 3.3, token mới nhận row.familyId của token vừa dùng, chứ không sinh cái mới — nhờ vậy cả chuỗi xoay vòng của một thiết bị vẫn nhận ra nhau.
  • Phát hiện dùng lại thì xoá cả family. Đó là lý do delete({ familyId }) quét sạch một lượt: kẻ trộm dùng lại một token cũ thì toàn bộ chuỗi của thiết bị đó bị thu hồi, không cần biết nó đang ở token thứ mấy.

3.3. Xoay vòng, và cái bẫy khi hai tab cùng refresh

Đây là hàm quan trọng nhất của mục này. Nó phải làm bốn việc trong cùng một transaction: tìm token, phát hiện tái sử dụng, đánh dấu đã dùng, phát cặp mới. Viết thành hai hàm — một hàm công khai mở transaction, một hàm riêng làm việc thật — vì mục 3.3 bên dưới cần gọi đệ quy vào đúng phần việc thật đó khi gặp khoảng ân hạn.

src/auth/auth.service.ts — xoay vòng — BẢN 1, sẽ được thay ở mục 3.3 bên dưới
async refresh(presented: string): Promise<TokenPair> {
  return this.dataSource.transaction((manager) => this.reissueFrom(manager, this.sha256(presented)));
}

private async reissueFrom(manager: EntityManager, hash: string): Promise<TokenPair> {
  // Khoa dong nay lai. Hai tab cung refresh thi mot cai phai xep hang.
  const row = await manager
    .createQueryBuilder(RefreshToken, 'rt')
    .setLock('pessimistic_write')
    .where('rt.token_hash = :hash', { hash })
    .getOne();

  if (row === null) {
    throw new UnauthorizedException('Refresh token khong hop le');
  }

  // TAI SU DUNG. Ban sao dang ton tai o dau do — dot ca family (xem tiep muc 3.3).
  if (row.usedAt !== null) {
    await manager.delete(RefreshToken, { familyId: row.familyId });
    throw new UnauthorizedException('Phien dang nhap da bi thu hoi');
  }

  if (row.expiresAt.getTime() < Date.now()) {
    throw new UnauthorizedException('Refresh token da het han');
  }

  const user = await manager.findOneOrFail(User, { where: { id: row.userId } });
  const pair = await this.issuePair(user, row.familyId, manager);

  row.usedAt = new Date();
  row.replacedByHash = this.sha256(pair.refreshToken);
  await manager.save(row);

  return pair;
}

Dòng setLock('pessimistic_write') chính là SELECT ... FOR UPDATE của Part 1, và nó ở đây vì một lý do rất đời thường: người dùng mở hai tab.

Access token hết hạn cùng lúc ở cả hai tab, cả hai cùng gửi refresh với cùng một token. Không có khoá, cả hai cùng đọc thấy used_at = null, cả hai cùng cho là hợp lệ, cả hai cùng phát token mới — và bạn có hai nhánh xoay vòng song song từ một gốc, tức là chính cái tình trạng "token bị nhân bản" mà cơ chế này sinh ra để phát hiện. Vài phút sau, một trong hai nhánh chạm vào token đã dùng và người dùng bị đăng xuất dù chẳng ai tấn công cả.

Có khoá thì tab thứ hai xếp hàng, và khi tới lượt nó đọc được used_at đã có giá trị. Nhưng như vậy nó rơi thẳng vào nhánh báo động — cũng đăng xuất người dùng vô tội. Cách xử lý thực dụng là cho token vừa dùng một khoảng ân hạn: nếu nó mới bị dùng trong vòng vài chục giây, đi theo dấu vết tới token đã thay thế nó và phát tiếp từ đó, thay vì hô báo động.

Đây là chỗ cột replaced_by_hash ở mục 3.1 kiếm sống. Nhớ lại dòng cuối của reissueFrom: mỗi lần xoay vòng, token cũ ghi lại row.replacedByHash = this.sha256(pair.refreshToken) — tức là hash của token vừa thay thế nó. Một con trỏ, từ mắt xích cũ sang mắt xích kế tiếp.

Vì sao cần con trỏ đó? Khi tab thứ hai đưa lên một token vừa bị dùng, bạn không thể trả lại chính token đó (nó đã bị đánh dấu used), và cũng không thể coi như không có chuyện gì. Thứ bạn cần là đi tới mắt xích còn sống của chuỗi — mà token cũ thì không biết gì về nó, trừ khi lúc xoay vòng ta đã ghi lại. replaced_by_hash chính là ghi chép đó, và vì reissueFrom vốn tra cứu bằng hash nên con trỏ này dùng thẳng làm khoá tra được. Lưu hash chứ không lưu token gốc, cùng lý do với token_hash: database bị đọc trộm cũng không đổi ra được token dùng thật.

Nói cho chính xác điều xảy ra sau đó, vì nó không hẳn như trực giác: reissueFrom(manager, row.replacedByHash) không trả lại token mà tab thứ nhất đang giữ. Nó tìm tới dòng của token thay thế, thấy dòng đó chưa dùng, nên xoay tiếp một nhịp nữa và trả về một token mới. Kết quả: cả hai tab đều cầm token hợp lệ trong cùng một family, không ai bị đăng xuất. Cái giá là mỗi lần va chạm như vậy tiêu thêm một mắt xích — chấp nhận được, vì nó chỉ xảy ra khi hai tab refresh cách nhau vài giây.

Và đây là ranh giới của cơ chế, đáng nhớ: ân hạn chỉ che trong GRACE_MS. Một tab ngủ quên rồi refresh bằng token đã dùng từ mười phút trước thì vẫn rơi vào nhánh báo động và cả family bị thu hồi — đúng như thiết kế, vì ở khoảng cách đó không còn phân biệt được tab chậm với kẻ trộm.

src/auth/auth.service.ts — đoạn thay cho nhánh if (row.usedAt !== null) bên trong refresh() — không phải file
const GRACE_MS = 30_000;

if (row.usedAt !== null) {
  const age = Date.now() - row.usedAt.getTime();

  if (age < GRACE_MS && row.replacedByHash !== null) {
    // Gan nhu chac chan la tab thu hai, khong phai ke trom. Di theo con tro
    // replaced_by_hash toi token da thay the token nay, roi xoay tiep tu do.
    return this.reissueFrom(manager, row.replacedByHash);
  }

  await manager.delete(RefreshToken, { familyId: row.familyId });
  throw new UnauthorizedException('Phien dang nhap da bi thu hoi');
}
⚠️ Nhánh báo động ở trên không thật sự đốt được gì
Nhìn lại nhánh báo động: await manager.delete(...) rồi throw, cả hai đều nằm trong cùng một dataSource.transaction() đang mở ở refresh(). Khi hàm callback bên trong transaction() ném lỗi, TypeORM tự động rollback toàn bộ những gì đã làm bên trong nó — kể cả lệnh xoá family bạn vừa gọi. Kết quả: request bị từ chối đúng như mong đợi, nhưng family không hề bị đốt. Token cũ vẫn nằm nguyên trong bảng, và lần phát hiện tái sử dụng kế tiếp lặp lại y hệt — cơ chế được quảng cáo là "đốt cả family" thực chất không làm gì cả.

Cách vá: đừng xoá bên trong transaction đang khoá dòng đó. Để reissueFrom trả về một tín hiệu thay vì tự ném lỗi khi gặp tái sử dụng thật, rồi xoá family sau khi transaction đó đã đóng — dùng repository tiêm (this.refreshTokens), không phải manager của transaction vừa kết thúc, để lệnh xoá này chạy trên một giao dịch độc lập, không bị cuốn theo bất kỳ rollback nào:
📌 Vẫn là hàm đó, đổi tên vì hợp đồng của nó đổi
Khối dưới đây đặt tên tryReissue chứ không còn là reissueFrom. Không phải hàm mới: nó thay thế hoàn toàn bản trên, và tên đổi vì hợp đồng đổi. Bản cũ gặp tái sử dụng thì throw ngay tại chỗ; bản mới không ném mà trả về một kết quả { kind: 'ok' | 'reused' } để refresh() tự quyết sau khi transaction đã đóng. Tiền tố try là quy ước cho đúng chuyện đó: hàm trả kết quả thay vì ném lỗi.

Nếu bạn đang bực vì vừa học một cái tên rồi nó bị thay — hợp lý, và đây là lý do đáng để đổi: bản đầu không sai cú pháp, không sai logic khi đọc từng dòng, nó chỉ âm thầm không đốt được family vì rollback. Đọc thẳng bản đúng thì bạn sẽ không bao giờ biết cái bẫy đó tồn tại. Từ đây trở đi chỉ còn tryReissue; bản reissueFrom ở trên không dùng nữa.
src/auth/auth.service.ts — xoay vòng — BẢN CUỐI, THAY TRỌN refresh() ở hai khối trên
// Khai bao o pham vi module, canh DUMMY_HASH — khoi code o muc 3.3 phia tren
// da bi ban nay thay tron, nen dong nay phai co mat o day.
const GRACE_MS = 30_000;

async refresh(presented: string): Promise<TokenPair> {
  const hash = this.sha256(presented);
  const result = await this.dataSource.transaction((manager) => this.tryReissue(manager, hash));

  if (result.kind === 'reused') {
    // Repo tiem mo mot giao dich RIENG, doc lap voi giao dich khoa dong da dong o tren.
    await this.refreshTokens.delete({ familyId: result.familyId });
    throw new UnauthorizedException('Phien dang nhap da bi thu hoi');
  }

  return result.pair;
}

private async tryReissue(
  manager: EntityManager,
  hash: string,
): Promise<{ kind: 'ok'; pair: TokenPair } | { kind: 'reused'; familyId: string }> {
  const row = await manager
    .createQueryBuilder(RefreshToken, 'rt')
    .setLock('pessimistic_write')
    .where('rt.token_hash = :hash', { hash })
    .getOne();

  if (row === null) {
    throw new UnauthorizedException('Refresh token khong hop le');
  }

  if (row.usedAt !== null) {
    const age = Date.now() - row.usedAt.getTime();

    if (age < GRACE_MS && row.replacedByHash !== null) {
      return this.tryReissue(manager, row.replacedByHash);
    }

    // KHONG throw o day. Tin hieu ra ngoai de refresh() xu ly sau khi
    // transaction nay da dong — xem ly do o callout ben tren.
    return { kind: 'reused', familyId: row.familyId };
  }

  if (row.expiresAt.getTime() < Date.now()) {
    throw new UnauthorizedException('Refresh token da het han');
  }

  const user = await manager.findOneOrFail(User, { where: { id: row.userId } });
  const pair = await this.issuePair(user, row.familyId, manager);

  row.usedAt = new Date();
  row.replacedByHash = this.sha256(pair.refreshToken);
  await manager.save(row);

  return { kind: 'ok', pair };
}

Nhắc lại cho chắc: tryReissue thay thế hoàn toàn reissueFrom ở trên — bản cũ tồn tại để bạn thấy được cái bẫy rollback, không phải để giữ lại trong dự án. Cấu trúc tín hiệu ({ kind: 'ok' | 'reused', ... }) thay vì throw ngay là điểm mấu chốt: nó cho refresh() cơ hội chờ transaction đóng xong rồi mới quyết định bước tiếp theo, đúng như nguyên tắc "transaction chỉ nên bảo vệ những gì thật sự cần nguyên tử cùng nhau".

⚠️ Khoảng ân hạn là một đánh đổi, không phải cải tiến
Trong 30 giây đó, một kẻ trộm dùng lại token đúng lúc sẽ không bị phát hiện. Bạn đang đổi một chút an toàn lấy việc người dùng không bị đăng xuất oan. Con số 30 giây không thiêng liêng — nó chỉ cần đủ để che các lần đua thật (thường dưới một giây) và đủ ngắn để cửa sổ tấn công không đáng kể.

Muốn bỏ hẳn đánh đổi này thì phải giải bài toán ở phía client: dồn mọi yêu cầu refresh của cả ứng dụng vào một lời gọi duy nhất. Trong một tab, một biến Promise dùng chung là đủ. Giữa nhiều tab thì cần BroadcastChannel hoặc Web Locks API — làm được, nhưng đó là thêm một hệ thống nữa để hỏng. Ân hạn phía server rẻ hơn nhiều và che được cả trường hợp client cũ.

Cơ chế này bạn sẽ tự chạy thấy ở cuối mục 3.5 — phải chờ tới đó vì demo cần hai endpoint /auth/login/auth/refresh, mà controller phát ra chúng lại là chỗ quyết định chuyện cookie hay body, nên nó thuộc mục 3.5.

3.4. Đăng xuất một thiết bị hay tất cả

Nhờ có family_id, hai loại đăng xuất chỉ khác nhau ở điều kiện WHERE:

src/auth/auth.service.ts — đăng xuất (thêm vào class)
// Dang xuat thiet bi hien tai: xoa dung family cua token dang cam.
async logout(presented: string): Promise<void> {
  const row = await this.refreshTokens.findOne({
    where: { tokenHash: this.sha256(presented) },
  });
  if (row !== null) {
    await this.refreshTokens.delete({ familyId: row.familyId });
  }
}

// Dang xuat moi noi: dung khi doi mat khau hoac nghi bi lo.
async logoutEverywhere(userId: string): Promise<void> {
  await this.refreshTokens.delete({ userId });
}

Access token đã phát vẫn sống thêm tối đa 15 phút sau khi đăng xuất — đúng như đã nói ở mục 2, và đó là cái giá đã biết trước của JWT. Với thao tác thật sự nhạy cảm (đổi email, xoá tài khoản, rút tiền), đừng tin mỗi access token: bắt nhập lại mật khẩu ngay tại chỗ.

AuthService vừa được lắp từng mảnh qua năm khối phía trên — mỗi khối chỉ thêm những gì constructor cần cho riêng phần đó, nên constructor cuối cùng chưa từng xuất hiện trọn vẹn. Ráp lại một lần cho rõ:

src/auth/auth.service.ts — constructor đầy đủ — THAY constructor ở khối đầu mục 1.1
constructor(
  @InjectRepository(User) private readonly users: Repository<User>,
  @InjectRepository(RefreshToken) private readonly refreshTokens: Repository<RefreshToken>,
  private readonly passwords: PasswordService,
  private readonly jwt: JwtService,
  private readonly config: ConfigService<AppConfig, true>,
  private readonly dataSource: DataSource,
) {}

3.5. Trả refresh token qua cookie hay qua body

Hai lựa chọn, và chúng thua ở hai kiểu tấn công khác nhau.

Đặt refresh token vào cookie httpOnly
response.cookie('refresh_token', refreshToken, {
  httpOnly: true, // JavaScript khong doc duoc — chan XSS lay token
  secure: true, // chi gui qua HTTPS
  sameSite: 'strict', // khong gui kem request tu site khac — chan CSRF
  path: '/auth/refresh', // KHONG dinh vao moi request, chi dinh vao dung endpoint nay
  maxAge: days * 24 * 60 * 60 * 1000,
});

Cookie httpOnly miễn nhiễm với XSS: kể cả khi kẻ tấn công chèn được script vào trang, script đó không đọc nổi giá trị cookie. Đổi lại, trình duyệt tự động đính cookie vào request nên nó mở ra CSRF — và đó là lý do sameSite: 'strict' phải có, không phải tuỳ chọn.

Trả qua body thì ngược lại: client tự cất, không tự động gửi đi đâu nên không có CSRF, nhưng chỗ cất nào cũng nằm trong tầm với của JavaScript, tức là trong tầm với của XSS.

💡 Chọn thế nào
Web chạy trên trình duyệt: cookie httpOnly cho refresh token, access token giữ trong biến JavaScript (không phải localStorage). Mất tab thì mất access token — không sao, refresh lấy cái mới.

Ứng dụng di động hoặc client không phải trình duyệt: trả qua body, vì không có khái niệm cookie tự động và các nền tảng đó có kho lưu trữ an toàn riêng.

Dự án này để endpoint hỗ trợ cả hai: đọc refresh token từ cookie trước, không có thì đọc từ body. Chỉ vài dòng, mà sau này thêm ứng dụng di động thì không phải sửa gì.

Ghép mọi thứ ở mục 3 lại thành controller thật — auth.module.ts ở đầu mục đã khai nó rồi:

src/auth/refresh.dto.ts
import { IsOptional, IsString } from 'class-validator';

export class RefreshDto {
  // Chi dung khi client khong phai trinh duyet (khong co cookie tu dong).
  @IsOptional()
  @IsString()
  refreshToken?: string;
}
src/auth/auth.controller.ts
import { Body, Controller, HttpCode, HttpStatus, Post, Req, Res, UnauthorizedException, UseGuards } from '@nestjs/common';
import type { Request, Response } from 'express';
import { ConfigService } from '@nestjs/config';
import { AuthService } from './auth.service';
import { LoginDto } from './login.dto';
import { RefreshDto } from './refresh.dto';
import { JwtAuthGuard } from './jwt-auth.guard';
import { CurrentUser } from './current-user.decorator';
import type { AuthedRequest, JwtPayload } from './jwt-payload';
import type { AppConfig } from '../config/configuration';

const COOKIE_NAME = 'refresh_token';

@Controller('auth')
export class AuthController {
  constructor(
    private readonly auth: AuthService,
    private readonly config: ConfigService<AppConfig, true>,
  ) {}

  @Post('login')
  async login(@Body() dto: LoginDto, @Res({ passthrough: true }) response: Response): Promise<{ accessToken: string }> {
    const pair = await this.auth.login(dto.email, dto.password);
    this.setRefreshCookie(response, pair.refreshToken);
    return { accessToken: pair.accessToken };
  }

  // Doc cookie truoc, khong co thi doc body — dung nhu quyet dinh o muc 3.5.
  @Post('refresh')
  async refresh(
    @Req() request: Request,
    @Body() dto: RefreshDto,
    @Res({ passthrough: true }) response: Response,
  ): Promise<{ accessToken: string }> {
    const presented = this.readPresentedToken(request, dto);
    if (presented === undefined) {
      throw new UnauthorizedException('Thieu refresh token');
    }

    const pair = await this.auth.refresh(presented);
    this.setRefreshCookie(response, pair.refreshToken);
    return { accessToken: pair.accessToken };
  }

  @Post('logout')
  @HttpCode(HttpStatus.NO_CONTENT)
  async logout(@Req() request: Request, @Body() dto: RefreshDto, @Res({ passthrough: true }) response: Response): Promise<void> {
    const presented = this.readPresentedToken(request, dto);
    if (presented !== undefined) {
      await this.auth.logout(presented);
    }
    response.clearCookie(COOKIE_NAME, { path: '/auth/refresh' });
  }

  @Post('logout-everywhere')
  @UseGuards(JwtAuthGuard)
  @HttpCode(HttpStatus.NO_CONTENT)
  async logoutEverywhere(@CurrentUser() user: JwtPayload): Promise<void> {
    await this.auth.logoutEverywhere(user.sub);
  }

  private readPresentedToken(request: Request, dto: RefreshDto): string | undefined {
    return (request as AuthedRequest & Request).cookies?.[COOKIE_NAME] ?? dto.refreshToken;
  }

  private setRefreshCookie(response: Response, refreshToken: string): void {
    const days = this.config.get('REFRESH_TOKEN_TTL_DAYS', { infer: true });

    response.cookie(COOKIE_NAME, refreshToken, {
      httpOnly: true,
      secure: true,
      sameSite: 'strict',
      path: '/auth/refresh',
      maxAge: days * 24 * 60 * 60 * 1000,
    });
  }
}

Controller vừa viết xong vẫn chưa được nạp: AuthModule ở mục 2 mới chỉ khai providers. Thêm nó vào, và đây là lúc route /auth/* thật sự tồn tại:

src/auth/auth.module.ts — thêm controller
import { AuthController } from './auth.controller';

@Module({
  imports: [
    /* ... giu nguyen nhu muc 2 ... */
  ],
  controllers: [AuthController],
  providers: [AuthService, PasswordService],
  exports: [AuthService, JwtModule],
})
export class AuthModule {}

Khai module xong vẫn chưa xong: AppModule chưa biết nó tồn tại nên NestJS không nạp gì trong đó cả. Cắm nó vào ngay bây giờ, đúng chỗ đã cắm BillingModule ở Part 1 mục 8.2 — làm sớm thì mỗi lần lưu file bạn biết ngay có gãy gì không, thay vì dồn tới cuối mục 3 mới phát hiện:

src/app.module.ts — thêm AuthModule
import { AuthModule } from './auth/auth.module';

@Module({
  imports: [
    AppConfigModule,
    TypeOrmModule.forRootAsync({
      /* ... nhu Part 1 ... */
    }),
    BillingModule,
    AuthModule,
  ],
})
export class AppModule {}
📌 Từ đây trở đi bài chỉ nhắc một câu
Mỗi module mới trong loạt bài này đều cần đúng một dòng như vậy trong imports của AppModule. Để khỏi lặp lại cả khối, từ đây bài chỉ viết "thêm XModule vào imports của AppModule" — nghĩa là đúng chỗ này.

Khởi động lại app rồi kiểm nhanh trước khi chạy bài demo dài phía dưới. Số dư credit chưa liên quan gì ở đây, nên một request đăng nhập sai mật khẩu là đủ để biết dây đã thông:

Terminal — kiểm dây trước
curl -i -X POST localhost:3000/auth/login \
  -H 'Content-Type: application/json' \
  -d '{"email":"khong-ton-tai@test.local","password":"sai-mat-khau"}'

# Mong doi: HTTP/1.1 401 Unauthorized
#   401 = route CO THAT, DTO qua duoc, service da chay va tu choi dung.
#   404 = chua cam AuthModule vao AppModule, hoac chua them controllers: [...]
#   400 = ValidationPipe chan body, soi lai login.dto.ts

Endpoint đã có. Giờ chạy được cái đã hẹn ở mục 3.3: tự thấy một token cũ bị dùng lại làm thu hồi cả family.

Terminal — tự thấy cơ chế thu hồi family
# 1. Dang nhap, lay refresh token tu cookie tra ve
OLD=$(curl -s -i -X POST localhost:3000/auth/login \
  -H 'Content-Type: application/json' \
  -d '{"email":"demo@test.local","password":"matkhau123"}' \
  | sed -n 's/.*refresh_token=\([^;]*\).*/\1/p' | tr -d '\r')

# 2. Xoay vong mot lan -> nhan token moi
NEW=$(curl -s -i -X POST localhost:3000/auth/refresh \
  -H 'Content-Type: application/json' -d "{\"refreshToken\":\"$OLD\"}" \
  | sed -n 's/.*refresh_token=\([^;]*\).*/\1/p' | tr -d '\r')

# 3. PHAI CHO qua 30 giay an han, neu khong buoc 4 tra ve 201 chu khong phai 401
sleep 33

# 4. Dung lai token CU -> bao dong, thu hoi ca family
curl -s -X POST localhost:3000/auth/refresh \
  -H 'Content-Type: application/json' -d "{\"refreshToken\":\"$OLD\"}"
# -> 401 {"message":"Phien dang nhap da bi thu hoi"}

# 5. Token MOI cung chet theo, vi ca family da bi xoa
curl -s -X POST localhost:3000/auth/refresh \
  -H 'Content-Type: application/json' -d "{\"refreshToken\":\"$NEW\"}"
# -> 401 {"message":"Refresh token khong hop le"}

Bước 3 là bước dễ bỏ nhất và cũng gây hiểu nhầm nhất. Thử ngay lập tức thì bước 4 trả 201 kèm một access token hợp lệ — trông y như cơ chế phát hiện tái sử dụng không hoạt động, trong khi thật ra đó chính là khoảng ân hạn đang làm đúng việc của nó.

📝 request.cookies cần cookie-parser
Express không tự parse header Cookie thành request.cookies — thiếu cookie-parser, readPresentedToken ở trên luôn nhận undefined từ nhánh cookie, và mọi client trình duyệt sẽ luôn rơi vào lỗi "Thieu refresh token" dù cookie vẫn nằm trong request. Cài đặt và gắn ở main.ts, trước dòng app.listen():

npm i cookie-parser
npm i -D @types/cookie-parser


import cookieParser from 'cookie-parser';
app.use(cookieParser());

4. nginx đứng trước ứng dụng

Đến đây NestJS đang nghe trực tiếp ở cổng 3000. Chạy được, nhưng không nên để vậy trên máy chủ thật. Có một lớp nữa cần dựng, và nó gánh những việc mà Node làm được nhưng làm dở.

Trình duyệt React + TS HTTPS cổng 443 nginx cắt TLS chặn file quá khổ giới hạn tần suất thô HTTP mạng nội bộ NestJS cổng 3000 nghiệp vụ quyền theo người dùng Ranh giới rất rõ: nginx lọc theo thứ nó biết mà không cần hỏi ai — IP, kích thước, giao thức. NestJS quyết theo thứ chỉ nó biết — người này là ai, còn bao nhiêu credit. Việc nào đặt sai chỗ thì hoặc chậm, hoặc không làm được.
nginx/media-forge.conf
upstream app {
    server app:3000;
    keepalive 32;          # giu ket noi lai, khoi bat tay TCP moi request
}

server {
    listen 443 ssl;
    http2 on;
    server_name media-forge.local;

    ssl_certificate     /etc/nginx/certs/fullchain.pem;
    ssl_certificate_key /etc/nginx/certs/privkey.pem;
    ssl_protocols       TLSv1.2 TLSv1.3;

    # Mac dinh cua nginx la 1MB. Khong sua thi upload video nao cung 413.
    client_max_body_size 2g;

    # Doc body cham cung khong sao — upload dai la binh thuong.
    client_body_timeout 300s;

    location / {
        proxy_pass http://app;
        proxy_http_version 1.1;

        # Bon dong nay quyet dinh ung dung "nhin thay" client la ai.
        proxy_set_header Host              $host;
        proxy_set_header X-Real-IP         $remote_addr;
        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;

        # 15s - dung dung ngan sach thoi gian o muc 5.2, khong phai mac dinh cua nginx
        proxy_read_timeout 15s;
    }
}

server {
    listen 80;
    server_name media-forge.local;
    return 301 https://$host$request_uri;
}

File cấu hình nằm đó thì chưa có gì chạy cả. Cần ba thứ nữa để thật sự thấy tầng này hoạt động: một chứng chỉ, một dịch vụ nginx trong Compose, và một cách để hostname app trong khối upstream trỏ về đúng tiến trình NestJS đang chạy.

Terminal — chứng chỉ tự ký để chạy máy mình
mkdir -p docker/certs

# Chung chi tu ky, chi dung o may minh. Tren may chu that thi day la cho
# Let's Encrypt ghi vao, va do la ly do duong dan nam ngoai ung dung.
openssl req -x509 -newkey rsa:2048 -nodes -days 365 \
  -keyout docker/certs/privkey.pem -out docker/certs/fullchain.pem \
  -subj "/CN=media-forge.local" -addext "subjectAltName=DNS:media-forge.local"
docker/docker-compose.yml — THÊM dịch vụ nginx, giữ nguyên dịch vụ postgres đã có
services:
  # ... dich vu postgres da chot o Part 1 muc 4.5, giu nguyen ...

  nginx:
    image: nginx:1.27-alpine
    container_name: forge-nginx
    ports:
      - '8080:80'
      - '8443:443'
    volumes:
      - ../nginx/media-forge.conf:/etc/nginx/conf.d/default.conf:ro
      - ./certs:/etc/nginx/certs:ro
    # Cho hostname `app` trong khoi upstream tro ve may that, noi
    # `npm run start:dev` dang chay. Part 3 dong goi ung dung thanh container
    # ten `app` thi bo dong nay di, con file cau hinh khong phai sua mot chu.
    extra_hosts:
      - 'app:host-gateway'
💡 Vì sao cổng 8080/8443 chứ không phải 80/443
Cổng dưới 1024 cần quyền quản trị trên máy cá nhân, và cổng 443 thường đã có thứ khác chiếm. Ánh xạ ra 8080/8443 là chuyện của riêng máy mình — bên trong container nginx vẫn nghe đúng 80 và 443 như file cấu hình viết, nên không có dòng nào trong media-forge.conf phải đổi.
Terminal — dựng lên và kiểm ba thứ
npm run db -- up -d nginx
npm run start:dev            # o mot cua so khac, van chay tren host nhu truoc

# 1. Cong 80 phai day sang HTTPS
curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' \
  --resolve media-forge.local:8080:127.0.0.1 \
  http://media-forge.local:8080/auth/login
# -> 301 https://media-forge.local/auth/login

# 2. Di xuyen qua nginx toi NestJS. -k vi chung chi tu ky.
curl -sk --resolve media-forge.local:8443:127.0.0.1 \
  -X POST https://media-forge.local:8443/auth/login \
  -H 'Content-Type: application/json' \
  -d '{"email":"demo@test.local","password":"matkhau123"}'
# -> {"accessToken":"eyJhbGciOi..."} — dung request da di qua ca hai tang

# 3. http2 on da co tac dung chua
curl -sk --resolve media-forge.local:8443:127.0.0.1 \
  -o /dev/null -w 'http=%{http_version}\n' https://media-forge.local:8443/
# -> http=2

Từ đây trở đi mọi thứ trong Part 2 đều đi qua cổng 8443 chứ không gọi thẳng cổng 3000 nữa — đó là điều kiện để những mục sau có nghĩa, vì limit_req ở mục 5, header X-Forwarded-For ở mục 4.2 và X-Accel-Redirect ở mục 7.1 đều là việc của nginx. Gọi thẳng 3000 thì cả ba đơn giản là không tồn tại.

4.1. Vì sao TLS dừng lại ở nginx

Node hoàn toàn có thể tự nghe HTTPS. Nhưng để nginx cắt TLS có mấy cái lợi khó bỏ: gia hạn chứng chỉ không cần khởi động lại ứng dụng, đổi bộ thuật toán mã hoá là sửa một file cấu hình, và khi Part 3 chạy nhiều instance Node thì cả cụm dùng chung một chứng chỉ ở một chỗ thay vì mỗi tiến trình một bản. Phần bắt tay TLS cũng là việc nặng CPU mà nginx làm bằng C, còn Node thì mỗi giây dành cho việc đó là một giây không xử lý được nghiệp vụ.

Đoạn từ nginx tới Node đi bằng HTTP thường. Điều đó chấp nhận được vì hai bên nằm cùng một máy hoặc cùng một mạng riêng — và chỉ chấp nhận được với điều kiện đó. Nếu Node mở cổng 3000 ra internet thì mọi thứ ở trên thành vô nghĩa: người ta gọi thẳng cổng 3000, bỏ qua toàn bộ lớp nginx.

4.2. Cái bẫy X-Forwarded-For

Sau khi đặt nginx vào, request.ip trong Node không còn là IP người dùng nữa — nó là IP của nginx. Mọi thứ dựa vào IP đều hỏng theo: nhật ký ghi sai, giới hạn theo IP gom cả thế giới vào một nhóm.

src/main.ts — CHÈN một dòng ngay sau dòng tạo app, giữ nguyên phần còn lại của file
const app = await NestFactory.create<NestExpressApplication>(AppModule);

// So proxy TIN CAY dung truoc ung dung. Dat dung con so, khong dat true.
app.set('trust proxy', 1);
⚠️ trust proxy: true là một lỗ hổng
X-Forwarded-For là một header thường — client tự đặt được. Đặt true, Express tin toàn bộ chuỗi, nên ai cũng gửi kèm X-Forwarded-For: 1.2.3.4 để giả IP và vượt qua mọi giới hạn theo IP.

Đặt con số 1 nghĩa là "chỉ bỏ qua một chặng cuối cùng, phần còn lại do nginx ghi". Con số phải khớp với số proxy thật đứng trước ứng dụng: qua Cloudflare rồi mới tới nginx thì là 2. Đếm sai theo hướng nào cũng sai — thừa thì tin lời client, thiếu thì lấy nhầm IP của proxy.

5. Giới hạn tần suất: hai tầng, hai mục đích

Câu hỏi thường gặp là "đặt rate limit ở nginx hay ở ứng dụng". Câu trả lời là cả hai, vì chúng chặn hai thứ khác nhau.

nginx/media-forge.conf — bản đầy đủ sau khi thêm giới hạn theo IP
upstream app {
    server app:3000;
    keepalive 32;
}

# NGU CANH `http` — hai dong nay phai nam NGOAI moi khoi server.
# 10MB bo nho giu duoc khoang 160.000 dia chi IP.
limit_req_zone $binary_remote_addr zone=general:10m rate=30r/s;
limit_req_zone $binary_remote_addr zone=login:10m   rate=5r/m;

server {
    listen 443 ssl;
    http2 on;
    server_name media-forge.local;

    ssl_certificate     /etc/nginx/certs/fullchain.pem;
    ssl_certificate_key /etc/nginx/certs/privkey.pem;
    ssl_protocols       TLSv1.2 TLSv1.3;

    client_max_body_size 2g;
    client_body_timeout  300s;

    # DOI CHO so voi muc 4: bon dong header gio dat o muc `server` chu khong
    # trong `location`, nen CA HAI location duoi day deu thua ke chung.
    proxy_http_version 1.1;
    proxy_set_header Host              $host;
    proxy_set_header X-Real-IP         $remote_addr;
    proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_read_timeout 15s;

    location /auth/login {
        limit_req zone=login burst=3 nodelay;
        proxy_pass http://app;
    }

    location / {
        limit_req zone=general burst=60 nodelay;
        proxy_pass http://app;
    }
}

server {
    listen 80;
    server_name media-forge.local;
    return 301 https://$host$request_uri;
}
⚠️ Hai ngữ cảnh khác nhau, và cái bẫy khi ghép
limit_req_zone khai báo vùng nhớ dùng chung nên nó thuộc ngữ cảnh http — đặt nhầm vào trong server thì nginx từ chối nạp. Còn limit_req là chỗ sử dụng vùng đó nên nằm trong location. Nhét cả hai vào cùng một chỗ là lỗi "location" directive is not allowed here.

Cái bẫy thứ hai tinh vi hơn: mục 4 để bốn dòng proxy_set_header bên trong location /. Giờ có thêm location /auth/login, mà nginx không cho location con thừa kế từ location anh em — chép thiếu là endpoint đăng nhập mất sạch X-Forwarded-For, và trust proxy vừa đặt ở mục 4.2 lại đọc ra IP của nginx. Đẩy chúng lên mức server như trên là cách gọn nhất để không bao giờ quên.
Terminal — nhìn thấy nó chặn thật
docker exec forge-nginx nginx -t && docker exec forge-nginx nginx -s reload

# rate=5r/m + burst=3 -> vai request dau lot, phan con lai bi chan ngay tai nginx
for i in $(seq 1 8); do
  curl -sk --resolve media-forge.local:8443:127.0.0.1 -o /dev/null -w '%{http_code} ' \
    -X POST https://media-forge.local:8443/auth/login \
    -H 'Content-Type: application/json' \
    -d '{"email":"demo@test.local","password":"sai"}'
done; echo
# -> 400 400 400 400 503 503 503 503

rate=5r/m ở endpoint đăng nhập là để chống dò mật khẩu. burst=3 cho phép dồn ba request vượt mức trước khi từ chối, còn nodelay nghĩa là phục vụ chúng ngay chứ không xếp hàng cho đều — người gõ sai mật khẩu rồi thử lại ngay không phải chờ vô cớ.

Chú ý mã trả về là 503 chứ không phải 429. Đó là mặc định của limit_req, và nó khác hẳn mã 429 mà tầng ứng dụng sẽ trả ở mục 5.1 ngay dưới đây — hai con số khác nhau là dấu hiệu rất tiện để biết request bị chặn ở tầng nào. Muốn nginx cũng trả 429 thì thêm limit_req_status 429;, nhưng giữ nguyên 503 thì đọc log dễ hơn.

Số request lọt qua ở lần chạy đầu có thể không đúng bằng 4: gáo của nginx được tính theo thời gian thật, nên nếu vừa gọi /auth/login ở mục 4 xong thì ngân sách còn ít hơn. Chờ một phút rồi chạy lại sẽ thấy đúng bốn request đầu lọt.

Nhưng nginx chỉ biết địa chỉ IP. Nó không biết người dùng này gói miễn phí hay trả tiền, không biết hôm nay họ đã chuyển mã bao nhiêu video. Cả một văn phòng đi chung một IP thì với nginx họ là một người. Giới hạn theo người dùng phải nằm trong ứng dụng, và cần một chỗ đếm dùng chung cho mọi instance — tức là Redis.

5.1. Token bucket trên Redis

Thuật toán ở đây là gáo token: mỗi người có một gáo, token nhỏ vào đều đặn theo thời gian, mỗi request múc ra một token, hết token thì bị từ chối. Nó nhỉnh hơn kiểu đếm theo cửa sổ cố định vì cho phép dồn cục ngắn — người dùng im lặng mười phút rồi bắn một loạt vẫn qua được, đúng với cách con người thật sự dùng ứng dụng.

Điều bắt buộc là toàn bộ phép tính phải nguyên tử. Đọc gáo rồi tính rồi ghi lại bằng ba lệnh Redis riêng lẻ là tái lập đúng lỗi double-spend của Part 1, chỉ đổi chỗ xảy ra. Redis chạy script Lua đơn luồng, nên gói cả phép tính vào một script là xong.

src/common/rate-limit/token-bucket.lua
-- KEYS[1] = khoa cua gao, vi du "rl:user:42"
-- ARGV = suc chua, so token moi giay, thoi diem hien tai (giay), so token can lay
local capacity = tonumber(ARGV[1])
local refill   = tonumber(ARGV[2])
local now      = tonumber(ARGV[3])
local want     = tonumber(ARGV[4])

local bucket = redis.call('HMGET', KEYS[1], 'tokens', 'updated')
local tokens = tonumber(bucket[1]) or capacity
local updated = tonumber(bucket[2]) or now

-- Do day lai theo thoi gian da troi qua, khong vuot suc chua.
tokens = math.min(capacity, tokens + (now - updated) * refill)

local allowed = 0
if tokens >= want then
  tokens = tokens - want
  allowed = 1
end

redis.call('HMSET', KEYS[1], 'tokens', tokens, 'updated', now)
-- Gao day lai hoan toan sau capacity/refill giay thi khong con thong tin gi.
redis.call('EXPIRE', KEYS[1], math.ceil(capacity / refill) * 2)

return { allowed, math.floor(tokens) }

Không có vòng lặp nào chạy nền để đổ token. Gáo chỉ được tính lại đúng lúc có người chạm vào nó, dựa trên khoảng thời gian kể từ lần chạm trước. Một triệu người dùng im lặng thì tốn đúng không đồng nào.

File Lua ở trên cần một nơi nạp và chạy nó, mà trước đó thì cần hai thứ chưa hề có trong dự án: thư viện client và một Redis đang chạy. Cả hai đều thêm vào lúc này.

Terminal
npm i ioredis   # bai nay viet voi ioredis@5
docker/docker-compose.yml — THÊM dịch vụ redis, giữ nguyên postgres và nginx đã có
services:
  # ... postgres (Part 1 muc 4.5) va nginx (muc 4) giu nguyen ...

  redis:
    image: redis:7-alpine
    container_name: forge-redis
    ports:
      - '6379:6379'
    healthcheck:
      test: ['CMD', 'redis-cli', 'ping']
      interval: 5s
      timeout: 3s
      retries: 10
Terminal
npm run db -- up -d redis
docker exec forge-redis redis-cli ping
# -> PONG

Biến REDIS_URL đã khai ở mục 2 với .default('redis://localhost:6379'), nên không phải sửa .env. Mặt trái của cái mặc định đó: thiếu Redis thì ứng dụng vẫn khởi động bình thường rồi mới chết lúc guard chạy request đầu tiên — khác hẳn kiểu dừng ngay từ đầu của JWT_SECRET.

Giờ mới tới chỗ nạp file Lua. RedisService kế thừa thẳng từ client ioredis — mọi lệnh Redis thô (xadd, publish, duplicate()...) dùng ở Part 3 đều là method có sẵn từ lớp cha, chỉ runTokenBucket() là method riêng thêm vào cho gáo token ở đây:

src/redis/redis.service.ts
import { Injectable, OnModuleDestroy } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import Redis from 'ioredis';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import type { AppConfig } from '../config/configuration';

@Injectable()
export class RedisService extends Redis implements OnModuleDestroy {
  constructor(config: ConfigService<AppConfig, true>) {
    super(config.get('REDIS_URL', { infer: true }));

    this.defineCommand('tokenBucket', {
      numberOfKeys: 1,
      lua: readFileSync(join(__dirname, '../common/rate-limit/token-bucket.lua'), 'utf8'),
    });
  }

  // Chu ky khop dung file .lua: capacity, refillPerSecond, now (giay), want.
  // defineCommand gan method nay luc runtime nen TypeScript khong tu suy ra
  // duoc kieu — khai bao tay o day de phan con lai dung kieu that.
  async runTokenBucket(
    key: string,
    capacity: number,
    refillPerSecond: number,
    now: number,
    want: number,
  ): Promise<[allowed: number, remaining: number]> {
    const call = this as unknown as {
      tokenBucket(key: string, capacity: number, refill: number, now: number, want: number): Promise<[number, number]>;
    };
    return call.tokenBucket(key, capacity, refillPerSecond, now, want);
  }

  onModuleDestroy(): void {
    this.disconnect();
  }
}
📝 File .lua biến mất sau nest build
nest build chỉ biên dịch .ts — nó không tự copy token-bucket.lua sang dist/, nên bản build production readFileSync ở trên báo ENOENTnpm run start:dev (chạy qua ts-node, đọc thẳng từ src/) không hề gặp lỗi này. Khai báo tường minh trong nest-cli.json để asset này được copy đúng chỗ:

"compilerOptions": {
  "assets": [{ "include": "common/rate-limit/*.lua", "outDir": "dist" }],
  "watchAssets": true
}


outDir ở đây phải khớp với vị trí thật của main.js sau khi build — TypeScript tự suy ra rootDir từ tập file được biên dịch, nên tuỳ cấu hình tsconfig.json của từng dự án, dist/main.js có thể nằm ở dist/ hoặc dist/src/. Chạy find dist -iname main.js để biết chắc, rồi đặt outDir đúng thư mục cha của file đó — đặt sai thì lỗi ENOENT vẫn còn nguyên, chỉ khác đường dẫn báo lỗi.

RedisService là một provider, nên nó cần một module công bố mình ra. Module này rất ngắn nhưng bỏ nó đi thì mọi thứ dựa vào Redis từ đây tới hết Part 3 đều không tiêm được:

src/redis/redis.module.ts
import { Global, Module } from '@nestjs/common';
import { RedisService } from './redis.service';

// @Global vi gan nhu module nao cung can Redis (rate limit o day, hang doi va
// WebSocket o Part 3) — khai bao mot lan, khoi import lai o khap noi.
@Global()
@Module({
  providers: [RedisService],
  exports: [RedisService],
})
export class RedisModule {}

Rồi thêm RedisModule vào mảng imports của AppModule, đúng chỗ đã thêm AuthModule ở mục 2.

src/common/rate-limit/rate-limit.guard.ts
import { CanActivate, ExecutionContext, HttpException, HttpStatus, Injectable } from '@nestjs/common';
import type { AuthedRequest } from '../../auth/jwt-payload';
import { RedisService } from '../../redis/redis.service';

interface BucketRule {
  readonly capacity: number;
  readonly refillPerSecond: number;
}

const RULES: Readonly<Record<'free' | 'pro', BucketRule>> = {
  free: { capacity: 20, refillPerSecond: 0.2 }, // ~12 request/phut
  pro: { capacity: 200, refillPerSecond: 2 },
};

@Injectable()
export class RateLimitGuard implements CanActivate {
  constructor(private readonly redis: RedisService) {}

  async canActivate(context: ExecutionContext): Promise<boolean> {
    const request = context.switchToHttp().getRequest<AuthedRequest>();
    const rule = RULES[request.user.plan];

    const [allowed, remaining] = await this.redis.runTokenBucket(
      `rl:user:${request.user.sub}`,
      rule.capacity,
      rule.refillPerSecond,
      Math.floor(Date.now() / 1000),
      1,
    );

    const response = context.switchToHttp().getResponse<{ setHeader: (k: string, v: string) => void }>();
    response.setHeader('X-RateLimit-Remaining', String(remaining));

    if (allowed === 0) {
      // 429 kem Retry-After — client biet cho bao lau thay vi thu lien tuc.
      throw new HttpException(
        { message: 'Vuot han muc, thu lai sau it phut' },
        HttpStatus.TOO_MANY_REQUESTS,
      );
    }
    return true;
  }
}
🔍 Cái nào đặt ở đâu
nginx chặn lưu lượng: một IP bắn 10.000 request/giây. Chặn ở đây thì request không bao giờ chạm tới Node, không tốn một vòng event loop nào. Đây là tuyến phòng thủ trước cạn kiệt tài nguyên.

Ứng dụng chặn hạn mức: tài khoản miễn phí chỉ được chuyển mã 5 video mỗi ngày. Đây là quyết định nghiệp vụ, cần biết người dùng là ai — nginx không thể biết vì thông tin đó nằm trong JWT mà nó không giải mã.

Đặt nhầm chỗ thì hoặc phải giải mã JWT trong nginx (làm được, nhưng nghiệp vụ rơi vào file cấu hình), hoặc để một trận lụt request chạy hết vào Node rồi mới từ chối — tức là vẫn chịu toàn bộ chi phí.

5.2. Ngân sách thời gian

Một request đi qua nhiều tầng, mỗi tầng có timeout riêng. Nếu các con số đó không được đặt theo một thứ tự có chủ đích, bạn sẽ gặp tình huống rất khó gỡ: nginx bỏ cuộc và trả 504 cho người dùng, trong khi Node vẫn đang cần mẫn chạy tiếp một truy vấn mà kết quả không còn ai nhận.

Ngân sách thời gian, tính từ trong ra ngoài
Truy van database   :  5s   (statement_timeout)
Handler NestJS      : 10s   (interceptor timeout)
proxy_read_timeout  : 15s   (nginx)
Client              : 20s   (fetch + AbortController)

Nguyen tac: tang ngoai LUON dai hon tang trong.
Nguoc lai thi tang ngoai bo cuoc truoc, tang trong van chay — ton tai nguyen
cho mot ket qua khong con ai doi, va nhat ky ghi "thanh cong" cho mot request
ma nguoi dung da thay bao loi.

Trong bốn con số đó mới có đúng một cái là thật: proxy_read_timeout 15s đã nằm trong media-forge.conf từ mục 4. Ba cái còn lại phải tự đặt, và mỗi cái đặt ở một chỗ khác nhau. Tầng trong cùng là một dòng trong tuỳ chọn TypeORM — Postgres tự huỷ truy vấn quá hạn, không cần ai canh:

src/database/typeorm.options.ts — THÊM một dòng vào sharedOptions
  // Bat buoc false. Xem canh bao ben duoi.
  synchronize: false,

  // Tang trong cung cua ngan sach thoi gian. Postgres tu huy truy van qua 5s,
  // thay vi de no chay tiep khi khong con ai doi ket qua nua.
  extra: { statement_timeout: 5_000 },

Tầng giữa là một interceptor toàn cục. Nó dùng rxjs, vốn đã có sẵn cùng NestJS nên không phải cài thêm gì:

src/common/timeout/timeout.interceptor.ts
import { CallHandler, ExecutionContext, Injectable, NestInterceptor, RequestTimeoutException } from '@nestjs/common';
import { Observable, TimeoutError, throwError } from 'rxjs';
import { catchError, timeout } from 'rxjs/operators';

// Dai hon statement_timeout (5s) de truy van kip bao loi truoc, va ngan hon
// proxy_read_timeout (15s) de nginx khong bo cuoc truoc ung dung.
const HANDLER_TIMEOUT_MS = 10_000;

@Injectable()
export class TimeoutInterceptor implements NestInterceptor {
  intercept(_context: ExecutionContext, next: CallHandler): Observable<unknown> {
    return next.handle().pipe(
      timeout(HANDLER_TIMEOUT_MS),
      catchError((error: unknown) =>
        throwError(() => (error instanceof TimeoutError ? new RequestTimeoutException() : error)),
      ),
    );
  }
}
src/main.ts — CHÈN một dòng sau useGlobalPipes, giữ nguyên phần còn lại
app.useGlobalInterceptors(new TimeoutInterceptor());
📝 Kiểm rằng tầng trong cùng có thật
extra của TypeORM đi thẳng xuống driver pg, nên dễ tưởng đã đặt mà thực ra không. Cách nhanh nhất để biết chắc là hỏi thẳng Postgres qua chính AppDataSource đã viết ở Part 1 mục 7: SHOW statement_timeout phải trả về 5s, và SELECT pg_sleep(7) phải chết với canceling statement due to statement timeout thay vì chạy đủ bảy giây.

Tầng ngoài cùng — AbortController phía trình duyệt — thuộc về giao diện, nên nó nằm ở mục 8 cùng với api.ts.

6. Upload: nhận file 2 GB mà không phình bộ nhớ

Đây là chỗ mọi hướng dẫn "upload trong Node" đều dạy sai, vì với file 2 MB thì cách sai vẫn chạy tốt.

Cách sai — hoạt động hoàn hảo cho tới khi không
@Post('upload')
@UseInterceptors(FileInterceptor('file'))
upload(@UploadedFile() file: Express.Multer.File) {
  // file.buffer — CA FILE dang nam trong RAM.
  // 10 nguoi upload 2GB cung luc = 20GB. Tien trinh chet.
  return this.media.save(file.buffer);
}

Vấn đề không phải multer, mà là chế độ mặc định của nó: gom toàn bộ body vào một Buffer rồi mới gọi handler. Cách đúng là không bao giờ giữ cả file — đọc từng mẩu, ghi từng mẩu, mẩu đã ghi xong thì thả cho bộ nhớ thu hồi. Đó chính là stream.

src/media/upload.controller.ts
import { Controller, Post, Req, UseGuards, BadRequestException } from '@nestjs/common';
import { createWriteStream } from 'node:fs';
import { pipeline } from 'node:stream/promises';
import { randomUUID } from 'node:crypto';
import { join } from 'node:path';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { RateLimitGuard } from '../common/rate-limit/rate-limit.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import type { AuthedRequest, JwtPayload } from '../auth/jwt-payload';
import { MediaService } from './media.service';

@Controller('media')
@UseGuards(JwtAuthGuard, RateLimitGuard)
export class UploadController {
  constructor(private readonly media: MediaService) {}

  @Post('upload')
  async upload(
    @Req() request: AuthedRequest,
    @CurrentUser() user: JwtPayload,
  ): Promise<{ videoId: string }> {
    const contentLength = Number(request.headers['content-length'] ?? 0);
    if (contentLength > 2 * 1024 * 1024 * 1024) {
      throw new BadRequestException('File vuot qua 2GB');
    }

    const videoId = randomUUID();
    const target = join(this.media.uploadDir, `${videoId}.bin`);

    // pipeline lo backpressure va don dep khi co loi. Tu goi .pipe()
    // thi phai tu bat 'error' o ca hai dau, quen mot ben la ro file.
    await pipeline(request, createWriteStream(target));

    // Ten file goc di kem qua header rieng — body la bytes thuan tuy, khong
    // phai multipart, nen khong the doc file.name theo cach thuong (xem
    // src/lib/upload.ts o muc 8.2, noi header nay duoc dat tu phia client).
    const originalName = request.headers['x-filename'];
    const decodedName = typeof originalName === 'string' ? decodeURIComponent(originalName) : null;

    const video = await this.media.registerUpload(videoId, user.sub, target, decodedName);
    return { videoId: video.id };
  }
}
src/media/media.service.ts
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { ConfigService } from '@nestjs/config';
import { basename } from 'node:path';
import { Video } from './video.entity';
import type { AppConfig } from '../config/configuration';

export interface PlayableAsset {
  readonly path: string;
  readonly sizeBytes: number;
  readonly storageKey: string;
}

@Injectable()
export class MediaService {
  readonly uploadDir: string;
  readonly tempDir: string;

  constructor(
    @InjectRepository(Video) private readonly videos: Repository<Video>,
    config: ConfigService<AppConfig, true>,
  ) {
    this.uploadDir = config.get('UPLOAD_DIR', { infer: true });
    this.tempDir = config.get('TEMP_UPLOAD_DIR', { infer: true });
  }

  // videoId da duoc UploadController sinh TRUOC khi ghi file (de dat ten file
  // theo id) — luu voi id da co san thay vi de TypeORM tu sinh.
  async registerUpload(
    videoId: string,
    userId: string,
    path: string,
    originalName: string | null,
  ): Promise<Video> {
    return this.videos.save(
      this.videos.create({ id: videoId, userId, originalKey: path, originalName }),
    );
  }

  // Dung boi stream.controller.ts o muc 7. Kiem userId ngay tai day: video
  // khong thuoc ve nguoi goi thi coi nhu khong ton tai, khong tiet lo no co
  // ton tai o dau khac hay khong.
  async findPlayable(id: string, userId: string): Promise<PlayableAsset> {
    const video = await this.videos.findOne({ where: { id, userId } });
    if (video === null) {
      throw new NotFoundException('Khong tim thay video');
    }
    return {
      path: video.originalKey,
      sizeBytes: Number(video.size ?? 0),
      // storageKey la duong dan TUONG DOI ma nginx alias /var/media/ ghep
      // vao — dung khi uploadDir chinh la /var/media (xem docker-compose Part 3).
      storageKey: basename(video.originalKey),
    };
  }
}
📝 findPlayable chưa từng được viết ra
stream.controller.ts ở mục 7 gọi this.media.findPlayable(id, userId) và kỳ vọng nó trả về { path, sizeBytes, storageKey } — nhưng method này chưa từng tồn tại, và tên trường trong đó cũng không khớp thẳng với entity (Video dùng originalKey/size, không phải path/sizeBytes). Viết ra ở đây, ánh xạ đúng tên cột thật sang hình dạng mà controller cần.

Thêm một biến môi trường cho thư mục lưu file — docker-compose.yml ở mục 5 của Part 3 gắn một volume vào đúng đường dẫn này để API và worker cùng nhìn thấy một nơi:

src/config/configuration.ts — CHỈ thêm hai dòng vào configSchema, giữ nguyên phần còn lại của file
UPLOAD_DIR: z.string().default('./uploads'),
TEMP_UPLOAD_DIR: z.string().default('./uploads/tmp'),

Hai biến này có .default() nên không phải sửa .env, nhưng createWriteStream thì không tự tạo thư mục. Thiếu nó, lỗi ENOENT nổ ra ở giữa lúc đang đọc body — tức là người dùng đợi hết cả file rồi mới nhận 500, chứ không phải một lỗi cấu hình gọn gàng lúc khởi động. Tạo trước một lần:

Terminal
mkdir -p uploads/tmp

Còn thiếu mảnh cuối: cả controller lẫn service ở trên đều chưa thuộc module nào, nên NestJS không hề biết chúng tồn tại. Module này có một chi tiết dễ vấp — nó phải import AuthModule:

src/media/media.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Video } from './video.entity';
import { MediaService } from './media.service';
import { UploadController } from './upload.controller';
import { AuthModule } from '../auth/auth.module';

@Module({
  // forFeature: cong bo repository cua Video cho MediaService tiem vao.
  // AuthModule: vi JwtAuthGuard tiem JwtService — guard duoc phan giai trong
  // module DUNG no, khong phai module khai no.
  imports: [TypeOrmModule.forFeature([Video]), AuthModule],
  controllers: [UploadController],
  providers: [MediaService],
  exports: [MediaService],
})
export class MediaModule {}
⚠️ Guard được phân giải ở module dùng nó
Bỏ AuthModule khỏi imports thì ứng dụng không khởi động nổi, với thông báo dễ gây hiểu lầm: Nest can't resolve dependencies of the JwtAuthGuard (?)... argument JwtService at index [0] is available in the MediaModule module. Lỗi chỉ vào MediaModuleJwtAuthGuard nằm trong thư mục auth/ — vì decorator @UseGuards nằm ở UploadController, nên NestJS đi tìm JwtService trong ngữ cảnh của MediaModule. AuthModule đã exports: [AuthService, JwtModule] từ mục 2 chính là để chỗ này dùng được.

Thêm MediaModule vào imports của AppModule rồi thử thật:

Terminal — upload thật, và đo bộ nhớ trong lúc upload
head -c 838860800 /dev/urandom > /tmp/big.bin      # mot file 800MB

TOK=$(curl -sk --resolve media-forge.local:8443:127.0.0.1 \
  -X POST https://media-forge.local:8443/auth/login \
  -H 'Content-Type: application/json' \
  -d '{"email":"demo@test.local","password":"matkhau123"}' \
  | python3 -c 'import sys,json;print(json.load(sys.stdin)["accessToken"])')

PID=$(lsof -ti:3000 | head -1)
ps -o rss= -p $PID                                 # bo nho TRUOC khi upload

curl -sk --resolve media-forge.local:8443:127.0.0.1 \
  -X POST https://media-forge.local:8443/media/upload \
  -H "Authorization: Bearer $TOK" \
  -H 'Content-Type: application/octet-stream' \
  --data-binary @/tmp/big.bin -D- -w '\nhttp=%{http_code}\n'

ps -o rss= -p $PID                                 # va SAU khi upload xong

Kết quả đo trên máy viết bài: file 800 MB đi trọn vẹn, trả về {"videoId":"5df79e85-..."} với http=201, và bộ nhớ tiến trình đi từ 212 MB lên đỉnh 262 MB — tăng khoảng 50 MB, không phải 800. Đó là toàn bộ luận điểm của mục này gói trong một con số: cách sai sẽ cho ra một tiến trình phình đúng bằng kích thước file.

Trong đống header trả về còn một dòng đáng chú ý: x-ratelimit-remaining: 19. Nó chứng minh RateLimitGuard ở mục 5.1 đã chạy thật — gáo token 20 của gói free vừa bị múc mất một, và con số đó đi qua Redis chứ không nằm trong bộ nhớ tiến trình.

6.1. Backpressure, nói cho gọn

Mạng đưa dữ liệu vào nhanh hơn ổ đĩa ghi ra. Nếu cứ nhận bao nhiêu nhét vào bấy nhiêu, phần chênh lệch chất đống trong RAM — và ta quay lại đúng vấn đề vừa tránh, chỉ khó thấy hơn.

Backpressure là cơ chế cho phép đầu ghi nói với đầu đọc: khoan đã. Cụ thể, writable.write() trả về false khi bộ đệm nội bộ đã đầy, và bên đọc phải tạm dừng cho tới khi có sự kiện 'drain'. pipeline làm toàn bộ việc đó, kể cả phần khó nhất là dọn dẹp: một đầu lỗi thì nó huỷ đầu kia và đóng mọi thứ.

Vì sao pipeline chứ không phải pipe
// TU LAM — bo qua rat nhieu truong hop
request.pipe(createWriteStream(target)); // client ngat giua chung?
                                         // dia day? -> file rac nam lai,
                                         // handle khong dong

// PIPELINE — mot dau hong thi huy ca chuoi, dong sach
await pipeline(request, createWriteStream(target));
💡 Kiểm chứng bằng mắt, không cần tin lời ai
Thêm một dòng đo bộ nhớ vào handler rồi upload thử một file 1 GB:

setInterval(() => console.log(process.memoryUsage().rss / 1e6), 1000);

Bản dùng FileInterceptorrss leo dần lên hơn một nghìn. Bản dùng pipeline đi ngang, chênh lệch chỉ vài chục megabyte bất kể file lớn cỡ nào — vì tại mỗi thời điểm trong bộ nhớ chỉ có vài mẩu đang trên đường từ socket sang đĩa.

6.2. Upload tiếp tục được sau khi mất mạng

Upload 2 GB qua mạng di động thì đứt giữa chừng là chuyện thường. Bắt người dùng làm lại từ đầu là cách nhanh nhất để họ bỏ đi. Cách chữa không cần giao thức gì cao siêu, chỉ cần chia nhỏ và ghi nối:

src/media/upload.controller.ts — THÊM method vào class đã có ở mục 6
import { mkdir, readdir } from 'node:fs/promises';

// Va bo sung `Get` cung `Param` vao dong import @nestjs/common da co san:
// import { Controller, Post, Get, Param, Req, UseGuards, BadRequestException } from '@nestjs/common';

// Client chia file thanh cac manh 5MB va gui lan luot.
@Post('upload/:uploadId/chunk/:index')
async uploadChunk(
  @Param('uploadId') uploadId: string,
  @Param('index') index: string,
  @Req() request: AuthedRequest,
) {
  const target = join(this.media.tempDir, uploadId, `${index}.part`);
  // Thu muc con theo tung uploadId chua tung duoc tao truoc do.
  await mkdir(join(this.media.tempDir, uploadId), { recursive: true });
  await pipeline(request, createWriteStream(target));
  return { received: Number(index) };
}

// Truoc khi gui tiep, client hoi: toi da gui toi dau roi?
@Get('upload/:uploadId/status')
async status(@Param('uploadId') uploadId: string) {
  const parts = await readdir(join(this.media.tempDir, uploadId));
  return { received: parts.map((p) => Number(p.replace('.part', ''))).sort((a, b) => a - b) };
}

Mất mạng, mở lại trang, client gọi status, biết mảnh 0–37 đã tới nơi, gửi tiếp từ mảnh 38. Ghép các mảnh lại là việc của bước cuối, và nó cũng chạy bằng stream chứ không đọc hết vào bộ nhớ.

src/media/upload.controller.ts — THÊM method ghép mảnh vào cùng class đó
import { createReadStream } from 'node:fs';
import { rm } from 'node:fs/promises';

// Client goi endpoint nay khi da gui xong tat ca cac manh.
@Post('upload/:uploadId/complete')
async completeUpload(
  @Param('uploadId') uploadId: string,
  @CurrentUser() user: JwtPayload,
): Promise<{ videoId: string }> {
  const tempPath = join(this.media.tempDir, uploadId);
  const parts = await readdir(tempPath);
  const ordered = parts.map((p) => Number(p.replace('.part', ''))).sort((a, b) => a - b);

  const videoId = randomUUID();
  const target = join(this.media.uploadDir, `${videoId}.bin`);
  const out = createWriteStream(target);

  // MOT writeStream duy nhat cho ca file dich. { end: false } de pipeline
  // khong tu dong voi moi manh, chi dong that su o cuoi vong lap.
  for (const index of ordered) {
    await pipeline(createReadStream(join(tempPath, `${index}.part`)), out, { end: false });
  }
  out.end();

  // Da ghep xong, thu muc chua tung manh khong con can nua.
  await rm(tempPath, { recursive: true, force: true });

  const video = await this.media.registerUpload(videoId, user.sub, target, null);
  return { videoId: video.id };
}
Terminal — đứt giữa chừng rồi gửi tiếp, đúng kịch bản ở trên
head -c 26214400 /dev/urandom > /tmp/chunky.bin   # 25MB
split -b 5242880 /tmp/chunky.bin /tmp/part-       # thanh 5 manh 5MB
UP=$(uuidgen)

# Gui 3 manh dau roi dung lai — gia lam mat mang
i=0; for f in $(ls /tmp/part-* | head -3); do
  curl -sk --resolve media-forge.local:8443:127.0.0.1 \
    -X POST "https://media-forge.local:8443/media/upload/$UP/chunk/$i" \
    -H "Authorization: Bearer $TOK" -H 'Content-Type: application/octet-stream' \
    --data-binary @$f; i=$((i+1));
done
# -> {"received":0} {"received":1} {"received":2}

# Mo lai trang: toi da gui toi dau roi?
curl -sk --resolve media-forge.local:8443:127.0.0.1 \
  "https://media-forge.local:8443/media/upload/$UP/status" -H "Authorization: Bearer $TOK"
# -> {"received":[0,1,2]}   -> gui tiep tu manh 3

i=3; for f in $(ls /tmp/part-* | tail -2); do
  curl -sk --resolve media-forge.local:8443:127.0.0.1 \
    -X POST "https://media-forge.local:8443/media/upload/$UP/chunk/$i" \
    -H "Authorization: Bearer $TOK" -H 'Content-Type: application/octet-stream' \
    --data-binary @$f; i=$((i+1));
done

curl -sk --resolve media-forge.local:8443:127.0.0.1 \
  -X POST "https://media-forge.local:8443/media/upload/$UP/complete" \
  -H "Authorization: Bearer $TOK" -H 'Content-Type: application/json'
# -> {"videoId":"33003ecf-..."}

# Phep thu that su: file ghep lai co dung tung byte khong?
shasum -a 256 /tmp/chunky.bin
shasum -a 256 uploads/<videoId>.bin
# -> hai chuoi bam GIONG HET nhau

Hai chuỗi băm khớp nhau là bằng chứng gọn nhất rằng thứ tự ghép đúng và { end: false } đã làm đúng việc của nó — sai một trong hai thì file vẫn tạo ra được, vẫn đúng dung lượng, chỉ có nội dung là hỏng. Thư mục uploads/tmp/<uploadId> cũng biến mất sau lệnh cuối, đúng như rm ở cuối method.

⚠️ Hai lỗ hổng cố tình để trống ở đây
uploadId đi thẳng từ URL vào join(this.media.tempDir, uploadId). Mà join chuẩn hoá .., nên một uploadId kiểu ../../something ghi được ra ngoài tempDir. Trong dự án thật, phải ép uploadId là UUID hợp lệ trước khi ghép đường dẫn — chính là việc mà ParseUUIDPipe làm.

Thứ hai: không endpoint nào trong ba endpoint kiểm uploadId có thuộc về người gọi hay không. Ai biết được uploadId của người khác thì gọi complete được, và video sẽ về tài khoản của họ. Cách chữa đúng là lưu chủ sở hữu của uploadId ngay từ mảnh đầu tiên rồi đối chiếu ở mọi mảnh sau — cùng một tinh thần với findPlayable(id, userId) ở mục 6.

7. Download: tua được, mà Node gần như không làm gì

Phát lại video có một yêu cầu mà tải file thường không có: tua. Kéo thanh tiến trình tới phút thứ 12, trình duyệt không tải lại từ đầu — nó hỏi xin đúng đoạn byte tương ứng.

Trình duyệt yêu cầu một khoảng byte
GET /media/abc123/play HTTP/1.1
Range: bytes=52428800-

HTTP/1.1 206 Partial Content
Content-Range: bytes 52428800-104857599/104857600
Content-Length: 52428800
Accept-Ranges: bytes
Content-Type: video/mp4

Mã trạng thái là 206 chứ không phải 200, và nếu server bỏ qua header Range mà cứ trả 200 kèm cả file, trình duyệt sẽ vô hiệu hoá tua. Thanh tiến trình vẫn hiện nhưng kéo không được — một lỗi rất hay gặp và rất khó đoán nguyên nhân nếu không biết chỗ này.

src/media/stream.controller.ts
@Get(':id/play')
@UseGuards(JwtAuthGuard)
async play(
  @Param('id') id: string,
  @CurrentUser() user: JwtPayload,
  @Req() request: Request,
  @Res() response: Response,
): Promise<void> {
  const asset = await this.media.findPlayable(id, user.sub);
  const size = asset.sizeBytes;
  const range = request.headers.range;

  if (range === undefined) {
    response.writeHead(200, { 'Content-Length': size, 'Accept-Ranges': 'bytes' });
    await pipeline(createReadStream(asset.path), response);
    return;
  }

  // "bytes=52428800-" hoac "bytes=0-1023"
  const [rawStart, rawEnd] = range.replace('bytes=', '').split('-');
  const start = Number(rawStart);
  const end = rawEnd === undefined || rawEnd === '' ? size - 1 : Number(rawEnd);

  if (Number.isNaN(start) || start >= size || end >= size) {
    response.writeHead(416, { 'Content-Range': `bytes */${size}` });
    response.end();
    return;
  }

  response.writeHead(206, {
    'Content-Range': `bytes ${start}-${end}/${size}`,
    'Content-Length': end - start + 1,
    'Accept-Ranges': 'bytes',
    'Content-Type': 'video/mp4',
  });

  await pipeline(createReadStream(asset.path, { start, end }), response);
}

Đoạn này đúng và chạy được. Nhưng nó có một điểm yếu chỉ lộ ra khi đông người xem: mỗi người đang xem chiếm một luồng đọc đĩa và một chuỗi stream trong tiến trình Node. Một nghìn người xem đồng thời là một nghìn thứ như vậy, và Node đang bận bê byte từ đĩa ra mạng — công việc mà nó chẳng đóng góp giá trị gì.

7.1. X-Accel-Redirect: giao việc bê vác cho nginx

Ý tưởng là tách đôi: NestJS quyết định có được xem không, nginx lo phần gửi byte. Ứng dụng trả về một response rỗng kèm một header trỏ tới file, nginx thấy header đó thì tự phục vụ file — bằng sendfile, tức là dữ liệu đi thẳng từ đĩa ra socket trong nhân hệ điều hành, không đi qua tiến trình nào ở tầng ứng dụng.

nginx/media-forge.conf — THÊM location này vào trong khối server nghe cổng 443
location /protected-media/ {
    internal;                 # go thang URL nay tu ngoai vao -> 404
    alias /var/media/;
    sendfile on;
    tcp_nopush on;
}
⚠️ /var/media/ chưa tồn tại với container nginx
alias /var/media/ là đường dẫn bên trong container nginx, còn file thì đang nằm ở ./uploads trên máy mình theo UPLOAD_DIR ở mục 6. Không nối hai chỗ đó lại thì nginx trả 404 cho mọi lượt xem, và lỗi trông y hệt như signed URL bị sai. Thêm một dòng mount vào dịch vụ nginx đã dựng ở mục 4:

volumes:
  - ../nginx/media-forge.conf:/etc/nginx/conf.d/default.conf:ro
  - ./certs:/etc/nginx/certs:ro
  - ../uploads:/var/media:ro


Chỉ đọc (:ro) là đủ và nên như vậy: nginx chỉ gửi file đi, việc ghi file là của NestJS. Ở Part 3, khi API và worker cùng chạy trong Compose, chỗ này thành một volume dùng chung và UPLOAD_DIR đổi thẳng thành /var/media — lúc đó không còn phải ánh xạ gì nữa.
src/media/stream.controller.ts — bản giao việc cho nginx
@Get(':id/play')
@UseGuards(JwtAuthGuard)
async play(
  @Param('id') id: string,
  @CurrentUser() user: JwtPayload,
  @Res() response: Response,
): Promise<void> {
  // Nest van lam phan kho: kiem tra quyen, han muc, trang thai video.
  const asset = await this.media.findPlayable(id, user.sub);

  response.setHeader('X-Accel-Redirect', `/protected-media/${asset.storageKey}`);
  response.setHeader('Content-Type', 'video/mp4');
  response.end(); // Body rong. nginx thay header tren va tu gui file.
}

Toàn bộ phần Range viết tay ở trên biến mất — nginx xử lý 206, Content-Range, 416 đầy đủ và đúng chuẩn hơn bản tự viết. Node chỉ còn chạy một truy vấn kiểm tra quyền rồi trả về response rỗng, tính bằng mili giây. Từ một nghìn stream mở trong tiến trình xuống còn không cái nào.

🔍 Vì sao không đơn giản là cho tải trực tiếp
Nếu để file nằm trong thư mục nginx phục vụ công khai thì đúng là nhanh nhất — và ai có đường dẫn cũng xem được, mãi mãi. Chỉ thị internal là thứ ngăn điều đó: vùng /protected-media/ chỉ đáp ứng khi lệnh đến từ chính ứng dụng, gõ thẳng từ trình duyệt thì nhận 404.

Kết quả là ta được cả hai: mỗi lượt xem vẫn đi qua một lần kiểm tra quyền thật trong NestJS, nhưng không byte dữ liệu nào phải chui qua Node.

7.2. Khi thẻ <video> không gửi được header

Có một trở ngại thực tế: <video src="..."> do trình duyệt tự tải, và bạn không chen được header Authorization vào đó. Cách phổ biến là URL có chữ ký — nhét quyền vào chính đường dẫn, kèm hạn dùng ngắn.

src/media/signed-url.service.ts
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { createHmac, timingSafeEqual } from 'node:crypto';
import type { AppConfig } from '../config/configuration';

@Injectable()
export class SignedUrlService {
  constructor(private readonly config: ConfigService<AppConfig, true>) {}

  private get secret(): string {
    return this.config.get('SIGNED_URL_SECRET', { infer: true });
  }

  sign(assetId: string, userId: string, ttlSeconds = 300): string {
    const expires = Math.floor(Date.now() / 1000) + ttlSeconds;
    const payload = `${assetId}:${userId}:${expires}`;
    const signature = createHmac('sha256', this.secret).update(payload).digest('base64url');
    return `/media/${assetId}/play?u=${userId}&e=${expires}&s=${signature}`;
  }

  verify(assetId: string, userId: string, expires: number, signature: string): boolean {
    if (expires < Math.floor(Date.now() / 1000)) return false;

    const expected = createHmac('sha256', this.secret)
      .update(`${assetId}:${userId}:${expires}`)
      .digest('base64url');

    const a = Buffer.from(signature);
    const b = Buffer.from(expected);
    // So sanh thoi gian hang so. Dung === thi thoat som o byte dau tien khac nhau,
    // va chenh lech thoi gian do duoc de do dan tung byte cua chu ky.
    return a.length === b.length && timingSafeEqual(a, b);
  }
}
⚠️ play() ở mục 7.1 mâu thuẫn với chính lý do URL có chữ ký tồn tại
Nhìn lại play() vừa viết: nó bắt @UseGuards(JwtAuthGuard), tức là đòi header Authorization. Nhưng URL có chữ ký sinh ra chính vì thẻ <video src="..."> không gửi được header đó — route mà nó trỏ tới phải chấp nhận yêu cầu không kèm Bearer token. Giữ nguyên JwtAuthGuard ở đó thì mọi request từ <video> luôn nhận 401, và verify() vừa viết không bao giờ được gọi tới. Nối lại cho đúng — thêm endpoint xin URL (đòi xác thực bình thường, vì đây là một lời gọi fetch từ chính trang, gửi header được) và sửa play() đọc chữ ký từ query thay vì đọc JWT:
src/media/stream.controller.ts — bản cuối cùng
import { Controller, Get, Param, Query, Res, UnauthorizedException, UseGuards } from '@nestjs/common';
import type { Response } from 'express';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import type { JwtPayload } from '../auth/jwt-payload';
import { MediaService } from './media.service';
import { SignedUrlService } from './signed-url.service';

@Controller('media')
export class StreamController {
  constructor(
    private readonly media: MediaService,
    private readonly signedUrl: SignedUrlService,
  ) {}

  // Doi hoi JwtAuthGuard binh thuong: lay URL nay la mot lenh goi tu chinh
  // trang SPA qua fetch/XHR, no gui duoc header Authorization.
  @Get(':id/signed-url')
  @UseGuards(JwtAuthGuard)
  async getSignedUrl(
    @Param('id') id: string,
    @CurrentUser() user: JwtPayload,
  ): Promise<{ url: string }> {
    // Kiem quyen xem NGAY tai day — phat URL cho video khong thuoc ve nguoi
    // goi thi coi nhu tim khong thay, giong het play() ben duoi.
    await this.media.findPlayable(id, user.sub);
    return { url: this.signedUrl.sign(id, user.sub) };
  }

  // KHONG dung JwtAuthGuard — day chinh la route ma URL co chu ky ton tai de
  // phuc vu, vi the <video src="..."> khong gui duoc header Authorization.
  @Get(':id/play')
  async play(
    @Param('id') id: string,
    // Khong co ValidationPipe toan cuc — @Query() khong dam bao gia tri ton
    // tai luc runtime du kieu khai bao la string. Kiem tra tay truoc khi dung.
    @Query('u') userId: string | undefined,
    @Query('e') expires: string | undefined,
    @Query('s') signature: string | undefined,
    @Res() response: Response,
  ): Promise<void> {
    if (
      userId === undefined ||
      expires === undefined ||
      signature === undefined ||
      !this.signedUrl.verify(id, userId, Number(expires), signature)
    ) {
      throw new UnauthorizedException('URL het han hoac khong hop le');
    }

    // Nest van lam phan kho: kiem tra quyen, han muc, trang thai video.
    const asset = await this.media.findPlayable(id, userId);

    response.setHeader('X-Accel-Redirect', `/protected-media/${asset.storageKey}`);
    response.setHeader('Content-Type', 'video/mp4');
    response.end(); // Body rong. nginx thay header tren va tu gui file.
  }
}

Client gọi GET /media/:id/signed-url có xác thực bình thường để xin URL, rồi gắn URL đó vào thẻ <video>. URL sống 5 phút — đủ để bắt đầu phát, quá ngắn để chia sẻ lại có ý nghĩa. Đây cũng chính là cơ chế mà các dịch vụ lưu trữ đám mây dùng cho link tải tạm thời.

src/config/configuration.ts — thêm biến cho URL có chữ ký
// Doc lap voi JWT_SECRET vi khac vong doi: URL nay het han sau vai phut,
// khong phai vai phut sau khi dang xuat.
SIGNED_URL_SECRET: z.string().min(32),

Biến này không.default(), đúng như JWT_SECRET ở mục 2 — nên thiếu nó là ứng dụng từ chối khởi động ngay: SIGNED_URL_SECRET: Invalid input: expected string, received undefined. Sinh một khoá nữa, khác khoá JWT:

Terminal
echo "SIGNED_URL_SECRET=$(openssl rand -hex 32)" >> .env
echo "SIGNED_URL_SECRET=thay-bang-chuoi-ngau-nhien-tu-openssl-rand-hex-32" >> .env.example

Và mảnh cuối cùng, lại là chỗ dễ quên nhất: MediaModule ở mục 6 chưa biết gì về hai class vừa viết. Không thêm vào thì hai route của cả mục 7 đơn giản là không tồn tại — ứng dụng vẫn khởi động bình thường, chỉ có /media/:id/play trả 404.

src/media/media.module.ts — bản đầy đủ sau mục 7
@Module({
  imports: [TypeOrmModule.forFeature([Video]), AuthModule],
  controllers: [UploadController, StreamController],
  providers: [MediaService, SignedUrlService],
  exports: [MediaService],
})
export class MediaModule {}
Terminal — năm phép thử cho cả mục 7
VID=<videoId tra ve tu buoc upload o muc 6>

# 1. Xin URL co chu ky (co Authorization, nhu mot lenh fetch tu trang)
URL=$(curl -sk --resolve media-forge.local:8443:127.0.0.1 \
  "https://media-forge.local:8443/media/$VID/signed-url" \
  -H "Authorization: Bearer $TOK" \
  | python3 -c 'import sys,json;print(json.load(sys.stdin)["url"])')

# 2. Phat bang URL do, KHONG kem Authorization — dung nhu the <video> goi
curl -sk --resolve media-forge.local:8443:127.0.0.1 -D- -o /dev/null \
  "https://media-forge.local:8443$URL"
# -> 200, content-length: 26214400, accept-ranges: bytes

# 3. Tua: xin dung 1024 byte tu giua file
curl -sk --resolve media-forge.local:8443:127.0.0.1 -D- -o /dev/null \
  -H 'Range: bytes=10485760-10486783' "https://media-forge.local:8443$URL"
# -> 206, content-range: bytes 10485760-10486783/26214400, content-length: 1024

# 4. Go thang vung noi bo tu ngoai vao
curl -sk --resolve media-forge.local:8443:127.0.0.1 -o /dev/null -w '%{http_code}\n' \
  "https://media-forge.local:8443/protected-media/$VID.bin"
# -> 404   (chi thi `internal` lam dung viec cua no)

# 5. Sua mot ky tu cuoi cua chu ky
curl -sk --resolve media-forge.local:8443:127.0.0.1 -o /dev/null -w '%{http_code}\n' \
  "https://media-forge.local:8443${URL%?}X"
# -> 401

Phép thử số 3 là cái đáng nhìn kỹ nhất: 206Content-Range đúng từng byte, trong khi trong stream.controller.ts bản cuối không còn một dòng nào xử lý Range. Toàn bộ đoạn tính start/end/416 viết tay ở đầu mục 7 đã bị xoá, và nginx làm lại phần đó đầy đủ hơn. Đó chính là điều mục 7.1 hứa, đo được bằng đúng một lệnh curl.

8. Giao diện: hai mốc để nhìn thấy thứ vừa dựng

Backend giờ có xác thực và luồng media hoàn chỉnh, nhưng vẫn chưa nhìn được bằng mắt. Giao diện là một dự án React + TypeScript riêng, dùng Tailwind, gọi sang API qua nginx.

Khởi tạo
npm create vite@latest media-forge-web -- --template react-ts
cd media-forge-web
npm i
npm i -D tailwindcss @tailwindcss/vite
vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';

export default defineConfig({
  plugins: [react(), tailwindcss()],
});
src/index.css
@import "tailwindcss";

Không cần tailwind.config.js: @tailwindcss/vite (Tailwind v4) không bắt buộc file cấu hình riêng cho trường hợp mặc định này.

8.1. Mốc #1 — đăng nhập và tự làm mới token

Phần đáng nói không phải cái form, mà là lớp bọc quanh fetch. Nó phải nhận ra 401, gọi refresh, rồi thử lại request cũ — và tuyệt đối không được để nhiều request cùng gặp 401 kích hoạt nhiều lần refresh song song. Đó chính là tình huống đua ở mục 3.3, nhìn từ phía client.

src/lib/api.ts
let accessToken: string | null = null;

// Bien then chot: MOT promise refresh dung chung cho ca ung dung.
let refreshing: Promise<void> | null = null;

async function refreshOnce(): Promise<void> {
  // Da co ai dang refresh thi cho ket qua cua ho, khong goi them lan nua.
  refreshing ??= (async () => {
    const res = await fetch('/auth/refresh', { method: 'POST', credentials: 'include' });
    if (!res.ok) throw new Error('Phien dang nhap het hieu luc');
    const data = (await res.json()) as { accessToken: string };
    accessToken = data.accessToken;
  })().finally(() => {
    refreshing = null;
  });

  return refreshing;
}

export async function api(path: string, init: RequestInit = {}): Promise<Response> {
  const call = (): Promise<Response> =>
    fetch(path, {
      ...init,
      credentials: 'include',
      headers: {
        ...init.headers,
        ...(accessToken === null ? {} : { Authorization: `Bearer ${accessToken}` }),
      },
    });

  const first = await call();
  if (first.status !== 401) return first;

  await refreshOnce();
  return call(); // thu lai dung mot lan
}

// Dang nhap: doi email/mat khau lay access token, va giu no trong bien tren.
// Refresh token khong di qua day — server dat no vao cookie httpOnly.
export async function login(email: string, password: string): Promise<void> {
  const res = await fetch('/auth/login', {
    method: 'POST',
    credentials: 'include',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ email, password }),
  });

  if (!res.ok) throw new Error('Email hoac mat khau khong dung');

  const data = (await res.json()) as { accessToken: string };
  accessToken = data.accessToken;
}

export function isLoggedIn(): boolean {
  return accessToken !== null;
}

Bốn dòng quanh biến refreshing là toàn bộ giá trị của file này. Mười request cùng nhận 401 thì cả mười cùng chờ một lời gọi refresh, và cùng thử lại sau khi nó xong. Bỏ chúng đi thì bạn bắn mười refresh song song với cùng một token — đúng cái mà server ở mục 3.3 phải dựng khoảng ân hạn để tha thứ.

Chú ý accessToken là biến trong module, không phải localStorage. Mất tab là mất nó — và không sao, vì refresh token nằm trong cookie httpOnly nên lần gọi api() đầu tiên sau khi tải lại trang sẽ nhận 401, tự refresh, rồi chạy tiếp. Đổi lại, một script chèn được vào trang cũng không đọc được token bằng JavaScript.

Giờ mới tới cái form. Nó không có gì đặc biệt, và đó là chủ ý — mọi thứ khó đã nằm ở trên:

src/components/LoginForm.tsx
import { useState, type FormEvent } from 'react';
import { login } from '../lib/api';

export function LoginForm({ onDone }: { onDone: () => void }): JSX.Element {
  const [email, setEmail] = useState('demo@test.local');
  const [password, setPassword] = useState('');
  const [error, setError] = useState<string | null>(null);
  const [busy, setBusy] = useState(false);

  async function handleSubmit(event: FormEvent): Promise<void> {
    event.preventDefault(); // khong de trinh duyet tu nap lai trang
    setBusy(true);
    setError(null);
    try {
      await login(email, password);
      onDone();
    } catch (err) {
      // useUnknownInCatchVariables (Part 1 muc 5) buoc thu hep kieu o day
      setError(err instanceof Error ? err.message : 'Dang nhap that bai');
    } finally {
      setBusy(false);
    }
  }

  return (
    <form onSubmit={handleSubmit} className="mx-auto mt-24 w-80 space-y-3">
      <h1 className="text-lg font-semibold">Đăng nhập</h1>

      <input
        type="email"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
        className="w-full rounded border border-slate-300 px-3 py-2"
      />
      <input
        type="password"
        value={password}
        onChange={(e) => setPassword(e.target.value)}
        placeholder="Mật khẩu"
        className="w-full rounded border border-slate-300 px-3 py-2"
      />

      {error !== null && <p className="text-sm text-red-600">{error}</p>}

      <button
        type="submit"
        disabled={busy}
        className="w-full rounded bg-slate-900 py-2 text-white disabled:opacity-50"
      >
        {busy ? 'Đang gửi…' : 'Đăng nhập'}
      </button>
    </form>
  );
}

Còn một mảnh nữa mà thiếu nó thì cả ba file trên chỉ nằm im trên đĩa: một chỗ ráp chúng lại. Vite sinh sẵn src/App.tsx để in trang mẫu — thay toàn bộ nội dung của nó:

src/App.tsx — thay toàn bộ file Vite sinh sẵn
import { useState } from 'react';
import { LoginForm } from './components/LoginForm';

export default function App(): JSX.Element {
  const [ready, setReady] = useState(false);

  if (!ready) return <LoginForm onDone={() => setReady(true)} />;

  // Muc 8.2 se thay dong nay bang man upload + trinh phat.
  return <p className="mt-24 text-center">Đã đăng nhập.</p>;
}
🔍 Chạy thử ngay — đây là lần đầu nhìn thấy sản phẩm
Backend phải đang chạy ở cổng 3000, và vite.config.ts cần chuyển tiếp lời gọi API sang đó, nếu không trình duyệt sẽ chặn vì khác origin:

server: { proxy: { '/auth': 'http://localhost:3000', '/media': 'http://localhost:3000' } }

Chạy npm run dev, mở http://localhost:5173: phải thấy form đăng nhập. Nhập sai mật khẩu thì hiện đúng thông báo màu đỏ; nhập đúng thì đổi sang "Đã đăng nhập." Chưa thấy chữ nào của trang mẫu Vite nữa là đúng.

8.2. Mốc #2 — upload có thanh tiến trình và trình phát

Chỗ này có một bất ngờ nhỏ: fetch hiện đại vẫn không báo được tiến trình upload. Nó theo dõi được luồng tải xuống, nhưng không theo dõi được luồng gửi lên. Muốn có phần trăm thật thì phải quay lại XMLHttpRequest — một trong số ít trường hợp API cũ vẫn còn là lựa chọn đúng.

src/lib/upload.ts
export function uploadWithProgress(
  file: File,
  token: string,
  onProgress: (percent: number) => void,
): Promise<{ videoId: string }> {
  return new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest();
    xhr.open('POST', '/media/upload');
    xhr.setRequestHeader('Authorization', `Bearer ${token}`);
    xhr.setRequestHeader('Content-Type', 'application/octet-stream');
    // Ten file goc di kem qua header rieng, encode de an toan qua HTTP header
    // (xem src/media/upload.controller.ts o muc 6, noi header nay duoc doc lai).
    xhr.setRequestHeader('X-Filename', encodeURIComponent(file.name));

    // upload.onprogress — thu ma fetch khong co.
    xhr.upload.onprogress = (event) => {
      if (event.lengthComputable) {
        onProgress(Math.round((event.loaded / event.total) * 100));
      }
    };

    xhr.onload = () => {
      if (xhr.status >= 200 && xhr.status < 300) {
        resolve(JSON.parse(xhr.responseText) as { videoId: string });
      } else {
        reject(new Error(`Upload that bai: ${xhr.status}`));
      }
    };
    xhr.onerror = () => reject(new Error('Mat ket noi'));

    // Gui thang File. Trinh duyet tu doc theo stream, khong nap het vao RAM.
    xhr.send(file);
  });
}
src/components/VideoPlayer.tsx
import { useEffect, useState, type JSX } from 'react';
import { api } from '../lib/api';

export function VideoPlayer({ assetId }: { assetId: string }): JSX.Element {
  const [src, setSrc] = useState<string | null>(null);

  useEffect(() => {
    // The <video> khong gui duoc header Authorization, nen xin URL co chu ky.
    api(`/media/${assetId}/signed-url`)
      .then((res) => res.json() as Promise<{ url: string }>)
      .then((data) => setSrc(data.url));
  }, [assetId]);

  if (src === null) return <div className="h-64 animate-pulse rounded-lg bg-slate-200" />;

  return <video src={src} controls className="w-full rounded-lg shadow-lg" />;
}
📝 JSX.Element với React 19
Cả ba component ở mục 8 đều khai kiểu trả về JSX.Element. Với React 19 và @types/react 19 — đúng bộ mà npm create vite@latest cài hôm nay — namespace JSX toàn cục đã bị bỏ, nên thiếu type JSX trong dòng import là TS2503: Cannot find namespace 'JSX'.

Cũng lưu ý: trong dự án Vite react-ts, tsconfig.json chỉ chứa references, nên npx tsc --noEmit không kiểm gì cả và luôn im lặng. Lệnh kiểm thật là npx tsc -b, đúng lệnh mà npm run build gọi.

Còn hai mảnh nữa trước khi nhìn thấy được thứ này. Thứ nhất: uploadWithProgress đòi một token, mà accessToken ở mục 8.1 là biến riêng của module — XMLHttpRequest không đi qua api() nên phải lấy token ra bằng một hàm nhỏ:

src/lib/api.ts — THÊM một hàm export, giữ nguyên phần còn lại
// upload.ts can token de dat header Authorization tren XMLHttpRequest —
// XHR khong di qua api() nen phai lay token ra day.
export function getAccessToken(): string | null {
  return accessToken;
}

Thứ hai: một màn hình ráp ô chọn file, thanh tiến trình và trình phát lại với nhau.

src/components/UploadScreen.tsx
import { useState, type ChangeEvent, type JSX } from 'react';
import { getAccessToken } from '../lib/api';
import { uploadWithProgress } from '../lib/upload';
import { VideoPlayer } from './VideoPlayer';

export function UploadScreen(): JSX.Element {
  const [percent, setPercent] = useState<number | null>(null);
  const [videoId, setVideoId] = useState<string | null>(null);
  const [error, setError] = useState<string | null>(null);

  async function handleFile(event: ChangeEvent<HTMLInputElement>): Promise<void> {
    const file = event.target.files?.[0];
    const token = getAccessToken();
    if (file === undefined || token === null) return;

    setError(null);
    setVideoId(null);
    setPercent(0);

    try {
      const result = await uploadWithProgress(file, token, setPercent);
      setVideoId(result.videoId);
    } catch (err) {
      setError(err instanceof Error ? err.message : 'Upload that bai');
    } finally {
      setPercent(null);
    }
  }

  return (
    <div className="mx-auto mt-16 w-full max-w-xl space-y-4 px-4">
      <h1 className="text-lg font-semibold">Tải video lên</h1>

      <input type="file" accept="video/*" onChange={handleFile} className="w-full" />

      {percent !== null && (
        <div className="h-2 w-full overflow-hidden rounded bg-slate-200">
          <div className="h-full bg-slate-900 transition-all" style={{ width: `${percent}%` }} />
        </div>
      )}
      {error !== null && <p className="text-sm text-red-600">{error}</p>}

      {videoId !== null && (
        <div className="space-y-2">
          <p className="text-sm text-slate-600">Xong. videoId: {videoId}</p>
          <VideoPlayer assetId={videoId} />
        </div>
      )}
    </div>
  );
}
src/App.tsx — thay dòng "Đã đăng nhập." của mốc #1
import { useState, type JSX } from 'react';
import { LoginForm } from './components/LoginForm';
import { UploadScreen } from './components/UploadScreen';

export default function App(): JSX.Element {
  const [ready, setReady] = useState(false);

  if (!ready) return <LoginForm onDone={() => setReady(true)} />;

  return <UploadScreen />;
}
⚠️ Proxy phải trỏ vào nginx, không phải thẳng Node
Đây là chỗ dễ mất nửa buổi nhất của cả Part 2. Từ mục 7.1, /media/:id/play trả về body rỗng kèm header X-Accel-Redirect, và chỉ nginx mới hiểu header đó. Trỏ proxy của Vite thẳng vào Node cổng 3000 thì trình duyệt nhận 200 với 0 byte, header x-accel-redirect lọt nguyên ra ngoài, và thẻ <video> chết với DEMUXER_ERROR_COULD_NOT_OPEN.

Triệu chứng rất dễ đánh lừa: trình phát vẫn hiện đủ nút, chỉ là 0:00 và bấm không lên — đúng cái bẫy mà chính mục 7 mở đầu đã cảnh báo.
vite.config.ts — bản đầy đủ
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';

const gateway = {
  target: 'https://127.0.0.1:8443',
  secure: false, // chung chi tu ky o muc 4
  // nginx khop server_name media-forge.local — dat Host o day de khoi
  // phai them mot dong vao /etc/hosts.
  headers: { Host: 'media-forge.local' },
};

export default defineConfig({
  plugins: [react(), tailwindcss()],
  server: {
    proxy: {
      '/auth': gateway,
      '/media': gateway,
    },
  },
});

Giờ thì chạy được thật: npm run dev, mở http://localhost:5173, đăng nhập bằng demo@test.local, chọn một file video. Thanh tiến trình chạy từ 0 tới 100, rồi trình phát hiện ra ngay bên dưới với đúng độ dài của video vừa tải lên.

Kéo thanh tiến trình của trình phát này, mở tab Network của trình duyệt: bạn sẽ thấy đúng những request 206 đã mô tả ở mục 7 — và chúng do nginx trả lời, không phải Node. (Các request đó thường kèm trạng thái ERR_ABORTED, và đó là bình thường: trình duyệt xin một khoảng byte rồi tự huỷ khi bạn kéo tiếp sang chỗ khác.)

🔍 Điều đáng nhớ nhất từ Part 2
Cả bốn vấn đề khó nhất của phần này đều là cùng một vấn đề: hai việc chạy đồng thời trên một dữ liệu chung. Hai tab cùng refresh một token. Nhiều request cùng múc một gáo token. Nhiều request cùng nhận 401 và cùng đòi refresh. Chúng là họ hàng trực tiếp của lỗi double-spend ở Part 1.

Và mỗi lần, lời giải đều thuộc một trong ba loại: khoá lại (pessimistic_write), làm cho phép tính trở thành nguyên tử (script Lua), hoặc gộp nhiều lời gọi thành một (biến refreshing). Nhận ra được hình dạng chung này thì lần sau bạn không phải khám phá lại từ đầu.
💡 Part 3 tiếp tục từ đâu
Video đã lên tới máy chủ, nhưng vẫn nằm nguyên định dạng gốc — chưa ai chuyển mã nó. Part 3 gọi ffmpeg bằng child_process, đẩy việc nặng sang worker_threads, nhân bản tiến trình bằng cluster với nginx cân tải phía trước, dựng hàng đợi để một job hỏng không kéo cả hệ thống, và đẩy tiến độ về trình duyệt theo thời gian thực qua WebSocket.

Các phần trong loạt bài

Part 1: Nền móng, thiết kế CSDL & ACID Part 3: child_process, worker_threads, cluster & realtime Quay lại Blog

Bình luận