Back to blog

Xây Dựng Video Platform: Security & Performance Hardening

javaspring-bootreactnextjsvideo-streaming
Xây Dựng Video Platform: Security & Performance Hardening

Features đã chạy. Users có thể sign up, subscribe, xem video, và track progress. Nhưng "chạy được" và "production-ready" là hai khái niệm hoàn toàn khác nhau. Ngay lúc này, ai đó có thể brute-force login endpoint của bạn, video segments nằm trần trên disk không mã hóa, không có caching layer, và bạn không có visibility gì vào các critical events như failed payments.

Trong bài này, ta sẽ harden từng layer một cách có hệ thống — API rate limiting, video encryption, HTTP security headers, Redis caching, static page optimization, và event-driven email notifications.

Thời gian thực hiện: 4–5 giờ
Yêu cầu: Phase 11: Admin Analytics & User Management

Những gì ta sẽ build trong bài này:
✅ API rate limiting với Spring Boot + Redis (Bucket4j)
✅ HLS AES-128 encryption cho video segments
✅ CORS và Content Security Policy headers
✅ Redis caching cho API responses truy cập thường xuyên
✅ Next.js Incremental Static Regeneration (ISR)
✅ Email notifications cho critical events


Rate Limiting với Bucket4j + Redis

Tại Sao Cần Rate Limiting?

Không có rate limiting, một client duy nhất có thể:

  • Brute-force login credentials
  • Scrape toàn bộ course catalog
  • Vắt kiệt server resources bằng API calls liên tục
  • Đẩy Stripe API usage lên bằng checkout attempts liên tiếp

Ta sẽ dùng Bucket4j — một Java rate limiting library dựa trên token bucket algorithm — backed bởi Redis để rate limiting phân tán across multiple server instances.

Dependencies

<!-- pom.xml -->
<dependency>
    <groupId>com.bucket4j</groupId>
    <artifactId>bucket4j-core</artifactId>
    <version>8.7.0</version>
</dependency>
<dependency>
    <groupId>com.bucket4j</groupId>
    <artifactId>bucket4j-redis</artifactId>
    <version>8.7.0</version>
</dependency>

Rate Limit Configuration

// src/main/java/com/videoplatform/api/config/RateLimitConfig.java
package com.videoplatform.api.config;
 
import io.github.bucket4j.Bandwidth;
import io.github.bucket4j.BucketConfiguration;
import io.github.bucket4j.distributed.proxy.ProxyManager;
import io.github.bucket4j.redis.lettuce.cas.LettuceBasedProxyManager;
import io.lettuce.core.RedisClient;
import io.lettuce.core.api.StatefulRedisConnection;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
 
import java.time.Duration;
 
@Configuration
public class RateLimitConfig {
 
    @Value("${spring.data.redis.host:localhost}")
    private String redisHost;
 
    @Value("${spring.data.redis.port:6379}")
    private int redisPort;
 
    @Bean
    public ProxyManager<String> proxyManager() {
        RedisClient redisClient = RedisClient.create(
                String.format("redis://%s:%d", redisHost, redisPort));
        StatefulRedisConnection<String, String> connection = redisClient.connect();
        return LettuceBasedProxyManager.builderFor(connection)
                .build();
    }
 
    // General API: 60 requests/phút
    @Bean("apiRateLimit")
    public BucketConfiguration apiRateLimit() {
        return BucketConfiguration.builder()
                .addLimit(Bandwidth.simple(60, Duration.ofMinutes(1)))
                .build();
    }
 
    // Auth endpoints: 10 requests/phút (nghiêm ngặt hơn)
    @Bean("authRateLimit")
    public BucketConfiguration authRateLimit() {
        return BucketConfiguration.builder()
                .addLimit(Bandwidth.simple(10, Duration.ofMinutes(1)))
                .build();
    }
 
    // Webhook endpoints: 100 requests/phút (Stripe gửi bursts)
    @Bean("webhookRateLimit")
    public BucketConfiguration webhookRateLimit() {
        return BucketConfiguration.builder()
                .addLimit(Bandwidth.simple(100, Duration.ofMinutes(1)))
                .build();
    }
}

Rate Limit Filter

// src/main/java/com/videoplatform/api/filter/RateLimitFilter.java
package com.videoplatform.api.filter;
 
import io.github.bucket4j.Bucket;
import io.github.bucket4j.BucketConfiguration;
import io.github.bucket4j.ConsumptionProbe;
import io.github.bucket4j.distributed.proxy.ProxyManager;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
 
import java.io.IOException;
import java.util.function.Supplier;
 
@Component
public class RateLimitFilter extends OncePerRequestFilter {
 
    private final ProxyManager<String> proxyManager;
    private final BucketConfiguration apiConfig;
    private final BucketConfiguration authConfig;
    private final BucketConfiguration webhookConfig;
 
