Back to blog

Xây Dựng Video Platform: Video Upload & Storage

javaspring-bootreactnextjsvideo-streaming
Xây Dựng Video Platform: Video Upload & Storage

Course có structure. Lesson có title. Nhưng chưa có video. Trong bài này, chúng ta sẽ build upload pipeline — nhận video file từ admin dashboard, validate chúng, lưu trên disk theo layout có tổ chức, và track upload progress real time.

Đây là nửa đầu của video pipeline. Upload đưa raw file lên disk. Ở Post #7, FFmpeg sẽ transcode nó thành HLS stream.

Thời gian: 2–3 giờ
Yêu cầu: Phase 4: Admin Dashboard

Những gì chúng ta sẽ build trong bài này:
✅ Multipart file upload endpoint với Spring Boot
✅ File validation (size, format, content type)
✅ Organized storage layout trên disk
✅ Upload progress tracking qua SSE (Server-Sent Events)
✅ Admin upload UI với drag-and-drop và progress bar
✅ Update lesson video_path sau khi upload thành công


Upload Architecture


Storage Layout

Trước khi viết code upload, hãy thiết kế disk layout. Cấu trúc storage có tổ chức giúp dễ tìm file, dọn dẹp orphan, và serve content:

/data/videos/
├── raw/                              # File upload gốc
│   ├── course-1/
│   │   ├── lesson-1/
│   │   │   └── intro-to-spring.mp4
│   │   └── lesson-2/
│   │       └── setting-up-ide.mp4
│   └── course-2/
│       └── lesson-1/
│           └── react-basics.mp4
├── hls/                              # HLS output sau transcode (Post #7)
│   ├── course-1/
│   │   ├── lesson-1/
│   │   │   ├── 360p/
│   │   │   │   ├── segment-0.ts
│   │   │   │   └── playlist.m3u8
│   │   │   ├── 720p/
│   │   │   │   ├── segment-0.ts
│   │   │   │   └── playlist.m3u8
│   │   │   └── master.m3u8
│   │   └── ...
│   └── ...
└── thumbnails/                       # Thumbnail tự sinh (tương lai)

Các quyết định quan trọng:

Quyết địnhLý do
Tách riêng raw/ và hls/File raw lớn và tạm thời; file HLS mới là thứ được serve
Tổ chức theo courseId/lessonId/Dễ tìm, dễ xoá khi lesson bị remove
Một file duy nhất mỗi lessonMột video mỗi lesson cho đơn giản
Mount dạng Docker volumeData tồn tại qua các lần container restart

Cấu Hình

# src/main/resources/application.yaml
app:
  storage:
    base-path: /data/videos
    raw-path: ${app.storage.base-path}/raw
    hls-path: ${app.storage.base-path}/hls
    max-file-size: 2GB
    allowed-types:
      - video/mp4
      - video/quicktime
      - video/x-msvideo
      - video/webm
      - video/x-matroska
    allowed-extensions:
      - mp4
      - mov
      - avi
      - webm
      - mkv
 
spring:
  servlet:
    multipart:
      enabled: true
      max-file-size: 2GB
      max-request-size: 2GB
// src/main/java/com/videoplatform/api/config/StorageConfig.java
package com.videoplatform.api.config;
 
import jakarta.annotation.PostConstruct;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
 
@Configuration
@ConfigurationProperties(prefix = "app.storage")
public class StorageConfig {
 
    private String basePath;
    private String rawPath;
    private String hlsPath;
    private String maxFileSize;
    private List<String> allowedTypes;
    private List<String> allowedExtensions;
 
    @PostConstruct
    public void init() throws IOException {
        // Tạo directory khi startup
        Files.createDirectories(Path.of(rawPath));
        Files.createDirectories(Path.of(hlsPath));
    }
 
    public long getMaxFileSizeBytes() {
        String size = maxFileSize.toUpperCase();
        if (size.endsWith("GB")) {
            return Long.parseLong(size.replace("GB", "")) * 1024 * 1024 * 1024;
        }
        if (size.endsWith("MB")) {
            return Long.parseLong(size.replace("MB", "")) * 1024 * 1024;
        }
        return Long.parseLong(size);
    }
 
