Build a Video Platform: Video Upload & Storage

Courses have structure. Lessons have titles. But there's no video yet. In this post, we'll build the upload pipeline — accepting video files from the admin dashboard, validating them, storing them on disk in an organized layout, and tracking upload progress in real time.
This is the first half of the video pipeline. Upload gets the raw file onto disk. In Post #7, FFmpeg will transcode it into HLS streams.
Time commitment: 2–3 hours
Prerequisites: Phase 4: Admin Dashboard
What we'll build in this post:
✅ Multipart file upload endpoint with Spring Boot
✅ File validation (size, format, content type)
✅ Organized storage layout on disk
✅ Upload progress tracking via SSE (Server-Sent Events)
✅ Admin upload UI with drag-and-drop and progress bar
✅ Lesson video_path update after successful upload
Upload Architecture
Storage Layout
Before writing any upload code, let's design the disk layout. An organized storage structure makes it easy to find files, clean up orphans, and serve content:
/data/videos/
├── raw/ # Original uploaded files
│ ├── course-1/
│ │ ├── lesson-1/
│ │ │ └── intro-to-spring.mp4
│ │ └── lesson-2/
│ │ └── setting-up-ide.mp4
│ └── course-2/
│ └── lesson-1/
│ └── react-basics.mp4
├── hls/ # Transcoded HLS output (Post #7)
│ ├── course-1/
│ │ ├── lesson-1/
│ │ │ ├── 360p/
│ │ │ │ ├── segment-0.ts
│ │ │ │ └── playlist.m3u8
│ │ │ ├── 720p/
│ │ │ │ ├── segment-0.ts
│ │ │ │ └── playlist.m3u8
│ │ │ └── master.m3u8
│ │ └── ...
│ └── ...
└── thumbnails/ # Auto-generated thumbnails (future)Key decisions:
| Decision | Why |
|---|---|
Separate raw/ and hls/ directories | Raw files are large and temporary; HLS files are what gets served |
Organized by courseId/lessonId/ | Easy to find, easy to delete when a lesson is removed |
| Single file per lesson | One video per lesson keeps things simple |
| Mount as Docker volume | Data persists across container restarts |
Configuration
# 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 {
// Create directories on 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);
}
// Getters and setters
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; }
}The @PostConstruct method creates the storage directories when the app starts. No more "directory not found" errors on first upload.
File Validation
Never trust user input — especially file uploads. We validate three things: content type, file extension, and 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");
}
// Check file size
if (file.getSize() > storageConfig.getMaxFileSizeBytes()) {
throw new BadRequestException(
"File size exceeds maximum allowed: " + storageConfig.getMaxFileSize()
);
}
// Check 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())
);
}
// Check 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);
}
}Why validate both content type AND extension? Because:
- Content type can be spoofed (the browser sends whatever the OS thinks the file type is)
- Extension can be renamed (a
.exerenamed to.mp4) - Checking both catches more edge cases
For a production system, you'd also want to inspect the file's magic bytes (file signature), but content type + extension is sufficient for our use case.
Storage Service
The storage service handles writing files to disk and cleaning up old files:
// 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;
}
/**
* Store a video file on disk.
* Returns the relative path from the 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);
// Clean existing files in the lesson directory
cleanDirectory(targetDir);
// Sanitize filename
String originalFilename = sanitizeFilename(file.getOriginalFilename());
Path targetPath = targetDir.resolve(originalFilename);
// Write file to disk
try (InputStream inputStream = file.getInputStream()) {
Files.copy(inputStream, targetPath, StandardCopyOption.REPLACE_EXISTING);
}
// Return relative path for database storage
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);
}
}
/**
* Delete all files for a specific lesson.
*/
public void deleteVideoFiles(Long courseId, Long lessonId) {
try {
// Delete raw files
Path rawDir = Path.of(
storageConfig.getRawPath(),
String.valueOf(courseId),
String.valueOf(lessonId)
);
deleteDirectoryRecursively(rawDir);
// Delete HLS files (if they exist)
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";
// Remove path separators and special characters
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;
}
});
}
}Security Notes
The sanitizeFilename method is critical. Without it, an attacker could upload a file named ../../../etc/passwd and overwrite system files (path traversal attack). We strip all path separators and special characters.
Also note that cleanDirectory removes any existing file in the lesson directory before writing the new one. This means re-uploading a video for a lesson replaces the old file — no stale files accumulating on 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. Store on disk
String videoPath = storageService.storeRawVideo(file, courseId, lessonId);
// 3. Update lesson record
lessonService.updateVideoPath(lessonId, videoPath);
// 4. Return 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
) {}Add to LessonService
// Add to 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);
// Mark as PROCESSING when a new video is uploaded (ready for transcoding)
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);
}
}Add StorageException to the GlobalExceptionHandler from Post #2:
// Add to 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 with SSE
For large video files (hundreds of MB to 2GB), users need to see upload progress. We'll use two mechanisms:
- Axios
onUploadProgress— tracks bytes sent from browser to server - SSE (Server-Sent Events) — tracks server-side processing status
For most uploads, the Axios progress is sufficient. SSE becomes useful when the server does additional processing (like computing checksums or starting transcoding).
Progress Tracking Endpoint
// src/main/java/com/videoplatform/api/controller/AdminUploadController.java
// Add this method to the existing controller
@GetMapping(value = "/progress/{lessonId}", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter uploadProgress(@PathVariable Long lessonId) {
SseEmitter emitter = new SseEmitter(300_000L); // 5 minute timeout
// Register the emitter for this lesson
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);
}
}
}Docker Volume Configuration
Update docker-compose.yml to mount the storage directory:
# docker-compose.yml
services:
api:
build: ./api
ports:
- "8080:8080"
volumes:
- video-storage:/data/videos # Persist video files
environment:
- APP_STORAGE_BASE_PATH=/data/videos
depends_on:
- postgres
- redis
# ... other services
volumes:
video-storage: # Named volume for video files
driver: localFor local development without Docker, override the path in application-dev.yaml:
# src/main/resources/application-dev.yaml
app:
storage:
base-path: ./storage/videos # Relative to project rootAdmin Upload UI
Upload API Client
// web/src/lib/admin-api.ts — add these methods to the existing adminApi object
export interface UploadResponse {
videoPath: string;
fileSize: number;
originalFilename: string;
contentType: string;
}
// Add to 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 too large. Maximum size is 2GB.";
}
if (!ALLOWED_TYPES.includes(file.type)) {
return "Invalid file type. Allowed: 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("Delete this video? This cannot be undone.")) 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`;
};
// Upload in progress
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">
Do not close this page while uploading.
</p>
</div>
);
}
// Video already uploaded
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 ? "Drop video here" : "Drag and drop a video file"}
</p>
<p className="text-xs text-muted-foreground mb-4">
MP4, MOV, WebM, or MKV up to 2GB
</p>
<label>
<input
type="file"
accept="video/*"
onChange={handleFileInput}
className="hidden"
/>
<Button variant="outline" size="sm" asChild>
<span>Browse files</span>
</Button>
</label>
</div>
);
}Integrate with Lesson Manager
Add the upload component to each lesson in the LessonManager from Post #5:
// Update the lesson row in LessonManager.tsx
// Add VideoUpload below each lesson when expanded
import { VideoUpload } from "./VideoUpload";
// Inside the lesson map, add after the 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>
)}Add an expand button to each 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
Here's the complete flow from the admin clicking upload to the video being stored:
Testing the Upload
1. Test with curl
# Upload a 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. Verify File on Disk
ls -la /data/videos/raw/1/1/
# Or for local dev:
ls -la ./storage/videos/raw/1/1/3. Test Validation
# File too large (should fail)
# Create a test file larger than 2GB or lower the limit for testing
# Wrong file type (should 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"
# Returns: 400 Bad Request - Invalid file type4. Test via Admin Dashboard
- Navigate to
/admin/courses/1and go to the Content tab - Click the upload icon on a lesson
- Drag a video file onto the drop zone (or click "Browse files")
- Watch the progress bar fill up
- After completion, the lesson shows "Video uploaded" with the path
5. Test Re-upload
Upload another video to the same lesson. The old file should be deleted and replaced.
Common Mistakes
1. Not Setting Multipart Size Limits
Without explicit configuration, Spring Boot defaults to 1MB max file size:
# Wrong — default 1MB limit
spring:
servlet:
multipart:
enabled: true
# Right — explicit 2GB limit
spring:
servlet:
multipart:
enabled: true
max-file-size: 2GB
max-request-size: 2GB2. Path Traversal Vulnerability
Never use user-provided filenames directly:
// DANGEROUS — path traversal attack
Path targetPath = Path.of(rawPath, file.getOriginalFilename());
// Attacker uploads "../../../etc/passwd"
// SAFE — sanitize first
String safe = sanitizeFilename(file.getOriginalFilename());
Path targetPath = targetDir.resolve(safe);3. Not Cleaning Up Old Files
If you don't delete old files when re-uploading, disk space grows unbounded:
// Always clean before writing
cleanDirectory(targetDir);
Files.copy(inputStream, targetPath, StandardCopyOption.REPLACE_EXISTING);4. Blocking the UI During Upload
Large uploads can take minutes. Never block the UI thread:
// Wrong — no progress feedback
await adminApi.uploadVideo(courseId, lessonId, file);
// Right — show progress
await adminApi.uploadVideo(courseId, lessonId, file, (pct) => {
setProgress(pct);
});What's Next?
Videos are on disk, but they're raw files — large, single-resolution MP4s that can't be streamed adaptively. In Post #7, we'll build the transcoding pipeline:
- FFmpeg HLS transcoding to multiple resolutions (360p + 720p)
- Async processing with Spring
@Async - Progress tracking in Redis
ffprobemetadata extraction (duration, resolution, codec)- Automatic transcoding trigger after upload
Time to turn raw uploads into streamable content.
Series: Build a Video Streaming Platform
Previous: Phase 4: Admin Dashboard
Next: Phase 6: Video Transcoding Pipeline
📬 Subscribe to Newsletter
Get the latest blog posts delivered to your inbox every week. No spam, unsubscribe anytime.
We respect your privacy. Unsubscribe at any time.
💬 Comments
Sign in to leave a comment
We'll never post without your permission.