    public RateLimitFilter(
            ProxyManager<String> proxyManager,
            @Qualifier("apiRateLimit") BucketConfiguration apiConfig,
            @Qualifier("authRateLimit") BucketConfiguration authConfig,
            @Qualifier("webhookRateLimit") BucketConfiguration webhookConfig) {
        this.proxyManager = proxyManager;
        this.apiConfig = apiConfig;
        this.authConfig = authConfig;
        this.webhookConfig = webhookConfig;
    }
 
    @Override
    protected void doFilterInternal(
            HttpServletRequest request,
            HttpServletResponse response,
            FilterChain filterChain) throws ServletException, IOException {
 
        String clientIp = getClientIp(request);
        String path = request.getRequestURI();
 
        // Chọn rate limit config dựa trên path
        BucketConfiguration config;
        String bucketKey;
 
        if (path.startsWith("/api/auth/")) {
            config = authConfig;
            bucketKey = "rate:auth:" + clientIp;
        } else if (path.startsWith("/api/webhooks/")) {
            config = webhookConfig;
            bucketKey = "rate:webhook:" + clientIp;
        } else if (path.startsWith("/api/")) {
            config = apiConfig;
            bucketKey = "rate:api:" + clientIp;
        } else {
            // Không phải API request — bỏ qua rate limiting
            filterChain.doFilter(request, response);
            return;
        }
 
        Supplier<BucketConfiguration> configSupplier = () -> config;
        Bucket bucket = proxyManager.builder()
                .build(bucketKey, configSupplier);
 
        ConsumptionProbe probe = bucket.tryConsumeAndReturnRemaining(1);
 
        if (probe.isConsumed()) {
            // Thêm rate limit headers
            response.addHeader("X-Rate-Limit-Remaining",
                    String.valueOf(probe.getRemainingTokens()));
            filterChain.doFilter(request, response);
        } else {
            // Vượt quá rate limit
            long waitSeconds = probe.getNanosToWaitForRefill() / 1_000_000_000;
            response.addHeader("Retry-After", String.valueOf(waitSeconds));
            response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value());
            response.setContentType("application/json");
            response.getWriter().write("""
                    {"error": "Rate limit exceeded", "retryAfter": %d}
                    """.formatted(waitSeconds));
        }
    }
 
    private String getClientIp(HttpServletRequest request) {
        String forwarded = request.getHeader("X-Forwarded-For");
        if (forwarded != null && !forwarded.isEmpty()) {
            return forwarded.split(",")[0].trim();
        }
        return request.getRemoteAddr();
    }
}

Filter dùng token bucket per IP address. Auth endpoints có limit nghiêm ngặt hơn (10/phút) để chống brute-force, trong khi webhook endpoints có limit cao hơn (100/phút) vì Stripe có thể gửi event bursts. Headers X-Rate-Limit-Remaining và Retry-After giúp clients back off một cách graceful.

Đăng Ký Filter

// Thêm vào SecurityConfig
@Bean
public SecurityFilterChain securityFilterChain(
        HttpSecurity http, RateLimitFilter rateLimitFilter) throws Exception {
 
    http.addFilterBefore(rateLimitFilter, UsernamePasswordAuthenticationFilter.class);
 
    // ... phần config còn lại
}

HLS AES-128 Encryption

Tại Sao Mã Hóa Video Segments?

Dù đã có signed URLs, khi một .ts segment được download xong thì nó có thể play mãi mãi. AES-128 encryption nghĩa là mỗi segment được mã hóa — dù ai đó save files, họ cũng không thể play nếu không có decryption key.

Encryption Flow

Transcoding Cập Nhật với Encryption

// src/main/java/com/videoplatform/api/service/TranscodingService.java
// Method transcodeToHls đã cập nhật
 
private void transcodeToHls(Path inputFile, Path outputDir, String resolution,
                             int width, int height) throws IOException, InterruptedException {
 
    Path resDir = outputDir.resolve(resolution);
    Files.createDirectories(resDir);
 
    // Generate encryption key
    Path keyFile = outputDir.resolve("enc.key");
    Path keyInfoFile = outputDir.resolve("enc.keyinfo");
 
    if (!Files.exists(keyFile)) {
        // Generate random 16-byte AES key
        byte[] key = new byte[16];
        new java.security.SecureRandom().nextBytes(key);
        Files.write(keyFile, key);
 
        // Generate random IV
        byte[] iv = new byte[16];
        new java.security.SecureRandom().nextBytes(iv);
        String ivHex = bytesToHex(iv);
 
        // Key info file: key URL, key file path, IV
        // Key URL là nơi hls.js sẽ fetch key trong quá trình playback
        String keyInfo = String.format(
                "%s/api/videos/key/{lessonId}\n%s\n%s",
                appBaseUrl, keyFile.toAbsolutePath(), ivHex);
        Files.write(keyInfoFile, keyInfo.getBytes());
    }
 
    List<String> command = List.of(
            "ffmpeg", "-i", inputFile.toString(),
            "-vf", String.format("scale=%d:%d", width, height),
            "-c:v", "libx264", "-preset", "medium",
            "-c:a", "aac", "-b:a", "128k",
            "-hls_time", "6",
            "-hls_list_size", "0",
            "-hls_segment_filename", resDir.resolve("seg%03d.ts").toString(),
            "-hls_key_info_file", keyInfoFile.toString(),  // Bật encryption
            "-f", "hls",
            resDir.resolve("playlist.m3u8").toString()
    );
 
    ProcessBuilder pb = new ProcessBuilder(command);
    pb.redirectErrorStream(true);
    Process process = pb.start();
 
    int exitCode = process.waitFor();
    if (exitCode != 0) {
        throw new RuntimeException("FFmpeg transcoding failed with exit code: " + exitCode);
    }
}
 