    // Getter và setter
    public String getBasePath() { return basePath; }
    public void setBasePath(String basePath) { this.basePath = basePath; }
    public String getRawPath() { return rawPath; }
    public void setRawPath(String rawPath) { this.rawPath = rawPath; }
    public String getHlsPath() { return hlsPath; }
    public void setHlsPath(String hlsPath) { this.hlsPath = hlsPath; }
    public String getMaxFileSize() { return maxFileSize; }
    public void setMaxFileSize(String maxFileSize) { this.maxFileSize = maxFileSize; }
    public List<String> getAllowedTypes() { return allowedTypes; }
    public void setAllowedTypes(List<String> allowedTypes) { this.allowedTypes = allowedTypes; }
    public List<String> getAllowedExtensions() { return allowedExtensions; }
    public void setAllowedExtensions(List<String> allowedExtensions) { this.allowedExtensions = allowedExtensions; }
}

Method @PostConstruct tạo các storage directory khi app start. Không còn lỗi "directory not found" khi upload lần đầu.


File Validation

Không bao giờ tin user input — đặc biệt file upload. Chúng ta validate ba thứ: content type, file extension, và file size.

// src/main/java/com/videoplatform/api/service/FileValidationService.java
package com.videoplatform.api.service;
 
import com.videoplatform.api.config.StorageConfig;
import com.videoplatform.api.exception.BadRequestException;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
 
@Service
public class FileValidationService {
 
    private final StorageConfig storageConfig;
 
    public FileValidationService(StorageConfig storageConfig) {
        this.storageConfig = storageConfig;
    }
 
    public void validateVideoFile(MultipartFile file) {
        if (file.isEmpty()) {
            throw new BadRequestException("File is empty");
        }
 
        // Kiểm tra file size
        if (file.getSize() > storageConfig.getMaxFileSizeBytes()) {
            throw new BadRequestException(
                "File size exceeds maximum allowed: " + storageConfig.getMaxFileSize()
            );
        }
 
        // Kiểm tra content type
        String contentType = file.getContentType();
        if (contentType == null || !storageConfig.getAllowedTypes().contains(contentType)) {
            throw new BadRequestException(
                "Invalid file type: " + contentType +
                ". Allowed: " + String.join(", ", storageConfig.getAllowedTypes())
            );
        }
 
        // Kiểm tra file extension
        String filename = file.getOriginalFilename();
        if (filename == null || !hasAllowedExtension(filename)) {
            throw new BadRequestException(
                "Invalid file extension. Allowed: " +
                String.join(", ", storageConfig.getAllowedExtensions())
            );
        }
    }
 
    private boolean hasAllowedExtension(String filename) {
        String extension = filename.substring(filename.lastIndexOf('.') + 1).toLowerCase();
        return storageConfig.getAllowedExtensions().contains(extension);
    }
}

Tại sao validate cả content type LẪN extension? Vì:

  • Content type có thể giả mạo (browser gửi bất kỳ thứ gì OS nghĩ file type là)
  • Extension có thể đổi tên (một .exe đổi thành .mp4)
  • Kiểm tra cả hai bắt được nhiều edge case hơn

Cho production system, bạn cũng nên inspect magic byte của file (file signature), nhưng content type + extension là đủ cho use case của chúng ta.


Storage Service

Storage service xử lý ghi file lên disk và dọn dẹp file cũ:

// src/main/java/com/videoplatform/api/service/VideoStorageService.java
package com.videoplatform.api.service;
 
import com.videoplatform.api.config.StorageConfig;
import com.videoplatform.api.exception.StorageException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.*;
import java.nio.file.attribute.BasicFileAttributes;
 
@Service
public class VideoStorageService {
 
    private static final Logger log = LoggerFactory.getLogger(VideoStorageService.class);
    private final StorageConfig storageConfig;
 
    public VideoStorageService(StorageConfig storageConfig) {
        this.storageConfig = storageConfig;
    }
 
