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.
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.
npm i argon2 @nestjs/jwt
npm i -D @types/express
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.
$argon2id$v=19$m=19456,t=2,p=1$c29tZXNhbHR2YWx1ZQ$RdescudvJCsgt3ub+b+dWRWJTmaaJObG
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:
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(...):
import { ValidationPipe } from '@nestjs/common';
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
}),
);
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.
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.
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.
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.
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'),
});
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:
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';
import { AuthController } from './auth.controller';
@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',
},
}),
}),
],
controllers: [AuthController],
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 {}
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.
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;
}
AuthedRequest âm thầm sai kiểuimport 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.
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:
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:
// 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) };
}
BillingService thậtBillingService ở Part 1 chỉ có charge() và 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 amount và reason (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:
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:
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) };
}
}
sub chứ không phải userIdsub (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.
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:
@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.
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:
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 rồi chạy
npm run migration:generate -- AddReplacedByHash là TypeORM tự so sánh entity với schema hiện
tại và sinh ra đúng một lệnh ALTER TABLE.
type: 'varchar' ở đây không phải thừatype đ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_hashunique 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
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 ở đâyMath.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.
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.
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, trả lại
đúng cặp token đã phát ở lần đó thay vì hô báo động.
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.
// Tra lai dung token da phat cho tab thu nhat.
return this.reissueFrom(manager, row.replacedByHash);
}
await manager.delete(RefreshToken, { familyId: row.familyId });
throw new UnauthorizedException('Phien dang nhap da bi thu hoi');
}
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:
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 };
}
Hàm reissueFrom ở hai khối phía trên chỉ để dễ theo dõi từng mảnh riêng lẻ —
tryReissue là bản ghép đầy đủ, thay thế hoàn toàn cho 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".
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ũ.
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:
// 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õ:
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.
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.
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:
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;
}
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,
});
}
}
request.cookies cần cookie-parserCookie 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-parserimport 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ở.
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;
}
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.
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ổngX-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.
# 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;
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;
}
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ớ.
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.
-- 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ó. 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:
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();
}
}
.lua biến mất sau nest buildnest 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 ENOENT dù npm 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.
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;
}
}
Ứ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.
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.
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.
@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.
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 };
}
}
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 rastream.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:
UPLOAD_DIR: z.string().default('./uploads'),
TEMP_UPLOAD_DIR: z.string().default('./uploads/tmp'),
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ứ.
// 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));
setInterval(() => console.log(process.memoryUsage().rss / 1e6), 1000);
Bản dùng
FileInterceptor có rss 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:
import { mkdir, readdir } from 'node:fs/promises';
// 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ớ.
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 };
}
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.
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.
@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.
location /protected-media/ {
internal; # go thang URL nay tu ngoai vao -> 404
alias /var/media/;
sendfile on;
tcp_nopush on;
}
@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.
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.
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
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:
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.
// 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),
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.
npm create vite@latest media-forge-web -- --template react-ts
cd media-forge-web
npm i
npm i -D tailwindcss @tailwindcss/vite
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';
export default defineConfig({
plugins: [react(), tailwindcss()],
});
@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.
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
}
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ứ.
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.
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);
});
}
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" />;
}
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.
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.
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.
Bình luận