private static String bytesToHex(byte[] bytes) {
    StringBuilder sb = new StringBuilder();
    for (byte b : bytes) {
        sb.append(String.format("%02x", b));
    }
    return sb.toString();
}

Thay đổi chính là -hls_key_info_file — flag này bảo FFmpeg encrypt mọi .ts segment bằng AES-128. Key info file chứa 3 dòng: URL nơi player fetch key, đường dẫn local đến key file, và initialization vector (IV).

Key Delivery Endpoint

// src/main/java/com/videoplatform/api/controller/VideoKeyController.java
package com.videoplatform.api.controller;
 
import com.videoplatform.api.service.SubscriptionService;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
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.*;
 
import java.nio.file.Files;
import java.nio.file.Path;
 
@RestController
@RequestMapping("/api/videos")
public class VideoKeyController {
 
    private final SubscriptionService subscriptionService;
    private final String videoStoragePath;
 
    public VideoKeyController(
            SubscriptionService subscriptionService,
            @Value("${video.storage.path}") String videoStoragePath) {
        this.subscriptionService = subscriptionService;
        this.videoStoragePath = videoStoragePath;
    }
 
    @GetMapping("/key/{lessonId}")
    public ResponseEntity<byte[]> getDecryptionKey(
            @PathVariable Long lessonId,
            @AuthenticationPrincipal UserDetails userDetails) {
 
        // Xác minh subscription active
        if (!subscriptionService.hasActiveSubscription(userDetails.getUsername())) {
            return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
        }
 
        try {
            Path keyFile = Path.of(videoStoragePath, "lessons", lessonId.toString(), "enc.key");
            byte[] key = Files.readAllBytes(keyFile);
 
            return ResponseEntity.ok()
                    .contentType(MediaType.APPLICATION_OCTET_STREAM)
                    .header("Cache-Control", "no-store") // Không bao giờ cache key
                    .body(key);
        } catch (Exception e) {
            return ResponseEntity.notFound().build();
        }
    }
}

Key endpoint yêu cầu cả authentication (JWT) lẫn active subscription. Header Cache-Control: no-store đảm bảo browser không bao giờ cache encryption key — mỗi playback session phải re-authenticate.

M3U8 Đã Generate với Encryption

Sau transcoding, file .m3u8 playlist trông như thế này:

#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:6
#EXT-X-MEDIA-SEQUENCE:0
#EXT-X-KEY:METHOD=AES-128,URI="https://api.example.com/api/videos/key/42",IV=0x9a3b...
#EXTINF:6.000,
seg000.ts
#EXTINF:6.000,
seg001.ts
#EXTINF:4.320,
seg002.ts
#EXT-X-ENDLIST

hls.js đọc tag #EXT-X-KEY, fetch key từ API (kèm JWT cookie/header), và decrypt từng segment trước khi playback. Tất cả diễn ra trong suốt — không cần thay đổi gì trong player component từ Post #9.


CORS & Security Headers

CORS Configuration