    /**
     * Lưu video file lên disk.
     * Trả về relative path từ storage root.
     */
    public String storeRawVideo(MultipartFile file, Long courseId, Long lessonId) {
        try {
            // Build target directory: raw/{courseId}/{lessonId}/
            Path targetDir = Path.of(
                storageConfig.getRawPath(),
                String.valueOf(courseId),
                String.valueOf(lessonId)
            );
            Files.createDirectories(targetDir);
 
            // Xoá file cũ trong lesson directory
            cleanDirectory(targetDir);
 
            // Sanitize filename
            String originalFilename = sanitizeFilename(file.getOriginalFilename());
            Path targetPath = targetDir.resolve(originalFilename);
 
            // Ghi file lên disk
            try (InputStream inputStream = file.getInputStream()) {
                Files.copy(inputStream, targetPath, StandardCopyOption.REPLACE_EXISTING);
            }
 
            // Trả về relative path để lưu database
            String relativePath = String.format("raw/%d/%d/%s", courseId, lessonId, originalFilename);
            log.info("Video stored: {} ({} bytes)", relativePath, file.getSize());
 
            return relativePath;
 
        } catch (IOException e) {
            throw new StorageException("Failed to store video file", e);
        }
    }
 
    /**
     * Xoá tất cả file của một lesson cụ thể.
     */
    public void deleteVideoFiles(Long courseId, Long lessonId) {
        try {
            // Xoá raw file
            Path rawDir = Path.of(
                storageConfig.getRawPath(),
                String.valueOf(courseId),
                String.valueOf(lessonId)
            );
            deleteDirectoryRecursively(rawDir);
 
            // Xoá HLS file (nếu có)
            Path hlsDir = Path.of(
                storageConfig.getHlsPath(),
                String.valueOf(courseId),
                String.valueOf(lessonId)
            );
            deleteDirectoryRecursively(hlsDir);
 
            log.info("Deleted video files for course {} lesson {}", courseId, lessonId);
 
        } catch (IOException e) {
            log.error("Failed to delete video files for course {} lesson {}",
                      courseId, lessonId, e);
        }
    }
 
    private String sanitizeFilename(String filename) {
        if (filename == null) return "video.mp4";
 
        // Xoá path separator và ký tự đặc biệt
        return filename
            .replaceAll("[/\\\\]", "")
            .replaceAll("[^a-zA-Z0-9._-]", "_");
    }
 
    private void cleanDirectory(Path dir) throws IOException {
        if (!Files.exists(dir)) return;
 
        try (var stream = Files.list(dir)) {
            stream.forEach(path -> {
                try {
                    Files.deleteIfExists(path);
                } catch (IOException e) {
                    log.warn("Failed to delete {}", path, e);
                }
            });
        }
    }
 
    private void deleteDirectoryRecursively(Path dir) throws IOException {
        if (!Files.exists(dir)) return;
 
        Files.walkFileTree(dir, new SimpleFileVisitor<>() {
            @Override
            public FileVisitResult visitFile(Path file, BasicFileAttributes attrs)
                    throws IOException {
                Files.delete(file);
                return FileVisitResult.CONTINUE;
            }
 
            @Override
            public FileVisitResult postVisitDirectory(Path dir, IOException exc)
                    throws IOException {
                Files.delete(dir);
                return FileVisitResult.CONTINUE;
            }
        });
    }
}

Lưu Ý Bảo Mật

Method sanitizeFilename cực kỳ quan trọng. Không có nó, attacker có thể upload file tên ../../../etc/passwd và ghi đè file hệ thống (path traversal attack). Chúng ta strip tất cả path separator và ký tự đặc biệt.

Cũng lưu ý cleanDirectory xoá mọi file cũ trong lesson directory trước khi ghi file mới. Nghĩa là re-upload video cho lesson sẽ thay thế file cũ — không có stale file tích tụ trên disk.


Upload Controller

// src/main/java/com/videoplatform/api/controller/AdminUploadController.java
package com.videoplatform.api.controller;
 
import com.videoplatform.api.dto.ApiResponse;
import com.videoplatform.api.dto.response.UploadResponse;
import com.videoplatform.api.service.FileValidationService;
import com.videoplatform.api.service.VideoStorageService;
import com.videoplatform.api.service.LessonService;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
 
@RestController
@RequestMapping("/api/admin/upload")
@PreAuthorize("hasRole('ADMIN')")
public class AdminUploadController {
 
    private final FileValidationService validationService;
    private final VideoStorageService storageService;
    private final LessonService lessonService;
 
    public AdminUploadController(FileValidationService validationService,
                                  VideoStorageService storageService,
                                  LessonService lessonService) {
        this.validationService = validationService;
        this.storageService = storageService;
        this.lessonService = lessonService;
    }
 
