Xây Dựng Video Platform: Secure Video Streaming

File HLS nằm trên disk, sắp xếp gọn gàng trong các thư mục. Nhưng bây giờ, bất kỳ ai đoán được path đều có thể download trực tiếp từng segment. Không có authentication, không expiration, không protection gì cả. Đây là vấn đề lớn nếu content của bạn nằm sau paywall.
Trong bài này, ta sẽ setup Nginx để serve HLS streams với secure_link — module validate hash + expiration trên mọi request. Spring Boot generate signed URLs, và chỉ subscriber có token hợp lệ mới xem được. Không có token? Nginx trả về 403 trước khi segment rời server.
Thời gian: 2–3 giờ
Yêu cầu: Phase 6: Video Transcoding Pipeline
Những gì ta sẽ build trong bài này:
✅ Nginx configuration cho serving HLS segments
✅ secure_link module cho token-based URL validation
✅ Signed URL generation trong Spring Boot
✅ CORS headers cho cross-origin video playback
✅ Download prevention strategies
✅ Playlist URL rewriting cho signed streaming
✅ Complete request flow từ auth check đến video playback
Vấn Đề: Video Files Không Được Bảo Vệ
Không có protection, video của bạn chỉ là static files trên web server:
https://yoursite.com/videos/hls/1/1/master.m3u8 → Ai cũng truy cập được
https://yoursite.com/videos/hls/1/1/720p/segment-000.ts → Ai cũng download đượcAi đó có thể:
- Share direct URL và bypass subscription
- Viết script download toàn bộ segments rồi ghép lại thành video
- Hotlink video từ site của họ, xài bandwidth của bạn
Ta cần một cách để validate mọi request cho video segment đến từ authorized user, và URL chưa bị share hay expired.
Tổng Quan Architecture
Đây là cách secure streaming hoạt động:
Điểm mấu chốt: Nginx validate mọi request — không chỉ playlist, mà từng file .ts segment. Token được gắn với base path (ví dụ /videos/hls/1/1/), nên nó cover master playlist và tất cả quality levels, segments dưới path đó.
Nginx Configuration
Basic HLS Serving
Đầu tiên, setup Nginx để serve HLS files từ thư mục video storage:
# nginx/conf.d/hls.conf
# Rate limiting zone — chống abuse
limit_req_zone $binary_remote_addr zone=hls_limit:10m rate=30r/s;
server {
listen 80;
server_name localhost;
# HLS video streaming với secure_link
location /videos/hls/ {
# Secure link validation
secure_link $arg_token,$arg_expires;
secure_link_md5 "$secure_link_expires$uri$remote_addr YOUR_SECRET_KEY";
# Nếu secure_link rỗng — hash không hợp lệ
if ($secure_link = "") {
return 403;
}
# Nếu secure_link là "0" — link đã hết hạn
if ($secure_link = "0") {
return 410; # Gone
}
# Serve từ thư mục video storage
alias /data/videos/hls/;
# Rate limiting
limit_req zone=hls_limit burst=50 nodelay;
# CORS headers
add_header Access-Control-Allow-Origin $http_origin always;
add_header Access-Control-Allow-Methods "GET, OPTIONS" always;
add_header Access-Control-Allow-Headers "Range, Authorization" always;
add_header Access-Control-Expose-Headers "Content-Length, Content-Range" always;
# Cache control cho segments
location ~* \.m3u8$ {
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Access-Control-Allow-Origin $http_origin always;
add_header Access-Control-Allow-Methods "GET, OPTIONS" always;
types {
application/vnd.apple.mpegurl m3u8;
}
}
location ~* \.ts$ {
add_header Cache-Control "public, max-age=86400";
add_header Access-Control-Allow-Origin $http_origin always;
add_header Access-Control-Allow-Methods "GET, OPTIONS" always;
types {
video/mp2t ts;
}
}
# Chặn downloading — không có Content-Disposition header
add_header Content-Disposition "";
# Tắt directory listing
autoindex off;
}
# Xử lý CORS preflight requests
location /videos/hls/ {
if ($request_method = OPTIONS) {
add_header Access-Control-Allow-Origin $http_origin;
add_header Access-Control-Allow-Methods "GET, OPTIONS";
add_header Access-Control-Allow-Headers "Range, Authorization";
add_header Access-Control-Max-Age 86400;
add_header Content-Length 0;
return 204;
}
}
# Chặn truy cập trực tiếp raw uploads
location /videos/raw/ {
deny all;
return 403;
}
}secure_link Hoạt Động Như Thế Nào
Module secure_link được tích hợp sẵn trong Nginx (có thể cần compile với --with-http_secure_link_module, nhưng hầu hết distributions đã bao gồm). Đây là flow:
Directive secure_link_md5 định nghĩa cách tính hash:
secure_link_md5 "$secure_link_expires$uri$remote_addr YOUR_SECRET_KEY";Nghĩa là: MD5(expiration_timestamp + request_URI + client_IP + secret_key). Client phải cung cấp chính xác hash này trong query parameter token. Vì chỉ Spring Boot server biết secret key, chỉ nó mới generate được token hợp lệ.
Quan trọng: $remote_addr gắn token với IP address của client. Điều này ngăn token sharing — URL signed cho một user sẽ không hoạt động từ IP khác. Nếu user đứng sau corporate proxy hoặc VPN mà IP thay đổi, bạn có thể bỏ $remote_addr khỏi hash và chỉ dựa vào expiration.
Signed URL Generation Trong Spring Boot
Configuration
// src/main/java/com/videoplatform/api/config/StreamingConfig.java
package com.videoplatform.api.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;
@Configuration
@ConfigurationProperties(prefix = "streaming")
public class StreamingConfig {
private String secretKey = "change-me-in-production";
private int urlExpirationSeconds = 7200; // 2 giờ
private String baseUrl = "http://localhost/videos/hls";
// Getters and setters
public String getSecretKey() { return secretKey; }
public void setSecretKey(String secretKey) { this.secretKey = secretKey; }
public int getUrlExpirationSeconds() { return urlExpirationSeconds; }
public void setUrlExpirationSeconds(int urlExpirationSeconds) {
this.urlExpirationSeconds = urlExpirationSeconds;
}
public String getBaseUrl() { return baseUrl; }
public void setBaseUrl(String baseUrl) { this.baseUrl = baseUrl; }
}# src/main/resources/application.yml
streaming:
secret-key: ${STREAMING_SECRET_KEY:change-me-in-production}
url-expiration-seconds: 7200
base-url: ${STREAMING_BASE_URL:http://localhost/videos/hls}URL Signing Service
// src/main/java/com/videoplatform/api/service/SignedUrlService.java
package com.videoplatform.api.service;
import com.videoplatform.api.config.StreamingConfig;
import org.springframework.stereotype.Service;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Instant;
import java.util.Base64;
@Service
public class SignedUrlService {
private final StreamingConfig config;
public SignedUrlService(StreamingConfig config) {
this.config = config;
}
/**
* Generate signed URL cho HLS streaming.
*
* @param courseId Course ID
* @param lessonId Lesson ID
* @param clientIp Client IP address (cho IP binding)
* @return Signed URL cho master playlist
*/
public String generateSignedPlaylistUrl(Long courseId, Long lessonId, String clientIp) {
String uri = String.format("/videos/hls/%d/%d/master.m3u8", courseId, lessonId);
long expires = Instant.now().getEpochSecond() + config.getUrlExpirationSeconds();
String token = computeToken(uri, expires, clientIp);
return String.format("%s/%d/%d/master.m3u8?token=%s&expires=%d",
config.getBaseUrl(), courseId, lessonId, token, expires);
}
/**
* Generate signed base URL cho segment requests.
* Player sẽ append segment paths vào base này.
*/
public SignedStreamInfo generateSignedStreamInfo(Long courseId, Long lessonId, String clientIp) {
long expires = Instant.now().getEpochSecond() + config.getUrlExpirationSeconds();
// Sign master playlist
String masterUri = String.format("/videos/hls/%d/%d/master.m3u8", courseId, lessonId);
String masterToken = computeToken(masterUri, expires, clientIp);
String masterUrl = String.format("%s/%d/%d/master.m3u8?token=%s&expires=%d",
config.getBaseUrl(), courseId, lessonId, masterToken, expires);
return new SignedStreamInfo(masterUrl, expires);
}
private String computeToken(String uri, long expires, String clientIp) {
try {
// Phải match Nginx secure_link_md5 directive:
// secure_link_md5 "$secure_link_expires$uri$remote_addr YOUR_SECRET_KEY";
String raw = expires + uri + clientIp + config.getSecretKey();
MessageDigest md = MessageDigest.getInstance("MD5");
byte[] hash = md.digest(raw.getBytes(StandardCharsets.UTF_8));
// Nginx yêu cầu Base64url encoding (không padding)
return Base64.getUrlEncoder()
.withoutPadding()
.encodeToString(hash);
} catch (Exception e) {
throw new RuntimeException("Failed to compute secure link token", e);
}
}
public record SignedStreamInfo(
String masterPlaylistUrl,
long expiresAt
) {}
}Tại Sao MD5?
Bạn có thể nhận ra ta đang dùng MD5 — thuật toán đã bị broken về collision resistance. Không sao cả ở đây — secure_link dùng MD5 theo kiểu HMAC-like (secret key + message). Ta không dùng nó cho password hashing hay digital signatures. Security đến từ việc client không biết secret key, không phải từ collision resistance của hash algorithm. Đây là design của Nginx, và nó hoạt động tốt cho URL signing.
Streaming API Endpoint
Stream Controller
// src/main/java/com/videoplatform/api/controller/StreamController.java
package com.videoplatform.api.controller;
import com.videoplatform.api.dto.response.ApiResponse;
import com.videoplatform.api.dto.response.StreamResponse;
import com.videoplatform.api.entity.Lesson;
import com.videoplatform.api.entity.LessonStatus;
import com.videoplatform.api.exception.AccessDeniedException;
import com.videoplatform.api.exception.ResourceNotFoundException;
import com.videoplatform.api.repository.LessonRepository;
import com.videoplatform.api.service.SignedUrlService;
import com.videoplatform.api.service.SubscriptionService;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/lessons")
public class StreamController {
private final LessonRepository lessonRepository;
private final SignedUrlService signedUrlService;
private final SubscriptionService subscriptionService;
public StreamController(
LessonRepository lessonRepository,
SignedUrlService signedUrlService,
SubscriptionService subscriptionService) {
this.lessonRepository = lessonRepository;
this.signedUrlService = signedUrlService;
this.subscriptionService = subscriptionService;
}
@GetMapping("/{lessonId}/stream")
public ResponseEntity<ApiResponse<StreamResponse>> getStreamUrl(
@PathVariable Long lessonId,
@AuthenticationPrincipal UserDetails userDetails,
HttpServletRequest request) {
// 1. Tìm lesson
Lesson lesson = lessonRepository.findById(lessonId)
.orElseThrow(() -> new ResourceNotFoundException("Lesson not found: " + lessonId));
// 2. Check video đã sẵn sàng chưa
if (lesson.getStatus() != LessonStatus.READY) {
throw new ResourceNotFoundException("Video not available for this lesson");
}
// 3. Cho phép free preview lessons không cần subscription
if (!lesson.isFreePreview()) {
// 4. Check subscription
boolean hasAccess = subscriptionService.hasActiveSubscription(userDetails.getUsername());
if (!hasAccess) {
throw new AccessDeniedException("Active subscription required to watch this lesson");
}
}
// 5. Generate signed URL
String clientIp = getClientIp(request);
Long courseId = lesson.getSection().getCourse().getId();
var streamInfo = signedUrlService.generateSignedStreamInfo(courseId, lessonId, clientIp);
StreamResponse response = new StreamResponse(
streamInfo.masterPlaylistUrl(),
streamInfo.expiresAt(),
lesson.getDuration()
);
return ResponseEntity.ok(ApiResponse.success(response));
}
private String getClientIp(HttpServletRequest request) {
// Check X-Forwarded-For header (do reverse proxy set)
String forwarded = request.getHeader("X-Forwarded-For");
if (forwarded != null && !forwarded.isEmpty()) {
// Lấy IP đầu tiên (IP thật của client)
return forwarded.split(",")[0].trim();
}
return request.getRemoteAddr();
}
}Response DTO
// src/main/java/com/videoplatform/api/dto/response/StreamResponse.java
package com.videoplatform.api.dto.response;
public record StreamResponse(
String playlistUrl,
long expiresAt,
int durationSeconds
) {}Subscription Check
// src/main/java/com/videoplatform/api/service/SubscriptionService.java
package com.videoplatform.api.service;
import com.videoplatform.api.entity.Subscription;
import com.videoplatform.api.entity.SubscriptionStatus;
import com.videoplatform.api.repository.SubscriptionRepository;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
@Service
public class SubscriptionService {
private final SubscriptionRepository subscriptionRepository;
public SubscriptionService(SubscriptionRepository subscriptionRepository) {
this.subscriptionRepository = subscriptionRepository;
}
public boolean hasActiveSubscription(String email) {
return subscriptionRepository
.findByUserEmailAndStatus(email, SubscriptionStatus.ACTIVE)
.filter(sub -> sub.getCurrentPeriodEnd().isAfter(LocalDateTime.now()))
.isPresent();
}
}// src/main/java/com/videoplatform/api/repository/SubscriptionRepository.java
package com.videoplatform.api.repository;
import com.videoplatform.api.entity.Subscription;
import com.videoplatform.api.entity.SubscriptionStatus;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface SubscriptionRepository extends JpaRepository<Subscription, Long> {
Optional<Subscription> findByUserEmailAndStatus(String email, SubscriptionStatus status);
}Xử Lý Segment URLs
Có một vấn đề khá tinh tế. Khi hls.js fetch master playlist, nó nhận lại relative segment paths:
#EXT-X-STREAM-INF:BANDWIDTH=2628000,RESOLUTION=1280x720,NAME="720p"
720p/playlist.m3u8Player resolve path này relative với master playlist URL. Nếu master URL là:
https://yoursite.com/videos/hls/1/1/master.m3u8?token=abc&expires=123Thì player sẽ request:
https://yoursite.com/videos/hls/1/1/720p/playlist.m3u8Thấy vấn đề chưa? Token và expires bị mất vì chúng chỉ có trên master URL. Request segment sẽ bị Nginx trả 403.
Giải Pháp: Token-Aware Nginx Configuration
Ta sửa Nginx config để dùng path-based token cover toàn bộ thư mục course/lesson:
# nginx/conf.d/hls.conf — cập nhật
server {
listen 80;
server_name localhost;
# Extract course và lesson IDs từ path
# Pattern: /videos/hls/{courseId}/{lessonId}/...
location ~ ^/videos/hls/(\d+)/(\d+)/ {
set $course_id $1;
set $lesson_id $2;
# Secure link validation — hash cover base path, không phải full URI
secure_link $arg_token,$arg_expires;
secure_link_md5 "$secure_link_expires/videos/hls/$course_id/$lesson_id/$remote_addr YOUR_SECRET_KEY";
# Hash không hợp lệ
if ($secure_link = "") {
return 403;
}
# Link hết hạn
if ($secure_link = "0") {
return 410;
}
# Serve từ video storage
alias /data/videos/hls/$course_id/$lesson_id/;
# Thử file cụ thể
try_files $uri =404;
# Rate limiting
limit_req zone=hls_limit burst=50 nodelay;
# CORS headers
add_header Access-Control-Allow-Origin $http_origin always;
add_header Access-Control-Allow-Methods "GET, OPTIONS" always;
add_header Access-Control-Allow-Headers "Range" always;
add_header Access-Control-Expose-Headers "Content-Length, Content-Range" always;
# MIME types
types {
application/vnd.apple.mpegurl m3u8;
video/mp2t ts;
}
# Cache control
if ($uri ~* \.m3u8$) {
add_header Cache-Control "no-cache";
}
if ($uri ~* \.ts$) {
add_header Cache-Control "public, max-age=86400";
}
}
# CORS preflight
if ($request_method = OPTIONS) {
add_header Access-Control-Allow-Origin $http_origin;
add_header Access-Control-Allow-Methods "GET, OPTIONS";
add_header Access-Control-Allow-Headers "Range";
add_header Access-Control-Max-Age 86400;
return 204;
}
# Chặn truy cập raw video
location /videos/raw/ {
deny all;
}
}Thay đổi quan trọng nằm ở secure_link_md5: thay vì dùng $uri (thay đổi theo từng segment), ta dùng fixed base path /videos/hls/$course_id/$lesson_id/. Nghĩa là một token hoạt động cho tất cả files dưới thư mục lesson đó.
Cập Nhật Signing Service
// Cập nhật SignedUrlService.java — computeToken dùng base path
private String computeToken(String basePath, long expires, String clientIp) {
try {
// Phải match Nginx secure_link_md5 directive:
// secure_link_md5 "$secure_link_expires/videos/hls/$course_id/$lesson_id/$remote_addr SECRET";
String raw = expires + basePath + clientIp + config.getSecretKey();
MessageDigest md = MessageDigest.getInstance("MD5");
byte[] hash = md.digest(raw.getBytes(StandardCharsets.UTF_8));
return Base64.getUrlEncoder()
.withoutPadding()
.encodeToString(hash);
} catch (Exception e) {
throw new RuntimeException("Failed to compute secure link token", e);
}
}
public SignedStreamInfo generateSignedStreamInfo(Long courseId, Long lessonId, String clientIp) {
long expires = Instant.now().getEpochSecond() + config.getUrlExpirationSeconds();
// Sign dựa trên base path (không phải full URI)
String basePath = String.format("/videos/hls/%d/%d/", courseId, lessonId);
String token = computeToken(basePath, expires, clientIp);
// Token sẽ được append vào tất cả requests dưới path này
String masterUrl = String.format("%s/%d/%d/master.m3u8?token=%s&expires=%d",
config.getBaseUrl(), courseId, lessonId, token, expires);
return new SignedStreamInfo(masterUrl, token, expires);
}
public record SignedStreamInfo(
String masterPlaylistUrl,
String token,
long expiresAt
) {}hls.js Token Injection
Ở frontend, configure hls.js để append token vào mọi request:
// web/src/lib/hls-config.ts
export function createHlsConfig(token: string, expires: number) {
return {
xhrSetup: (xhr: XMLHttpRequest, url: string) => {
// Append token và expires vào mọi HLS request
const separator = url.includes("?") ? "&" : "?";
const signedUrl = `${url}${separator}token=${token}&expires=${expires}`;
xhr.open("GET", signedUrl, true);
},
};
}// Sử dụng trong video player component (sẽ build đầy đủ ở Post #9)
import Hls from "hls.js";
import { createHlsConfig } from "@/lib/hls-config";
// Khi khởi tạo hls.js:
const hls = new Hls(createHlsConfig(streamInfo.token, streamInfo.expiresAt));
hls.loadSource(streamInfo.masterPlaylistUrl);
hls.attachMedia(videoElement);Bằng cách này, mọi subrequest (quality playlists, segments) tự động bao gồm token. Nginx validate cùng token cho tất cả requests dưới lesson path đó.
CORS Configuration
Cross-Origin Resource Sharing (CORS) rất quan trọng vì video player trên Next.js domain (app.yoursite.com) cần fetch HLS segments từ Nginx (cdn.yoursite.com hoặc cùng domain trên port khác).
Tại Sao CORS Quan Trọng Cho Video
Không có CORS headers đúng:
- hls.js không thể fetch master playlist (bị browser chặn)
- Quality switching fail (không load được alternative playlists)
Rangerequests cho seeking không hoạt động
Giải Thích Nginx CORS Headers
# Cho phép frontend origin
add_header Access-Control-Allow-Origin $http_origin always;
# Chỉ cần GET và OPTIONS cho HLS
add_header Access-Control-Allow-Methods "GET, OPTIONS" always;
# Range header thiết yếu cho video seeking
add_header Access-Control-Allow-Headers "Range" always;
# Expose để hls.js đọc được content length cho progress
add_header Access-Control-Expose-Headers "Content-Length, Content-Range" always;Tip production: Thay $http_origin bằng domain chính xác để bảo mật hơn:
# Thay vì echo lại bất kỳ origin nào:
add_header Access-Control-Allow-Origin $http_origin always;
# Dùng domain cụ thể:
add_header Access-Control-Allow-Origin "https://app.yoursite.com" always;Spring Boot CORS Cho Stream API
// src/main/java/com/videoplatform/api/config/CorsConfig.java
package com.videoplatform.api.config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.List;
@Configuration
public class CorsConfig {
@Value("${app.frontend-url:http://localhost:3000}")
private String frontendUrl;
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(List.of(frontendUrl));
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
config.setAllowedHeaders(List.of("*"));
config.setAllowCredentials(true);
config.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/**", config);
return source;
}
}Chặn Direct Downloads
Ngay cả với signed URLs, user quyết tâm vẫn có thể dùng developer tools để copy segment URLs và download bằng curl. Đây là các lớp phòng thủ:
1. Thời Gian Hết Hạn Ngắn
Set URL expiration 2 giờ (đủ cho session xem dài, nhưng không đủ để share):
streaming:
url-expiration-seconds: 7200 # 2 giờ2. IP Binding
$remote_addr trong hash gắn token với IP của viewer. URL copy sang máy khác sẽ không hoạt động:
secure_link_md5 "$secure_link_expires/videos/hls/$course_id/$lesson_id/$remote_addr YOUR_SECRET_KEY";3. Rate Limiting
Chặn automated bulk downloading:
# 30 requests/giây/IP — đủ cho playback bình thường, chặn scrapers
limit_req_zone $binary_remote_addr zone=hls_limit:10m rate=30r/s;
limit_req zone=hls_limit burst=50 nodelay;Video 30 phút có khoảng 180 segments (mỗi segment 10s) nhân hai quality — tổng cộng khoảng 360 segments. Playback bình thường fetch chúng trong 30 phút. Scraper cố download tất cả cùng lúc sẽ bị rate limit chặn.
4. Response Headers
# Bỏ Content-Disposition để chặn dialog "Save As"
add_header Content-Disposition "" always;
# Chặn MIME sniffing
add_header X-Content-Type-Options "nosniff" always;
# Chặn embedding trong iframes trên site khác
add_header X-Frame-Options "SAMEORIGIN" always;5. Referrer Checking (Tùy Chọn)
Chặn requests không đến từ site của bạn:
valid_referers server_names *.yoursite.com;
if ($invalid_referer) {
return 403;
}Sơ Đồ Defense in Depth
Thực tế: Không có giải pháp streaming DRM-free nào chống piracy 100%. User quyết tâm luôn có thể screen-record. Các biện pháp này ngăn casual sharing và automated scraping — cover 99% trường hợp. True DRM (Widevine/FairPlay) sẽ được đề cập trong bài security hardening.
Docker Compose Configuration
Cập nhật Docker Compose để bao gồm Nginx cho HLS serving:
# docker-compose.yml — thêm nginx service
services:
nginx:
image: nginx:1.25-alpine
ports:
- "80:80"
volumes:
- ./nginx/conf.d:/etc/nginx/conf.d:ro
- video-storage:/data/videos:ro # Read-only access cho videos
depends_on:
- api
- web
restart: unless-stopped
api:
build: ./api
ports:
- "8080:8080"
environment:
STREAMING_SECRET_KEY: ${STREAMING_SECRET_KEY}
STREAMING_BASE_URL: http://localhost/videos/hls
volumes:
- video-storage:/data/videos
depends_on:
- postgres
- redis
web:
build: ./web
ports:
- "3000:3000"
environment:
NEXT_PUBLIC_API_URL: http://localhost:8080
NEXT_PUBLIC_STREAM_BASE_URL: http://localhost/videos/hls
depends_on:
- api
# ... postgres và redis services không đổi
volumes:
video-storage:Full Nginx Configuration Với Reverse Proxy
Trong production, Nginx vừa là reverse proxy cho app vừa là HLS server:
# nginx/conf.d/default.conf
upstream api {
server api:8080;
}
upstream web {
server web:3000;
}
limit_req_zone $binary_remote_addr zone=hls_limit:10m rate=30r/s;
server {
listen 80;
server_name yoursite.com;
# API proxy
location /api/ {
proxy_pass http://api;
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;
# Quan trọng cho multipart uploads
client_max_body_size 2G;
}
# HLS streaming với secure_link
location ~ ^/videos/hls/(\d+)/(\d+)/ {
set $course_id $1;
set $lesson_id $2;
secure_link $arg_token,$arg_expires;
secure_link_md5 "$secure_link_expires/videos/hls/$course_id/$lesson_id/$remote_addr YOUR_SECRET_KEY";
if ($secure_link = "") {
return 403;
}
if ($secure_link = "0") {
return 410;
}
alias /data/videos/hls/$course_id/$lesson_id/;
try_files $uri =404;
limit_req zone=hls_limit burst=50 nodelay;
add_header Access-Control-Allow-Origin "https://yoursite.com" always;
add_header Access-Control-Allow-Methods "GET, OPTIONS" always;
add_header Access-Control-Allow-Headers "Range" always;
add_header Access-Control-Expose-Headers "Content-Length, Content-Range" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
types {
application/vnd.apple.mpegurl m3u8;
video/mp2t ts;
}
}
# Chặn raw video access
location /videos/raw/ {
deny all;
}
# Next.js frontend (catch-all)
location / {
proxy_pass http://web;
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;
}
}Environment Variables
Thêm streaming secret vào .env:
# .env
STREAMING_SECRET_KEY=your-random-64-char-secret-key-here
STREAMING_BASE_URL=http://localhost/videos/hls
# Generate random key:
# openssl rand -hex 32Cực kỳ quan trọng: YOUR_SECRET_KEY trong nginx.conf và STREAMING_SECRET_KEY trong Spring Boot phải giống hệt nhau. Khác một ký tự là mọi signed URL đều fail validation.
Test Toàn Bộ Flow
1. Test URL Signing
# Lấy signed stream URL (cần auth token)
curl -s http://localhost:8080/api/lessons/1/stream \
-H "Authorization: Bearer ${TOKEN}" | jq
# Response:
{
"data": {
"playlistUrl": "http://localhost/videos/hls/1/1/master.m3u8?token=abc123&expires=1711234567",
"expiresAt": 1711234567,
"durationSeconds": 1800
}
}2. Test Direct Access (Phải Fail)
# Không có token — phải nhận 403
curl -v http://localhost/videos/hls/1/1/master.m3u8
# < HTTP/1.1 403 Forbidden
# Token sai — phải nhận 403
curl -v "http://localhost/videos/hls/1/1/master.m3u8?token=invalid&expires=9999999999"
# < HTTP/1.1 403 Forbidden3. Test Signed Access (Phải Thành Công)
# Dùng URL từ step 1
curl -v "http://localhost/videos/hls/1/1/master.m3u8?token=abc123&expires=1711234567"
# < HTTP/1.1 200 OK
# < Content-Type: application/vnd.apple.mpegurl
# #EXTM3U
# #EXT-X-VERSION:3
# ...4. Test Segment Access Với Cùng Token
# Cùng token phải hoạt động cho segments dưới cùng lesson
curl -v "http://localhost/videos/hls/1/1/720p/playlist.m3u8?token=abc123&expires=1711234567"
# < HTTP/1.1 200 OK
curl -v "http://localhost/videos/hls/1/1/720p/segment-000.ts?token=abc123&expires=1711234567"
# < HTTP/1.1 200 OK5. Test URL Hết Hạn
# Set expires thành timestamp trong quá khứ
curl -v "http://localhost/videos/hls/1/1/master.m3u8?token=abc123&expires=1000000000"
# < HTTP/1.1 410 Gone6. Test Playback Với ffplay
# Quick playback test dùng signed URL
ffplay "http://localhost/videos/hls/1/1/master.m3u8?token=abc123&expires=1711234567"Nginx Debug Tips
Khi secure_link trả 403 mà bạn không biết tại sao, bật Nginx debug logging:
# Trong server block, tạm thêm:
error_log /var/log/nginx/error.log debug;Rồi check logs:
docker compose exec nginx tail -f /var/log/nginx/error.logCác vấn đề thường gặp:
| Triệu chứng | Nguyên nhân | Cách fix |
|---|---|---|
| Luôn 403 | Secret key không khớp | Đảm bảo Nginx và Spring Boot dùng cùng key |
| Luôn 403 | Hash encoding không khớp | Nginx yêu cầu Base64url không padding |
| Local OK, qua proxy fail | IP không khớp | Đảm bảo X-Forwarded-For được set và đọc đúng |
| Request đầu OK, segments fail | Token gắn với full URI | Dùng base path trong secure_link_md5 (cách của ta) |
| Hết hạn quá nhanh | Clock skew | Đồng bộ server clocks với NTP |
Verify Hash Thủ Công
Bạn có thể tự tính expected hash để debug:
# Variables — phải match với Nginx expects
EXPIRES="1711234567"
BASE_PATH="/videos/hls/1/1/"
CLIENT_IP="172.18.0.1"
SECRET="your-secret-key"
# Tính MD5 (giống Nginx secure_link_md5)
echo -n "${EXPIRES}${BASE_PATH}${CLIENT_IP}${SECRET}" | \
openssl md5 -binary | \
openssl base64 | \
tr '+/' '-_' | \
tr -d '='
# Output phải match token parameter trong signed URLLỗi Thường Gặp
1. Secret Key Không Khớp Giữa Nginx Và Spring Boot
Đây là lỗi #1. Key trong nginx.conf và application.yml phải giống hệt:
# nginx.conf
secure_link_md5 "...$remote_addr MY_SECRET_KEY";# application.yml
streaming:
secret-key: MY_SECRET_KEY # Phải match chính xác!Dùng environment variable ở cả hai nơi để tránh drift.
2. Quên Forward Client IP
Đằng sau reverse proxy, $remote_addr là IP của proxy, không phải client. Spring Boot đọc IP thật từ X-Forwarded-For, nhưng nếu upstream proxy không set header này, IPs sẽ không khớp:
# Trong API proxy location
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;3. CORS Thiếu Trên Error Responses
add_header của Nginx mặc định không áp dụng cho error responses. Phải dùng parameter always:
# SAI — CORS headers không gửi trên 403/410
add_header Access-Control-Allow-Origin $http_origin;
# ĐÚNG — CORS headers gửi trên tất cả responses kể cả errors
add_header Access-Control-Allow-Origin $http_origin always;Không có always, browser không thể đọc status 403 — chỉ thấy CORS error, khiến debug rất khó.
4. Cache Playlists
Đừng bao giờ cache .m3u8 playlists aggressively. Nếu player nhận stale playlist, nó có thể request segments không còn tồn tại (ví dụ sau khi re-transcoding):
# Playlists: không cache
location ~* \.m3u8$ {
add_header Cache-Control "no-cache, no-store, must-revalidate";
}
# Segments: cache mạnh (chúng không bao giờ thay đổi)
location ~* \.ts$ {
add_header Cache-Control "public, max-age=86400";
}Tiếp Theo?
Ta đã có secure streaming hoạt động — signed URLs, token validation, CORS, và download prevention. Nhưng ta vẫn cần một video player đàng hoàng. Trong Post #9, ta sẽ build custom React video player:
- hls.js integration với quality switching
- Custom player controls (play/pause, seek, volume, fullscreen)
- Playback speed selection
- Resume từ vị trí cuối cùng (progress lưu vào database)
- Debounced progress saves với
sendBeaconkhi thoát trang
Đã đến lúc cho user một cách xem video đẹp và mượt mà.
Series: Xây Dựng Video Streaming Platform
Trước đó: Phase 6: Video Transcoding Pipeline
Tiếp theo: Phase 8: Video Player & Progress Tracking
📬 Đăng ký nhận bản tin
Nhận những bài viết mới nhất qua email mỗi tuần. Không spam, hủy đăng ký bất cứ lúc nào.
Chúng tôi tôn trọng quyền riêng tư của bạn. Hủy đăng ký bất cứ lúc nào.
💬 Comments
Đăng nhập để bình luận
Chúng tôi sẽ không đăng bất cứ điều gì mà không có sự cho phép của bạn.