// 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.cors.allowed-origins}")
    private List<String> allowedOrigins;
 
    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration config = new CorsConfiguration();
        config.setAllowedOrigins(allowedOrigins);
        config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
        config.setAllowedHeaders(List.of(
                "Authorization", "Content-Type", "X-Requested-With"));
        config.setExposedHeaders(List.of(
                "X-Rate-Limit-Remaining", "Retry-After"));
        config.setAllowCredentials(true);
        config.setMaxAge(3600L); // Cache preflight 1 giờ
 
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/api/**", config);
        return source;
    }
}
# application.yml
app:
  cors:
    allowed-origins:
      - https://yourdomain.com
      - http://localhost:3000  # Chỉ dùng cho dev

Security Headers với Nginx

# /etc/nginx/conf.d/security-headers.conf
 
# Chặn MIME type sniffing
add_header X-Content-Type-Options "nosniff" always;
 
# Chặn clickjacking
add_header X-Frame-Options "SAMEORIGIN" always;
 
# XSS protection (legacy browsers)
add_header X-XSS-Protection "1; mode=block" always;
 
# Referrer policy
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
 
# Permissions policy — tắt browser APIs không dùng
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=(self)" always;
 
# Strict Transport Security (chỉ HTTPS)
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
 
# Content Security Policy
add_header Content-Security-Policy "
    default-src 'self';
    script-src 'self' 'unsafe-inline' https://js.stripe.com;
    style-src 'self' 'unsafe-inline';
    img-src 'self' data: https:;
    font-src 'self';
    connect-src 'self' https://api.stripe.com;
    frame-src https://js.stripe.com https://hooks.stripe.com;
    media-src 'self' blob:;
    object-src 'none';
    base-uri 'self';
" always;

CSP là header quan trọng nhất. Phân tích chi tiết:

  • script-src: Chỉ domain của ta + Stripe.js (cho checkout)
  • connect-src: API calls đến self + Stripe API
  • frame-src: Stripe's iframe cho checkout và 3D Secure
  • media-src: Self + blob: (hls.js tạo blob URLs cho video playback)
  • object-src 'none': Block tất cả plugins (Flash, Java applets)

Nginx HTTPS Configuration

# /etc/nginx/sites-available/videoplatform
server {
    listen 80;
    server_name yourdomain.com;
    return 301 https://$server_name$request_uri;
}
 
server {
    listen 443 ssl http2;
    server_name yourdomain.com;
 
    ssl_certificate /etc/letsencrypt/live/yourdomain.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/yourdomain.com/privkey.pem;
 
    # Modern TLS config
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256;
    ssl_prefer_server_ciphers off;
 
    # Include security headers
    include /etc/nginx/conf.d/security-headers.conf;
 
    # API proxy
    location /api/ {
        proxy_pass http://localhost:8080;
        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;
    }
 
    # Next.js
    location / {
        proxy_pass http://localhost:3000;
        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;
    }
 
    # HLS video serving (với secure_link từ Post #8)
    location /videos/hls/ {
        internal;
        alias /var/videos/hls/;
        add_header Cache-Control "private, max-age=86400";
        add_header X-Content-Type-Options "nosniff";
    }
}

Redis Caching

Cache Configuration

// src/main/java/com/videoplatform/api/config/CacheConfig.java
package com.videoplatform.api.config;
 
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.cache.RedisCacheManager;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.RedisSerializationContext;
 
import java.time.Duration;
import java.util.Map;
 
@Configuration
@EnableCaching
public class CacheConfig {
 
    @Bean
    public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) {
        // Mặc định: 5 phút
        RedisCacheConfiguration defaults = RedisCacheConfiguration.defaultCacheConfig()
                .entryTtl(Duration.ofMinutes(5))
                .serializeValuesWith(
                        RedisSerializationContext.SerializationPair
                                .fromSerializer(new GenericJackson2JsonRedisSerializer()));
 
        // TTL riêng cho từng cache
        Map<String, RedisCacheConfiguration> cacheConfigs = Map.of(
                "courses", defaults.entryTtl(Duration.ofMinutes(15)),
                "course-detail", defaults.entryTtl(Duration.ofMinutes(10)),
                "public-courses", defaults.entryTtl(Duration.ofMinutes(15)),
                "dashboard-metrics", defaults.entryTtl(Duration.ofMinutes(2)),
                "popular-lessons", defaults.entryTtl(Duration.ofMinutes(5)),
                "user-subscription", defaults.entryTtl(Duration.ofMinutes(3))
        );
 
        return RedisCacheManager.builder(connectionFactory)
                .cacheDefaults(defaults)
                .withInitialCacheConfigurations(cacheConfigs)
                .build();
    }
}

Áp Dụng Cache vào Services

// PublicCourseService — cache course listings và details
@Service
public class PublicCourseService {
 
    @Cacheable(value = "public-courses", key = "'all'")
    @Transactional(readOnly = true)
    public List<PublicCourseListResponse> getAllPublishedCourses() {
        return courseRepository.findByStatus(CourseStatus.PUBLISHED)
                .stream()
                .map(this::toListResponse)
                .toList();
    }
 
    @Cacheable(value = "course-detail", key = "#slug")
    @Transactional(readOnly = true)
    public PublicCourseDetailResponse getCourseBySlug(String slug) {
        Course course = courseRepository.findBySlugAndStatus(slug, CourseStatus.PUBLISHED)
                .orElseThrow(() -> new ResourceNotFoundException("Course not found"));
        return toDetailResponse(course);
    }
 
    // Evict cache khi course được update
    @CacheEvict(value = {"public-courses", "course-detail"}, allEntries = true)
    @Transactional
    public void updateCourse(Long id, UpdateCourseRequest request) {
        // ... update logic
    }
}
// AnalyticsService — cache dashboard metrics
@Cacheable(value = "dashboard-metrics", key = "'current'")
@Transactional(readOnly = true)
public DashboardMetrics getDashboardMetrics() {
    // ... expensive aggregation queries
}
 
@Cacheable(value = "popular-lessons", key = "#limit")
@Transactional(readOnly = true)
public List<PopularLessonResponse> getPopularLessons(int limit) {
    return progressRepository.findPopularLessons(limit);
}
// SubscriptionService — cache subscription checks
@Cacheable(value = "user-subscription", key = "#email")
public boolean hasActiveSubscription(String email) {
    return subscriptionRepository
            .existsByUserEmailAndStatus(email, SubscriptionStatus.ACTIVE);
}
 
// Evict khi subscription thay đổi
@CacheEvict(value = "user-subscription", key = "#email")
public void onSubscriptionChanged(String email) {
    // Chỉ evict cache
}

Tóm Tắt Cache Strategy

CacheTTLEvictionLý do
public-courses15 phútKhi course updateCourse list hiếm khi thay đổi
course-detail10 phútKhi course updateTrang course riêng lẻ
dashboard-metrics2 phútTime-basedAdmin cần data gần real-time
popular-lessons5 phútTime-basedChấp nhận được staleness
user-subscription3 phútKhi subscription thay đổiCheck thường xuyên, webhook evicts

Next.js Incremental Static Regeneration

ISR cho Course Pages

// web/src/app/courses/[slug]/page.tsx
import { Metadata } from "next";
 
const API_BASE = process.env.API_URL || "http://localhost:8080";
 
// Revalidate mỗi 5 phút
export const revalidate = 300;
 
export async function generateStaticParams() {
  const res = await fetch(`${API_BASE}/api/public/courses`);
  const data = await res.json();
 
  return data.data.map((course: { slug: string }) => ({
    slug: course.slug,
  }));
}
 
export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { slug } = await params;
  const res = await fetch(`${API_BASE}/api/public/courses/${slug}`, {
    next: { revalidate: 300 },
  });
  const data = await res.json();
  const course = data.data;
 
  return {
    title: course.title,
    description: course.shortDescription,
    openGraph: {
      title: course.title,
      description: course.shortDescription,
      images: [course.thumbnailUrl],
    },
  };
}
 
export default async function CoursePage({ params }: Props) {
  const { slug } = await params;
  const res = await fetch(`${API_BASE}/api/public/courses/${slug}`, {
    next: { revalidate: 300 },
  });
  const data = await res.json();
  const course = data.data;
 
  return (
    <main>
      <CourseHero course={course} />
      <CourseSyllabus sections={course.sections} />
    </main>
  );
}

ISR cho Catalog Page

// web/src/app/courses/page.tsx
export const revalidate = 300; // 5 phút
 
export default async function CatalogPage() {
  const res = await fetch(`${API_BASE}/api/public/courses`, {
    next: { revalidate: 300 },
  });
  const data = await res.json();
 
  return (
    <main className="container mx-auto px-4 py-8">
      <h1 className="text-4xl font-bold mb-8">Course Catalog</h1>
      <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
        {data.data.map((course: Course) => (
          <CourseCard key={course.id} course={course} />
        ))}
      </div>
    </main>
  );
}

On-Demand Revalidation

Khi admin update course, trigger ISR revalidation:

// web/src/app/api/revalidate/route.ts
import { revalidatePath } from "next/cache";
import { NextRequest, NextResponse } from "next/server";
 
export async function POST(request: NextRequest) {
  const secret = request.headers.get("x-revalidation-secret");
  if (secret !== process.env.REVALIDATION_SECRET) {
    return NextResponse.json({ error: "Invalid secret" }, { status: 401 });
  }
 
  const { path } = await request.json();
 
  revalidatePath(path);
  revalidatePath("/courses"); // Revalidate cả catalog
 
  return NextResponse.json({ revalidated: true });
}
// Gọi từ Spring Boot khi course được update
@Service
public class RevalidationService {
 
    @Value("${app.nextjs.url}")
    private String nextjsUrl;
 
    @Value("${app.nextjs.revalidation-secret}")
    private String revalidationSecret;
 
    private final RestTemplate restTemplate;
 
    public void revalidateCoursePage(String slug) {
        HttpHeaders headers = new HttpHeaders();
        headers.set("x-revalidation-secret", revalidationSecret);
        headers.setContentType(MediaType.APPLICATION_JSON);
 
        Map<String, String> body = Map.of("path", "/courses/" + slug);
        HttpEntity<Map<String, String>> request = new HttpEntity<>(body, headers);
 
        try {
            restTemplate.postForEntity(
                    nextjsUrl + "/api/revalidate", request, String.class);
        } catch (Exception e) {
            // Log nhưng không fail — ISR vẫn sẽ refresh theo TTL
            log.warn("Failed to trigger ISR revalidation for {}: {}", slug, e.getMessage());
        }
    }
}

Flow ISR:

  1. Request đầu tiên: Next.js build page statically và serve
  2. Các request tiếp theo trong 5 phút: Serve cached static page (instant)
  3. Sau 5 phút: Request tiếp theo trigger background regeneration, stale page được serve ngay
  4. On-demand: Admin update trigger revalidation ngay lập tức qua API

Email Notifications

Notification Events

// src/main/java/com/videoplatform/api/event/NotificationEvent.java
package com.videoplatform.api.event;
 
public record NotificationEvent(
    NotificationType type,
    String recipientEmail,
    String recipientName,
    java.util.Map<String, String> data
) {
    public enum NotificationType {
        WELCOME,
        SUBSCRIPTION_ACTIVATED,
        SUBSCRIPTION_EXPIRING_SOON,
        PAYMENT_FAILED,
        COURSE_COMPLETED
    }
}

Event Publisher

// Publish events từ services
@Service
public class SubscriptionWebhookService {
 
    private final ApplicationEventPublisher eventPublisher;
 
    // Trong handleCheckoutCompleted:
    private void onNewSubscription(User user, Subscription subscription) {
        // ... save subscription
 
        eventPublisher.publishEvent(new NotificationEvent(
                NotificationEvent.NotificationType.SUBSCRIPTION_ACTIVATED,
                user.getEmail(),
                user.getName(),
                Map.of(
                        "planType", subscription.getPlanType().name(),
                        "endDate", subscription.getCurrentPeriodEnd().toString()
                )
        ));
    }
 
    // Trong handleInvoicePaymentFailed:
    private void onPaymentFailed(User user) {
        eventPublisher.publishEvent(new NotificationEvent(
                NotificationEvent.NotificationType.PAYMENT_FAILED,
                user.getEmail(),
                user.getName(),
                Map.of("portalUrl", stripeService.createPortalSession(user.getEmail()))
        ));
    }
}

Async Email Listener

// src/main/java/com/videoplatform/api/listener/NotificationListener.java
package com.videoplatform.api.listener;
 
import com.videoplatform.api.event.NotificationEvent;
import com.videoplatform.api.service.EmailService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.event.EventListener;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;
 
@Component
public class NotificationListener {
 
    private static final Logger log = LoggerFactory.getLogger(NotificationListener.class);
    private final EmailService emailService;
 
    public NotificationListener(EmailService emailService) {
        this.emailService = emailService;
    }
 
    @Async
    @EventListener
    public void handleNotification(NotificationEvent event) {
        try {
            switch (event.type()) {
                case WELCOME -> emailService.sendWelcomeEmail(
                        event.recipientEmail(), event.recipientName());
 
                case SUBSCRIPTION_ACTIVATED -> emailService.sendSubscriptionActivated(
                        event.recipientEmail(), event.recipientName(),
                        event.data().get("planType"),
                        event.data().get("endDate"));
 
                case PAYMENT_FAILED -> emailService.sendPaymentFailedEmail(
                        event.recipientEmail(), event.recipientName(),
                        event.data().get("portalUrl"));
 
                case SUBSCRIPTION_EXPIRING_SOON -> emailService.sendExpiringNotification(
                        event.recipientEmail(), event.recipientName(),
                        event.data().get("daysRemaining"));
 
                case COURSE_COMPLETED -> emailService.sendCourseCompletedEmail(
                        event.recipientEmail(), event.recipientName(),
                        event.data().get("courseTitle"));
            }
 
            log.info("Notification sent: {} to {}", event.type(), event.recipientEmail());
        } catch (Exception e) {
            log.error("Failed to send notification: {} to {}",
                    event.type(), event.recipientEmail(), e);
        }
    }
}

@Async + @EventListener là combo mạnh — service publish event và trả response ngay lập tức, email được gửi trong background thread. Nếu gửi email fail, API response không bị ảnh hưởng.

Email Service

// src/main/java/com/videoplatform/api/service/EmailService.java
package com.videoplatform.api.service;
 
import org.springframework.beans.factory.annotation.Value;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.stereotype.Service;
 
import jakarta.mail.MessagingException;
import jakarta.mail.internet.MimeMessage;
 
@Service
public class EmailService {
 
    private final JavaMailSender mailSender;
 
    @Value("${app.email.from}")
    private String fromEmail;
 
    @Value("${app.base-url}")
    private String baseUrl;
 
    public EmailService(JavaMailSender mailSender) {
        this.mailSender = mailSender;
    }
 
    public void sendSubscriptionActivated(
            String to, String name, String planType, String endDate) throws MessagingException {
 
        String subject = "Your subscription is now active!";
        String html = """
                <div style="font-family: sans-serif; max-width: 600px; margin: 0 auto;">
                    <h1 style="color: #1a1a2e;">Welcome aboard, %s!</h1>
                    <p>Your <strong>%s</strong> subscription is now active.</p>
                    <p>You have full access to all courses and lessons until <strong>%s</strong>.</p>
                    <a href="%s/courses"
                       style="display: inline-block; padding: 12px 24px; background: #6366f1;
                              color: white; text-decoration: none; border-radius: 8px; margin-top: 16px;">
                        Start Learning
                    </a>
                    <p style="color: #666; margin-top: 24px; font-size: 14px;">
                        Questions? Reply to this email — we're here to help.
                    </p>
                </div>
                """.formatted(name, planType, endDate, baseUrl);
 
        sendHtmlEmail(to, subject, html);
    }
 
    public void sendPaymentFailedEmail(
            String to, String name, String portalUrl) throws MessagingException {
 
        String subject = "Action needed: Payment failed";
        String html = """
                <div style="font-family: sans-serif; max-width: 600px; margin: 0 auto;">
                    <h1 style="color: #1a1a2e;">Payment Issue</h1>
                    <p>Hi %s, we couldn't process your latest payment.</p>
                    <p>Your subscription is still active, but please update your payment method
                       to avoid interruption.</p>
                    <a href="%s"
                       style="display: inline-block; padding: 12px 24px; background: #ef4444;
                              color: white; text-decoration: none; border-radius: 8px; margin-top: 16px;">
                        Update Payment Method
                    </a>
                </div>
                """.formatted(name, portalUrl);
 
        sendHtmlEmail(to, subject, html);
    }
 
    public void sendCourseCompletedEmail(
            String to, String name, String courseTitle) throws MessagingException {
 
        String subject = "Congratulations! You completed " + courseTitle;
        String html = """
                <div style="font-family: sans-serif; max-width: 600px; margin: 0 auto;">
                    <h1 style="color: #1a1a2e;">Course Complete!</h1>
                    <p>Amazing work, %s! You've completed <strong>%s</strong>.</p>
                    <a href="%s/courses"
                       style="display: inline-block; padding: 12px 24px; background: #22c55e;
                              color: white; text-decoration: none; border-radius: 8px; margin-top: 16px;">
                        Explore More Courses
                    </a>
                </div>
                """.formatted(name, courseTitle, baseUrl);
 
        sendHtmlEmail(to, subject, html);
    }
 
    public void sendWelcomeEmail(String to, String name) throws MessagingException {
        String subject = "Welcome to the platform!";
        String html = """
                <div style="font-family: sans-serif; max-width: 600px; margin: 0 auto;">
                    <h1 style="color: #1a1a2e;">Welcome, %s!</h1>
                    <p>Thanks for joining. Browse our courses and start learning today.</p>
                    <a href="%s/courses"
                       style="display: inline-block; padding: 12px 24px; background: #6366f1;
                              color: white; text-decoration: none; border-radius: 8px; margin-top: 16px;">
                        Browse Courses
                    </a>
                </div>
                """.formatted(name, baseUrl);
 
        sendHtmlEmail(to, subject, html);
    }
 
    public void sendExpiringNotification(
            String to, String name, String daysRemaining) throws MessagingException {
 
        String subject = "Your subscription expires in " + daysRemaining + " days";
        String html = """
                <div style="font-family: sans-serif; max-width: 600px; margin: 0 auto;">
                    <h1 style="color: #1a1a2e;">Subscription Expiring Soon</h1>
                    <p>Hi %s, your subscription expires in <strong>%s days</strong>.</p>
                    <p>Renew now to keep your access to all courses.</p>
                    <a href="%s/pricing"
                       style="display: inline-block; padding: 12px 24px; background: #f59e0b;
                              color: white; text-decoration: none; border-radius: 8px; margin-top: 16px;">
                        Renew Subscription
                    </a>
                </div>
                """.formatted(name, daysRemaining, baseUrl);
 
        sendHtmlEmail(to, subject, html);
    }
 
    private void sendHtmlEmail(String to, String subject, String html) throws MessagingException {
        MimeMessage message = mailSender.createMimeMessage();
        MimeMessageHelper helper = new MimeMessageHelper(message, true);
        helper.setFrom(fromEmail);
        helper.setTo(to);
        helper.setSubject(subject);
        helper.setText(html, true);
        mailSender.send(message);
    }
}

Scheduled Expiration Check

// src/main/java/com/videoplatform/api/scheduler/SubscriptionScheduler.java
package com.videoplatform.api.scheduler;
 
import com.videoplatform.api.entity.Subscription;
import com.videoplatform.api.entity.SubscriptionStatus;
import com.videoplatform.api.event.NotificationEvent;
import com.videoplatform.api.repository.SubscriptionRepository;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
 
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
 
@Component
public class SubscriptionScheduler {
 
    private final SubscriptionRepository subscriptionRepository;
    private final ApplicationEventPublisher eventPublisher;
 
    public SubscriptionScheduler(
            SubscriptionRepository subscriptionRepository,
            ApplicationEventPublisher eventPublisher) {
        this.subscriptionRepository = subscriptionRepository;
        this.eventPublisher = eventPublisher;
    }
 
    // Chạy hàng ngày lúc 9 giờ sáng
    @Scheduled(cron = "0 0 9 * * *")
    public void checkExpiringSubscriptions() {
        // Tìm subscriptions sắp hết hạn trong 3 ngày
        LocalDateTime threeDaysFromNow = LocalDateTime.now().plusDays(3);
        LocalDateTime fourDaysFromNow = LocalDateTime.now().plusDays(4);
 
        List<Subscription> expiring = subscriptionRepository
                .findByStatusAndCurrentPeriodEndBetween(
                        SubscriptionStatus.ACTIVE,
                        threeDaysFromNow, fourDaysFromNow);
 
        for (Subscription sub : expiring) {
            eventPublisher.publishEvent(new NotificationEvent(
                    NotificationEvent.NotificationType.SUBSCRIPTION_EXPIRING_SOON,
                    sub.getUser().getEmail(),
                    sub.getUser().getName(),
                    Map.of("daysRemaining", "3")
            ));
        }
    }
}

Mail Configuration

# application.yml
spring:
  mail:
    host: smtp.gmail.com    # Hoặc SMTP provider của bạn
    port: 587
    username: ${MAIL_USERNAME}
    password: ${MAIL_PASSWORD}
    properties:
      mail.smtp.auth: true
      mail.smtp.starttls.enable: true
 
app:
  email:
    from: [email protected]
  base-url: https://yourdomain.com

Testing

1. Rate Limiting

# Hit login endpoint 11 lần liên tiếp
for i in $(seq 1 11); do
  STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
    -X POST http://localhost:8080/api/auth/login \
    -H "Content-Type: application/json" \
    -d '{"email":"[email protected]","password":"wrong"}')
  echo "Request $i: HTTP $STATUS"
done
 
# Expected: 10 cái đầu trả 401 (sai password), cái thứ 11 trả 429

2. HLS Encryption

# Kiểm tra .ts files đã encrypted (không play trực tiếp được)
file /var/videos/hls/lessons/1/720p/seg000.ts
# Kết quả: "data" (encrypted), không phải "MPEG transport stream"
 
# Kiểm tra m3u8 có encryption tag
grep "EXT-X-KEY" /var/videos/hls/lessons/1/720p/playlist.m3u8
# Kết quả: #EXT-X-KEY:METHOD=AES-128,URI="...",IV=0x...

3. Security Headers

curl -I https://yourdomain.com | grep -E "(Content-Security|X-Frame|X-Content-Type|Strict-Transport)"

4. Redis Cache

# Kiểm tra Redis keys sau khi gọi API
redis-cli keys "public-courses*"
redis-cli keys "dashboard-metrics*"
redis-cli ttl "public-courses::all"  # Kết quả khoảng 900 (15 phút tính bằng giây)

5. Kiểm Tra ISR

# Request đầu tiên — generate static page
curl -s -w "\nTime: %{time_total}s\n" http://localhost:3000/courses/java-basics
 
# Request thứ hai — served from cache (nhanh hơn)
curl -s -w "\nTime: %{time_total}s\n" http://localhost:3000/courses/java-basics

Những Lỗi Thường Gặp

1. Rate Limiting Chỉ Bằng User ID

// SAI — unauthenticated requests bypass rate limiting
String key = "rate:" + currentUser.getId();
 
// ĐÚNG — rate limit theo IP cho unauthenticated, theo user cho authenticated
String key = isAuthenticated
    ? "rate:user:" + currentUser.getId()
    : "rate:ip:" + getClientIp(request);

2. Caching Data Riêng Của User

// SAI — tất cả users thấy cùng cached subscription status
@Cacheable("subscription")
public boolean hasActiveSubscription(String email) { ... }
 
// ĐÚNG — include user identifier trong cache key
@Cacheable(value = "user-subscription", key = "#email")
public boolean hasActiveSubscription(String email) { ... }

3. Quên Evict Cache

// SAI — stale course data sau khi update
@Transactional
public void updateCourse(Long id, UpdateCourseRequest request) {
    // ... save changes nhưng cache vẫn giữ data cũ
}
 
// ĐÚNG — evict related caches
@CacheEvict(value = {"public-courses", "course-detail"}, allEntries = true)
@Transactional
public void updateCourse(Long id, UpdateCourseRequest request) {
    // ... save changes, caches đã clear
}

4. Block API Response Chờ Gửi Email

// SAI — API response phải chờ email gửi xong
public void onNewSubscription(User user) {
    subscriptionRepository.save(subscription);
    emailService.sendSubscriptionActivated(user.getEmail(), ...); // Block!
}
 
// ĐÚNG — async event-driven
public void onNewSubscription(User user) {
    subscriptionRepository.save(subscription);
    eventPublisher.publishEvent(new NotificationEvent(...)); // Non-blocking
}

What's Next?

Platform giờ đã được harden — rate-limited APIs, encrypted video, security headers, caching, và proactive notifications. Ở Post #14, ta sẽ thêm comprehensive testing:

  • JUnit + Mockito cho unit tests
  • Spring Boot test slices cho API tests
  • Testcontainers cho Postgres/Redis integration
  • Playwright cho frontend E2E tests
  • Stripe webhook testing

Đã đến lúc đảm bảo mọi thứ ta đã build thực sự hoạt động đúng.

Series: Xây Dựng Video Streaming Platform
Trước đó: Phase 11: Admin Analytics & User Management
Tiếp theo: Phase 13: Testing Strategy

📬 Đă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.