Backend

99 Ngày Spring — Ngày 19: Upload & download file

SSite Admin
15 tháng 08, 2026 5 phút đọc 0 lượt xem
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.

Sketchnote Ngày 19: upload & download file — MultipartFile, giới hạn kích thước, stream download, chặn path traversal và checklist an toàn

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()));
}
  • MultipartFile gói mọi thứ về phần file của request: getOriginalFilename(), getContentType(), getSize()transferTo(...) để ghi ra đích — các field text đi kèm bind qua @RequestParam như 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 Location trỏ 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-size cho từng file, max-request-size cho 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 @ExceptionHandler cho MaxUploadSizeExceededException trả 413 với ProblemDetail — 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 heap
  • Trả 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ây OutOfMemoryError.

  • Content-Disposition: attachment chỉ 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ó, request GET /api/files/..%2F..%2Fapplication.yml có thể đọc file cấu hình của chính ứng dụng.

Bút máy đặt trên trang tài liệu — hợp đồng nhận file: mọi điều khoản an toàn phải được rà soát trước khi ký nhận

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_size củ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/files nhận MultipartFile — upload bằng curl -F "file=@anh.jpg" và kiểm tra response 201 + Location.

  • Đặt max-file-size: 1MB rồ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.jpg và so sánh checksum với file gốc.

  • Kiểm thử path traversal: gọi GET /api/files/..%2F..%2Fpom.xml trước và sau khi thêm normalize() + 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!

S

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.

Bài viết liên quan

99 Ngày Java — Ngày 19: Inner class & anonymous class

Ngày 19 của 99 Ngày Java: bốn dạng nested class — member inner (tham chiếu ngầm tới đối tượng ngoài), static nested (lựa chọn mặc định), local class trong method, anonymous class dùng một lần — cùng quy tắc capture effectively final và bảng quyết định chọn dạng phù hợp.

15 thg 8, 20266 phút0
99 Ngày Java — Ngày 18: static & final

Ngày 18 của 99 Ngày Java: static — thành viên thuộc về lớp thay vì đối tượng, static block và static import, ba cấp độ của final (biến, method, lớp), mẫu hằng số public static final, và ranh giới quan trọng: static bất biến thì an toàn, static có thể ghi là trạng thái toàn cục nguy hiểm.

14 thg 8, 20266 phút46
99 Ngày Spring — Ngày 18: Phân trang & sắp xếp API

Ngày 18 của 99 Ngày Spring: vì sao không trả cả bảng trong một response, Pageable tự bind page/size/sort từ query param, bọc Page thành PageResponse DTO để giữ hợp đồng ổn định, convention sắp xếp nhiều tiêu chí, và hai chốt an toàn — giới hạn max-page-size, whitelist field được sort.

14 thg 8, 20265 phút54