99 Ngày Spring — Ngày 19: Upload & download file
Đến nay API của ta chỉ trao đổi JSON. Hôm nay nó nhận và trả file — ảnh đại diện, tài liệu đính kèm, báo cáo xuất ra. Về giao thức, upload dùng multipart/form-data và Spring trừu tượng hóa thành MultipartFile; download là một response có Content-Disposition và body được stream. Phần quan trọng hơn cú pháp là kỷ luật an toàn: file do người dùng gửi lên là dữ liệu không đáng tin ở mức cao nhất.

Upload với MultipartFile
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<UploadResponse> upload(
@RequestParam("file") MultipartFile file, // phần file
@RequestParam("description") String description) // field text kèm theo
throws IOException {
if (file.isEmpty()) {
throw new EmptyFileException(); // → 400 qua advice (Ngày 16)
}
// KHÔNG dùng tên file client gửi làm tên lưu trữ — tự sinh tên an toàn:
String ext = StringUtils.getFilenameExtension(file.getOriginalFilename());
String storedName = UUID.randomUUID() + "." + ext;
file.transferTo(uploadDir.resolve(storedName));
URI location = URI.create("/api/files/" + storedName);
return ResponseEntity.created(location) // 201 + Location (Ngày 15)
.body(new UploadResponse(storedName, file.getSize()));
}MultipartFilegói mọi thứ về phần file của request:getOriginalFilename(),getContentType(),getSize()vàtransferTo(...)để ghi ra đích — các field text đi kèm bind qua@RequestParamnhư thường lệ.Quy tắc an toàn số một: tên file client gửi là dữ liệu không đáng tin — nó có thể chứa
../, ký tự đặc biệt, hoặc trùng tên file khác; tên lưu trữ luôn do server sinh (UUID), tên gốc chỉ giữ làm metadata hiển thị.Response tuân thủ nghi thức Ngày 15: 201 Created kèm header
Locationtrỏ tới endpoint download của file vừa nhận.
Giới hạn kích thước — hai tầng phòng thủ
# application.yml — giới hạn kích thước (mặc định chỉ 1MB mỗi file):
spring:
servlet:
multipart:
max-file-size: 10MB # giới hạn từng file
max-request-size: 12MB # giới hạn tổng cả request (nhiều file + form field)
# Vượt giới hạn → Spring ném MaxUploadSizeExceededException
# → bổ sung một @ExceptionHandler trong GlobalExceptionHandler (Ngày 16),
# trả 413 Payload Too Large kèm body ProblemDetail:
#
# @ExceptionHandler(MaxUploadSizeExceededException.class)
# public ProblemDetail handleTooLarge(MaxUploadSizeExceededException e) {
# return ProblemDetail.forStatusAndDetail(
# HttpStatus.PAYLOAD_TOO_LARGE,
# "File vượt quá kích thước cho phép (tối đa 10MB)");
# }Mặc định của Spring Boot là 1MB mỗi file — hầu hết ứng dụng thật cần nâng lên có chủ đích, kèm cân nhắc: giới hạn càng cao, một request càng chiếm nhiều tài nguyên.
Phân biệt hai giới hạn:
max-file-sizecho từng file,max-request-sizecho tổng request — request nhiều file cần cả hai được tính toán cùng nhau.Xử lý lỗi vượt giới hạn hòa vào hệ thống Ngày 16: một
@ExceptionHandlerchoMaxUploadSizeExceededExceptiontrả 413 vớiProblemDetail— nhất quán với mọi lỗi khác của API.
Download — stream và chặn path traversal
@GetMapping("/{name}")
public ResponseEntity<Resource> download(@PathVariable String name)
throws IOException {
Path file = uploadDir.resolve(name).normalize();
if (!file.startsWith(uploadDir)) { // chặn path traversal:
throw new FileNotFoundException(name); // "..%2F..%2Fetc" không thoát được thư mục
}
Resource resource = new InputStreamResource(Files.newInputStream(file));
return ResponseEntity.ok()
.contentType(MediaType.APPLICATION_OCTET_STREAM)
.header(HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename=\"" + name + "\"") // tải về thay vì mở
.contentLength(Files.size(file))
.body(resource); // STREAM từng phần — không nạp cả file vào heapTrả
Resource(ở đây làInputStreamResource) để Spring stream body theo từng phần — file 500MB không đi qua heap như một mảng byte khổng lồ; đây là khác biệt giữa endpoint chạy được và endpoint gâyOutOfMemoryError.Content-Disposition: attachmentchỉ thị trình duyệt tải về thay vì mở trực tiếp — với file do người dùng upload, mở trực tiếp trong trình duyệt là một vector XSS (HTML/SVG chứa script).Cặp
normalize()+startsWith(uploadDir)là lá chắn path traversal: thiếu nó, requestGET /api/files/..%2F..%2Fapplication.ymlcó thể đọc file cấu hình của chính ứng dụng.
Checklist an toàn khi làm việc với file
// Danh sách kiểm tra khi nhận file từ người dùng:
// 1. WHITELIST phần mở rộng & Content-Type — không dùng blacklist:
private static final Set<String> ALLOWED = Set.of("jpg", "jpeg", "png", "pdf");
if (ext == null || !ALLOWED.contains(ext.toLowerCase())) {
throw new UnsupportedFileTypeException(ext); // → 400/415 qua advice
}
// 2. Tên lưu trữ do SERVER sinh (UUID) — tên gốc chỉ giữ làm metadata hiển thị
// 3. Thư mục lưu nằm NGOÀI webroot; production dùng object storage
// (S3, Cloudinary...) — đĩa cục bộ mất dữ liệu khi container được tạo lại
// 4. Giới hạn kích thước ở CẢ HAI tầng: Spring config + reverse proxy
// (nginx: client_max_body_size — chặn sớm trước khi request tới ứng dụng)
// 5. Endpoint download: luôn normalize() đường dẫn + kiểm tra startsWith
// trước khi đọc file (mục 3 của bài — path traversal)Nguyên tắc whitelist thay vì blacklist: liệt kê những gì được phép và từ chối phần còn lại — blacklist luôn bỏ sót một phần mở rộng nguy hiểm nào đó (
.jsp,.svg,.html...).Lưu đĩa cục bộ chỉ phù hợp cho môi trường dev — production dùng object storage (S3, Cloudinary...): container được tạo lại là đĩa cục bộ về trạng thái ban đầu; giữ nguyên hợp đồng
FormData → { url }thì việc chuyển backend lưu trữ không ảnh hưởng client.Giới hạn kích thước nên tồn tại ở cả reverse proxy (
client_max_body_sizecủa nginx): chặn request quá lớn từ sớm, trước khi nó tiêu tốn tài nguyên của ứng dụng.
Bài tập nhỏ
Xây
POST /api/filesnhậnMultipartFile— upload bằngcurl -F "file=@anh.jpg"và kiểm tra response 201 +Location.Đặt
max-file-size: 1MBrồi upload file 2MB — xác nhận handler 413 trảProblemDetailđúng định dạng của Ngày 16.Xây
GET /api/files/{name}trảResource— thửcurl -o out.jpgvà so sánh checksum với file gốc.Kiểm thử path traversal: gọi
GET /api/files/..%2F..%2Fpom.xmltrước và sau khi thêmnormalize()+startsWith— ghi lại khác biệt.
Kết luận
API giờ xử lý được payload ngoài JSON một cách có kỷ luật: MultipartFile cho upload với tên lưu trữ do server sinh, giới hạn kích thước hai tầng với lỗi 413 chuẩn ProblemDetail, download stream qua Resource kèm lá chắn path traversal, và checklist whitelist – object storage – reverse proxy cho production. Ngày 20 khép lại giai đoạn REST API với một chủ đề mọi frontend đều sẽ hỏi đến: CORS — same-origin policy là gì, vì sao trình duyệt chặn request, và cấu hình cho đúng thay vì mở toang. Hẹn gặp lại!
Site Admin
Engineer and writer. Building things with TypeScript and distributed systems.
Bình luận (0)
Bạn cần đăng nhập bằng Google để bình luận.
Hãy là người bình luận đầu tiên.