    @PostMapping("/video")
    public ResponseEntity<ApiResponse<UploadResponse>> uploadVideo(
            @RequestParam("file") MultipartFile file,
            @RequestParam("courseId") Long courseId,
            @RequestParam("lessonId") Long lessonId) {
 
        // 1. Validate file
        validationService.validateVideoFile(file);
 
        // 2. Lưu lên disk
        String videoPath = storageService.storeRawVideo(file, courseId, lessonId);
 
        // 3. Update lesson record
        lessonService.updateVideoPath(lessonId, videoPath);
 
        // 4. Trả response
        UploadResponse response = new UploadResponse(
            videoPath,
            file.getSize(),
            file.getOriginalFilename(),
            file.getContentType()
        );
 
        return ResponseEntity.ok(
            ApiResponse.success("Video uploaded successfully", response)
        );
    }
 
    @DeleteMapping("/video")
    public ResponseEntity<ApiResponse<Void>> deleteVideo(
            @RequestParam("courseId") Long courseId,
            @RequestParam("lessonId") Long lessonId) {
 
        storageService.deleteVideoFiles(courseId, lessonId);
        lessonService.updateVideoPath(lessonId, null);
 
        return ResponseEntity.ok(ApiResponse.success("Video deleted", null));
    }
}
// src/main/java/com/videoplatform/api/dto/response/UploadResponse.java
package com.videoplatform.api.dto.response;
 
public record UploadResponse(
    String videoPath,
    long fileSize,
    String originalFilename,
    String contentType
) {}

Thêm vào LessonService

// Thêm vào LessonService.java
@Transactional
public void updateVideoPath(Long lessonId, String videoPath) {
    Lesson lesson = lessonRepository.findById(lessonId)
            .orElseThrow(() -> new ResourceNotFoundException("Lesson not found: " + lessonId));
 
    lesson.setVideoPath(videoPath);
 
    // Đánh dấu PROCESSING khi video mới được upload (sẵn sàng transcode)
    if (videoPath != null) {
        lesson.setStatus(LessonStatus.PROCESSING);
    }
 
    lessonRepository.save(lesson);
}

Custom Exception

// src/main/java/com/videoplatform/api/exception/StorageException.java
package com.videoplatform.api.exception;
 
public class StorageException extends RuntimeException {
    public StorageException(String message, Throwable cause) {
        super(message, cause);
    }
}

Thêm StorageException vào GlobalExceptionHandler từ Post #2:

// Thêm vào GlobalExceptionHandler.java
@ExceptionHandler(StorageException.class)
public ResponseEntity<ApiResponse<Void>> handleStorageException(StorageException ex) {
    return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
            .body(ApiResponse.error("Storage error: " + ex.getMessage()));
}
 
@ExceptionHandler(MaxUploadSizeExceededException.class)
public ResponseEntity<ApiResponse<Void>> handleMaxUploadSize(MaxUploadSizeExceededException ex) {
    return ResponseEntity.status(HttpStatus.PAYLOAD_TOO_LARGE)
            .body(ApiResponse.error("File too large. Maximum size is 2GB."));
}

Upload Progress với SSE

Với video file lớn (hàng trăm MB đến 2GB), user cần thấy upload progress. Chúng ta dùng hai cơ chế:

  1. Axios onUploadProgress — track byte đã gửi từ browser đến server
  2. SSE (Server-Sent Events) — track trạng thái xử lý phía server

Với hầu hết upload, Axios progress là đủ. SSE hữu ích khi server cần xử lý thêm (như tính checksum hoặc bắt đầu transcode).

Progress Tracking Endpoint

// src/main/java/com/videoplatform/api/controller/AdminUploadController.java
// Thêm method này vào controller hiện có
 
@GetMapping(value = "/progress/{lessonId}", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter uploadProgress(@PathVariable Long lessonId) {
    SseEmitter emitter = new SseEmitter(300_000L); // timeout 5 phút
 
    // Đăng ký emitter cho lesson này
    uploadProgressService.registerEmitter(lessonId, emitter);
 
    emitter.onCompletion(() -> uploadProgressService.removeEmitter(lessonId));
    emitter.onTimeout(() -> uploadProgressService.removeEmitter(lessonId));
 
    return emitter;
}
// src/main/java/com/videoplatform/api/service/UploadProgressService.java
package com.videoplatform.api.service;
 
import org.springframework.stereotype.Service;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.io.IOException;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
 
@Service
public class UploadProgressService {
 
    private final Map<Long, SseEmitter> emitters = new ConcurrentHashMap<>();
 
    public void registerEmitter(Long lessonId, SseEmitter emitter) {
        emitters.put(lessonId, emitter);
    }
 
    public void removeEmitter(Long lessonId) {
        emitters.remove(lessonId);
    }
 
    public void sendProgress(Long lessonId, String status, int percentage) {
        SseEmitter emitter = emitters.get(lessonId);
        if (emitter == null) return;
 
        try {
            emitter.send(SseEmitter.event()
                .name("progress")
                .data(Map.of(
                    "status", status,
                    "percentage", percentage
                )));
 
            if (percentage >= 100) {
                emitter.complete();
                emitters.remove(lessonId);
            }
        } catch (IOException e) {
            emitters.remove(lessonId);
        }
    }
}

Cấu Hình Docker Volume

Update docker-compose.yml để mount storage directory:

# docker-compose.yml
services:
  api:
    build: ./api
    ports:
      - "8080:8080"
    volumes:
      - video-storage:/data/videos    # Persist video file
    environment:
      - APP_STORAGE_BASE_PATH=/data/videos
    depends_on:
      - postgres
      - redis
 
  # ... service khác
 
volumes:
  video-storage:                       # Named volume cho video file
    driver: local

Cho local development không dùng Docker, override path trong application-dev.yaml:

# src/main/resources/application-dev.yaml
app:
  storage:
    base-path: ./storage/videos        # Relative với project root

Admin Upload UI

Upload API Client

// web/src/lib/admin-api.ts — thêm method này vào adminApi object
 
export interface UploadResponse {
  videoPath: string;
  fileSize: number;
  originalFilename: string;
  contentType: string;
}
 
// Thêm vào adminApi object:
uploadVideo: (
  courseId: number,
  lessonId: number,
  file: File,
  onProgress?: (percentage: number) => void
) => {
  const formData = new FormData();
  formData.append("file", file);
  formData.append("courseId", String(courseId));
  formData.append("lessonId", String(lessonId));
 
  return api
    .post<{ data: UploadResponse }>("/api/admin/upload/video", formData, {
      headers: { "Content-Type": "multipart/form-data" },
      onUploadProgress: (event) => {
        if (event.total && onProgress) {
          const percentage = Math.round((event.loaded / event.total) * 100);
          onProgress(percentage);
        }
      },
    })
    .then((r) => r.data.data);
},
 
deleteVideo: (courseId: number, lessonId: number) =>
  api.delete("/api/admin/upload/video", {
    params: { courseId, lessonId },
  }),

Upload Component

// web/src/components/admin/VideoUpload.tsx
"use client";
 
import { useState, useCallback } from "react";
import { adminApi } from "@/lib/admin-api";
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import { useToast } from "@/hooks/use-toast";
import { Upload, X, Film, Trash2, CheckCircle2 } from "lucide-react";
 
interface VideoUploadProps {
  courseId: number;
  lessonId: number;
  currentVideoPath: string | null;
  onUploadComplete: () => void;
}
 
const MAX_FILE_SIZE = 2 * 1024 * 1024 * 1024; // 2GB
const ALLOWED_TYPES = ["video/mp4", "video/quicktime", "video/webm", "video/x-matroska"];
 
export function VideoUpload({
  courseId,
  lessonId,
  currentVideoPath,
  onUploadComplete,
}: VideoUploadProps) {
  const [uploading, setUploading] = useState(false);
  const [progress, setProgress] = useState(0);
  const [dragActive, setDragActive] = useState(false);
  const { toast } = useToast();
 
  const validateFile = (file: File): string | null => {
    if (file.size > MAX_FILE_SIZE) {
      return "File quá lớn. Tối đa 2GB.";
    }
    if (!ALLOWED_TYPES.includes(file.type)) {
      return "File type không hợp lệ. Cho phép: MP4, MOV, WebM, MKV.";
    }
    return null;
  };
 
  const handleUpload = async (file: File) => {
    const error = validateFile(file);
    if (error) {
      toast({ title: "Validation Error", description: error, variant: "destructive" });
      return;
    }
 
    setUploading(true);
    setProgress(0);
 
    try {
      await adminApi.uploadVideo(courseId, lessonId, file, (pct) => {
        setProgress(pct);
      });
 
      toast({ title: "Video uploaded successfully" });
      onUploadComplete();
    } catch (err) {
      toast({
        title: "Upload failed",
        description: "Please try again",
        variant: "destructive",
      });
    } finally {
      setUploading(false);
      setProgress(0);
    }
  };
 
  const handleDelete = async () => {
    if (!confirm("Xoá video này? Không thể hoàn tác.")) return;
 
    try {
      await adminApi.deleteVideo(courseId, lessonId);
      toast({ title: "Video deleted" });
      onUploadComplete();
    } catch (err) {
      toast({
        title: "Delete failed",
        description: "Please try again",
        variant: "destructive",
      });
    }
  };
 
  const handleDrop = useCallback(
    (e: React.DragEvent) => {
      e.preventDefault();
      setDragActive(false);
 
      const file = e.dataTransfer.files[0];
      if (file) handleUpload(file);
    },
    [courseId, lessonId]
  );
 
  const handleDragOver = useCallback((e: React.DragEvent) => {
    e.preventDefault();
    setDragActive(true);
  }, []);
 
  const handleDragLeave = useCallback(() => {
    setDragActive(false);
  }, []);
 
  const handleFileInput = (e: React.ChangeEvent<HTMLInputElement>) => {
    const file = e.target.files?.[0];
    if (file) handleUpload(file);
    e.target.value = ""; // Reset input
  };
 
  const formatFileSize = (bytes: number) => {
    if (bytes >= 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024 / 1024).toFixed(1)} GB`;
    if (bytes >= 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
    return `${(bytes / 1024).toFixed(1)} KB`;
  };
 
  // Đang upload
  if (uploading) {
    return (
      <div className="border rounded-lg p-6 space-y-4">
        <div className="flex items-center gap-3">
          <Upload className="h-5 w-5 text-primary animate-pulse" />
          <span className="text-sm font-medium">Uploading video...</span>
          <span className="text-sm text-muted-foreground ml-auto">{progress}%</span>
        </div>
        <Progress value={progress} className="h-2" />
        <p className="text-xs text-muted-foreground">
          Đừng đóng trang này khi đang upload.
        </p>
      </div>
    );
  }
 
  // Video đã upload
  if (currentVideoPath) {
    return (
      <div className="border rounded-lg p-4 flex items-center gap-3">
        <CheckCircle2 className="h-5 w-5 text-green-500 flex-shrink-0" />
        <div className="flex-1 min-w-0">
          <p className="text-sm font-medium truncate">Video uploaded</p>
          <p className="text-xs text-muted-foreground truncate">{currentVideoPath}</p>
        </div>
        <Button
          variant="ghost"
          size="icon"
          className="text-destructive flex-shrink-0"
          onClick={handleDelete}
        >
          <Trash2 className="h-4 w-4" />
        </Button>
      </div>
    );
  }
 
  // Drop zone
  return (
    <div
      onDrop={handleDrop}
      onDragOver={handleDragOver}
      onDragLeave={handleDragLeave}
      className={`border-2 border-dashed rounded-lg p-8 text-center transition-colors
        ${dragActive
          ? "border-primary bg-primary/5"
          : "border-muted-foreground/25 hover:border-muted-foreground/50"
        }`}
    >
      <Film className="h-10 w-10 mx-auto text-muted-foreground mb-3" />
      <p className="text-sm font-medium mb-1">
        {dragActive ? "Thả video ở đây" : "Kéo thả video file vào đây"}
      </p>
      <p className="text-xs text-muted-foreground mb-4">
        MP4, MOV, WebM, hoặc MKV tối đa 2GB
      </p>
      <label>
        <input
          type="file"
          accept="video/*"
          onChange={handleFileInput}
          className="hidden"
        />
        <Button variant="outline" size="sm" asChild>
          <span>Chọn file</span>
        </Button>
      </label>
    </div>
  );
}

Tích Hợp với Lesson Manager

Thêm upload component vào mỗi lesson trong LessonManager từ Post #5:

// Update lesson row trong LessonManager.tsx
// Thêm VideoUpload bên dưới mỗi lesson khi expand
 
import { VideoUpload } from "./VideoUpload";
 
// Trong lesson map, thêm sau lesson row:
{expandedLesson === lesson.id && (
  <div className="ml-10 mt-2 mb-3">
    <VideoUpload
      courseId={courseId}
      lessonId={lesson.id}
      currentVideoPath={lesson.videoPath}
      onUploadComplete={onUpdate}
    />
  </div>
)}

Thêm expand button vào mỗi lesson row:

<Button
  variant="ghost"
  size="icon"
  className="h-7 w-7"
  onClick={() =>
    setExpandedLesson(expandedLesson === lesson.id ? null : lesson.id)
  }
>
  <Upload className="h-3 w-3" />
</Button>

Upload Flow Diagram

Đây là flow hoàn chỉnh từ admin click upload đến video được lưu:


Test Upload

1. Test bằng curl

# Upload video file
curl -X POST http://localhost:8080/api/admin/upload/video \
  -H "Authorization: Bearer ${TOKEN}" \
  -F "file=@/path/to/sample-video.mp4" \
  -F "courseId=1" \
  -F "lessonId=1"

Response:

{
  "success": true,
  "message": "Video uploaded successfully",
  "data": {
    "videoPath": "raw/1/1/sample-video.mp4",
    "fileSize": 15728640,
    "originalFilename": "sample-video.mp4",
    "contentType": "video/mp4"
  }
}

2. Kiểm Tra File Trên Disk

ls -la /data/videos/raw/1/1/
# Hoặc cho local dev:
ls -la ./storage/videos/raw/1/1/

3. Test Validation

# Sai file type (sẽ fail)
curl -X POST http://localhost:8080/api/admin/upload/video \
  -H "Authorization: Bearer ${TOKEN}" \
  -F "file=@/path/to/document.pdf" \
  -F "courseId=1" \
  -F "lessonId=1"
# Trả về: 400 Bad Request - Invalid file type

4. Test Qua Admin Dashboard

  1. Truy cập /admin/courses/1 và chuyển sang Content tab
  2. Click icon upload trên một lesson
  3. Kéo video file vào drop zone (hoặc click "Chọn file")
  4. Xem progress bar chạy
  5. Sau khi xong, lesson hiển thị "Video uploaded" với path

5. Test Re-upload

Upload video khác cho cùng lesson. File cũ sẽ bị xoá và thay thế.


Lỗi Thường Gặp

1. Không Set Multipart Size Limit

Không cấu hình rõ ràng thì Spring Boot mặc định max file size 1MB:

# Sai — mặc định 1MB
spring:
  servlet:
    multipart:
      enabled: true
 
# Đúng — rõ ràng 2GB
spring:
  servlet:
    multipart:
      enabled: true
      max-file-size: 2GB
      max-request-size: 2GB

2. Path Traversal Vulnerability

Không bao giờ dùng filename từ user trực tiếp:

// NGUY HIỂM — path traversal attack
Path targetPath = Path.of(rawPath, file.getOriginalFilename());
// Attacker upload file tên "../../../etc/passwd"
 
// AN TOÀN — sanitize trước
String safe = sanitizeFilename(file.getOriginalFilename());
Path targetPath = targetDir.resolve(safe);

3. Không Dọn File Cũ

Nếu không xoá file cũ khi re-upload, disk space sẽ tăng không giới hạn:

// Luôn dọn trước khi ghi
cleanDirectory(targetDir);
Files.copy(inputStream, targetPath, StandardCopyOption.REPLACE_EXISTING);

4. Block UI Khi Upload

Upload lớn có thể mất vài phút. Đừng bao giờ block UI thread:

// Sai — không có progress feedback
await adminApi.uploadVideo(courseId, lessonId, file);
 
// Đúng — hiện progress
await adminApi.uploadVideo(courseId, lessonId, file, (pct) => {
  setProgress(pct);
});

Bước Tiếp Theo

Video đã lên disk, nhưng chúng vẫn là raw file — MP4 lớn, đơn resolution, không thể stream adaptive. Trong Post #7, chúng ta sẽ build transcoding pipeline:

  • FFmpeg HLS transcode sang nhiều resolution (360p + 720p)
  • Async processing với Spring @Async
  • Progress tracking trong Redis
  • ffprobe metadata extraction (duration, resolution, codec)
  • Tự động trigger transcode sau upload

Đã đến lúc biến raw upload thành streamable content.

Series: Xây Dựng Video Streaming Platform
Trước đó: Phase 4: Admin Dashboard
Tiếp theo: Phase 6: Video Transcoding Pipeline

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