Skip to content

บทที่ 4 — File Upload (Avatar, Document, Image)

← บทที่ 3 | สารบัญ | บทที่ 5 →

ทุก app จริงต้องมี file upload — avatar, document, รูป

บทนี้สอน:

  • Multipart/form-data — ทำงานยังไง
  • Spring Boot endpoint รับ file
  • React form upload + progress bar
  • Validation (size, type) ทั้ง 2 ฝั่ง
  • เก็บไฟล์: local disk vs S3
  • Serve back ให้ user เห็น

หลังจบบท คุณจะ:

  • ทำ avatar (รูปโปรไฟล์) upload สำหรับ user
  • ทำ document (เอกสาร) upload หลายไฟล์
  • เห็น progress bar (แถบแสดงความคืบหน้า) ตอน upload
  • เข้าใจ pattern presigned URL (พรี-ไซน์ ยู-อาร์-แอล = URL ที่เซิร์ฟเวอร์เซ็นรับรองให้ล่วงหน้า มีอายุสั้น อนุญาตให้ client อัปโหลดไฟล์ตรงไปที่ storage ได้โดยไม่ต้องมีรหัสลับ — สำหรับ S3 (เอส-ทรี) = Amazon Simple Storage Service บริการเก็บไฟล์บนคลาวด์ของ AWS = Amazon Web Services ผู้ให้บริการคลาวด์รายใหญ่ที่สุดในโลก)

1. Multipart (มัล-ติ-พาร์ต) form-data — ทำงานยังไง

multipart (มัล-ติ-พาร์ต) = หลายส่วน — HTTP request เดียวที่บรรจุหลายชิ้นข้อมูล (text + file) ในข้อความเดียวกัน โดยใช้ boundary (บาวด์-ดา-รี่ = ตัวคั่น) เป็นเส้นแบ่งระหว่างแต่ละชิ้น

[ Multipart concept diagram ]

HTTP Request (1 ก้อน)
┌─────────────────────────────────┐
│ Header: boundary=---xxx         │ ← บอกตัวคั่น
├─────────────────────────────────┤
│ ---xxx                          │ ← ตัวคั่นเริ่ม part 1
│ Part 1: file "photo.jpg"        │
│ <binary bytes>                  │
├─────────────────────────────────┤
│ ---xxx                          │ ← ตัวคั่นเริ่ม part 2
│ Part 2: text "description"      │
│ "My photo"                      │
├─────────────────────────────────┤
│ ---xxx--                        │ ← ตัวคั่นปิด
└─────────────────────────────────┘

ตัวอย่าง raw HTTP (สิ่งที่ browser ส่งไปจริง — ปกติเราไม่ต้องเขียนเอง):

HTTP Request:
POST /api/upload
Content-Type: multipart/form-data; boundary=---xxx

---xxx
Content-Disposition: form-data; name="file"; filename="photo.jpg"
Content-Type: image/jpeg

<binary bytes...>
---xxx
Content-Disposition: form-data; name="description"

My photo
---xxx--
  • มี boundary (บาวด์-ดา-รี่ = ตัวคั่น) แยกแต่ละ part — string สุ่มที่ browser ตั้งให้ ไม่ซ้ำกับเนื้อหาไฟล์
  • แต่ละ part มี name + filename + content-type + body
  • รองรับหลาย file + field text ในคำขอเดียว

Part 1: Backend — Spring Boot

2. Endpoint รับไฟล์เดียว

java
@RestController
@RequestMapping("/api/uploads")
@RequiredArgsConstructor
public class UploadController {
    
    private final UploadService uploadService;
    
    @PostMapping("/avatar")
    public AvatarResponse uploadAvatar(
        @RequestParam("file") MultipartFile file,
        @AuthenticationPrincipal CustomUserDetails user
    ) throws IOException {
        return uploadService.uploadAvatar(file, user.getId());
    }
}

MultipartFile มี:

  • getOriginalFilename() — ชื่อไฟล์เดิม
  • getContentType()MIME type (รหัสบอกชนิดไฟล์ เช่น image/jpeg, application/pdf)
  • getSize() — bytes
  • getBytes() — content
  • getInputStream() — stream (สำหรับไฟล์ใหญ่)
  • transferTo(Path) — save ตรงไป disk

3. UploadService — Validation + Save

หัวใจของ file upload ฝั่ง backend คือ "validation" — ไฟล์ที่ user ส่งมาเป็นช่องโหว่ความปลอดภัยถ้าไม่ตรวจ ต้องเช็คทั้ง type (ใช้ whitelist (วายต์-ลิสต์ = รายชื่อที่อนุญาต) — ระบุชนิดที่อนุญาตเท่านั้น ไม่ใช่ดูแค่นามสกุลไฟล์), size และ content (เนื้อหา) จริง แล้วตั้งชื่อไฟล์ใหม่ (ไม่ใช้ชื่อจาก user) ส่วนนี้แสดง service ที่ validate + save อย่างปลอดภัย

ก่อนเข้าโค้ด ทำความรู้จักศัพท์ security 2 คำที่จะเจอ:

  • content-type (คอน-เทนต์-ไทป์ = ชนิดเนื้อหา) = header ที่ browser บอกว่าไฟล์เป็นชนิดอะไร เช่น image/jpegแต่ปลอมได้ (attacker ตั้งค่าเอง)
  • magic bytes (แม-จิ๊ก ไบทส์ = ไบต์เวทมนตร์/ไบต์ระบุชนิดไฟล์) = ไม่กี่ไบต์แรก ในตัวไฟล์จริง ที่บอกชนิดไฟล์ — ปลอมยากกว่ามาก เพราะถ้าใส่ผิด ไฟล์จะอ่านไม่ออกเลย

ตัวอย่าง magic bytes ของแต่ละ format (ตัวเลขแสดงเป็น hex = เลขฐาน 16):

  • JPEG: FF D8 FF (3 ไบต์แรก)
  • PNG: 89 50 4E 47 (4 ไบต์แรก — 50 4E 47 = ASCII "PNG")
  • GIF: 47 49 46 38 = ASCII "GIF8"
  • WebP: 52 49 46 46 = ASCII "RIFF" (4 ไบต์แรก)

เพราะ content-type ปลอมได้ (เปลี่ยนนามสกุล .txt เป็น .jpg ก็หลอก content-type ได้) เราเลยต้องเปิดดูไบต์จริงในไฟล์เพื่อยืนยันอีกชั้น

java
@Service
@RequiredArgsConstructor
public class UploadService {
    
    // ⚠️ GIF ใน 2026 production หายาก — และ animated GIF เป็นช่องโจมตี
    // (ขนาด/จำนวน frame โต = abuse vector) ถ้าไม่จำเป็นควรตัด หรือ limit frame count
    private static final Set<String> ALLOWED_IMAGE_TYPES = Set.of(
        "image/jpeg", "image/png", "image/webp", "image/gif"
    );
    private static final long MAX_AVATAR_SIZE = 2 * 1024 * 1024;   // 2 MB
    
    @Value("${app.upload-dir}")
    private String uploadDir;
    
    private final UserRepository userRepo;
    
    public AvatarResponse uploadAvatar(MultipartFile file, Long userId) throws IOException {
        // ตรวจความถูกต้อง (validate)
        if (file.isEmpty()) {
            throw new ValidationException("File is empty");
        }
        if (file.getSize() > MAX_AVATAR_SIZE) {
            throw new ValidationException("File too large (max 2 MB)");
        }
        // ⚠️ getContentType() = ค่าที่ browser ส่งมา (header) — *ปลอมได้*
        // ใช้เป็นด่านแรกสำหรับ UX (reject เร็ว) — magic bytes ข้างล่างคือด่านจริง
        if (!ALLOWED_IMAGE_TYPES.contains(file.getContentType())) {
            throw new ValidationException("Only JPEG/PNG/WebP/GIF allowed");
        }
        
        // ⚠️ ตรวจ magic bytes ด้วย (เพราะ content-type ปลอมได้) — ด่านจริง
        validateImageMagicBytes(file);
        
        // สร้างชื่อไฟล์ใหม่ — UUID (รหัสสุ่มที่ไม่ซ้ำ) + นามสกุลจากไฟล์เดิม
        String ext = getExtension(file.getOriginalFilename());
        String filename = UUID.randomUUID() + "." + ext;
        
        // สร้างโฟลเดอร์ถ้ายังไม่มี
        Path uploadPath = Paths.get(uploadDir, "avatars");
        Files.createDirectories(uploadPath);
        
        // บันทึกไฟล์ลงดิสก์ (save ใหม่ก่อน)
        Path target = uploadPath.resolve(filename);
        file.transferTo(target);
        
        // อัปเดตข้อมูล user
        User user = userRepo.findById(userId).orElseThrow();
        String oldAvatarPath = user.getAvatarPath();
        
        String relativePath = "avatars/" + filename;
        user.setAvatarPath(relativePath);
        userRepo.save(user);
        
        // ⚠️ ลบ avatar เก่า *หลัง* save สำเร็จ
        // ถ้าลบก่อนแล้ว save fail → ผู้ใช้สูญรูปเก่าตลอดไป
        if (oldAvatarPath != null) {
            Files.deleteIfExists(Paths.get(uploadDir, oldAvatarPath));
        }
        
        return new AvatarResponse(
            user.getId(),
            "/api/files/" + relativePath        // URL ที่ frontend ใช้แสดง
        );
    }
    
    private void validateImageMagicBytes(MultipartFile file) throws IOException {
        try (InputStream is = file.getInputStream()) {
            byte[] header = new byte[12];
            int read = is.read(header);
            if (read < 4) throw new ValidationException("Invalid file");
            
            // 💡 ทำไมต้อง `& 0xFF`?
            // Java byte เป็น *signed* (-128..127) ถ้าค่าไบต์เกิน 0x7F (เช่น 0xFF, 0x89)
            // Java จะเก็บเป็นตัวเลขติดลบ (`(byte)0xFF == -1`)
            // `& 0xFF` แปลงเป็น int *unsigned* (0..255) เพื่อเทียบกับ hex literal ได้ตรง
            
            // JPEG: FF D8 FF (ทุก JPEG เริ่มด้วย 3 ไบต์นี้)
            if ((header[0] & 0xFF) == 0xFF && (header[1] & 0xFF) == 0xD8) return;
            // PNG: 89 50 4E 47 (50 4E 47 = "PNG" ใน ASCII)
            if ((header[0] & 0xFF) == 0x89 && header[1] == 0x50 && header[2] == 0x4E && header[3] == 0x47) return;
            // GIF: 47 49 46 = "GIF" ใน ASCII (เทียบ char ได้เลย เพราะอยู่ในช่วง 0x00–0x7F)
            if (header[0] == 'G' && header[1] == 'I' && header[2] == 'F') return;
            // WebP: 52 49 46 46 = "RIFF" (ตามด้วย size 4 ไบต์ แล้ว "WEBP" ที่ offset 8)
            if (header[0] == 'R' && header[1] == 'I' && header[2] == 'F' && header[3] == 'F') return;
            
            throw new ValidationException("File is not a valid image");
        }
    }
    
    private String getExtension(String filename) {
        if (filename == null) return "bin";
        int dot = filename.lastIndexOf('.');
        return dot > 0 ? filename.substring(dot + 1).toLowerCase() : "bin";
    }
}

public record AvatarResponse(Long userId, String url) {}

🛡️ ขั้น advanced — decode + re-encode: magic bytes ตรวจหัวไฟล์ แต่ "ตัวไฟล์" อาจมี payload แปลกซ่อนอยู่ (polyglot files — ไฟล์ที่เป็น JPEG + ZIP ในกายเดียว) วิธีแข็งสุดคือ decode ภาพด้วย javax.imageio.ImageIO.read(...) แล้ว re-encode กลับ — กระบวนการนี้ทิ้ง EXIF + metadata + payload ฝังทุกอย่าง เหลือแค่ pixel จริง

🛡️ ทางเลือก library: ในงานจริง production มักใช้ Apache Tika (อะ-แพช-ชี ทิ-กะ — library ตรวจชนิดไฟล์ที่ครอบคลุม 1000+ format) แทนเขียน magic bytes เองเพื่อรองรับชนิดไฟล์เยอะ ๆ


4. Config — Size Limit

Spring Boot จำกัดขนาด upload ที่ 1MB โดย default — ถ้าไม่ตั้งจะ reject ไฟล์ใหญ่กว่านั้น ต้องตั้ง max-file-size (ต่อไฟล์) และ max-request-size (ต่อ request รวมหลายไฟล์) ให้เหมาะกับงาน เป็นด่านป้องกันชั้นแรกก่อนถึง validation:

yaml
# application.yml
spring:
  servlet:
    multipart:
      max-file-size: 5MB        # ต่อ 1 ไฟล์
      max-request-size: 20MB    # ต่อ 1 request (หลายไฟล์รวม)

app:
  upload-dir: ./uploads

ถ้าไม่ตั้ง → default 1 MB


5. Endpoint รับหลายไฟล์ + Form Field

นอกจากไฟล์เดียว endpoint รับหลายไฟล์ (MultipartFile[]) พร้อม form field อื่น ๆ ในคำขอเดียวกันได้ — เหมาะกับฟอร์มที่ส่งทั้งไฟล์และข้อมูล (เช่น อัปโหลดเอกสารพร้อมคำอธิบาย) ใช้ @RequestParam แยกแต่ละส่วน:

java
@PostMapping("/documents")
public List<DocumentResponse> uploadDocuments(
    @RequestParam("files") MultipartFile[] files,
    @RequestParam("description") String description,
    @AuthenticationPrincipal CustomUserDetails user
) {
    return uploadService.uploadDocuments(files, description, user.getId());
}

6. Serve File กลับ

หลังเก็บไฟล์แล้วต้องมี endpoint ส่งกลับให้ดู — จุดที่ต้องระวังที่สุดคือ Path traversal (พาธ ทรา-เวอร์-ซัล = การเดินทะลุนอกโฟลเดอร์ — user ขอ ../../etc/passwd เพื่อหลุดออกไปอ่านไฟล์ระบบ) ต้อง normalize path แล้วเช็คว่าอยู่ใน upload dir จริง ⚠️ ใน production ควรให้ nginx serve static file ตรง (เร็วกว่าผ่าน Spring มาก):

java
@RestController
@RequestMapping("/api/files")
@RequiredArgsConstructor
public class FileController {
    
    @Value("${app.upload-dir}")
    private String uploadDir;
    
    private final UserRepository userRepo;
    
    // 🔴 ต้องมี auth! /api/files/** ใน SecurityConfig ต้อง .authenticated()
    // (ไม่งั้นใครรู้ URL ก็โหลดไฟล์คนอื่นได้)
    @GetMapping("/**")
    @PreAuthorize("isAuthenticated()")
    public ResponseEntity<Resource> serveFile(
        HttpServletRequest request,
        @AuthenticationPrincipal CustomUserDetails currentUser
    ) throws IOException {
        // ดึงส่วน path หลัง /api/files/ ออกมา
        // ⚠️ pattern @GetMapping("/**") + substring เป็น anti-pattern เพราะอ่านยาก
        // ทางที่ดีกว่า: ใช้ @GetMapping("/avatars/{filename}") แยกตามชนิด
        String path = request.getRequestURI().substring("/api/files/".length());
        
        // ⚠️ ป้องกัน path traversal — toRealPath() resolve symlink ด้วย
        // (normalize() อย่างเดียวไม่พอ — ถ้าเจอ symlink จาก uploads/evil → /etc
        //  startsWith จะ "ผ่าน" ทั้งที่เปิดไฟล์นอกโฟลเดอร์)
        Path baseDir = Paths.get(uploadDir).toRealPath();
        Path requested;
        try {
            requested = baseDir.resolve(path).normalize().toRealPath();
        } catch (NoSuchFileException e) {
            return ResponseEntity.notFound().build();
        }
        if (!requested.startsWith(baseDir)) {
            // ลองหลุดออกนอกโฟลเดอร์ (รวมถึง %2e%2e/ ที่ Spring decode แล้ว
            // และ symlink — toRealPath() จับได้ทั้งคู่)
            return ResponseEntity.badRequest().build();
        }
        
        // 🔴 ownership check — ดูว่าไฟล์นี้เป็นของ user นี้จริงหรือเปล่า
        // (เช่น avatars/<filename>.jpg ต้องตรง avatarPath ของ user ใน DB)
        if (!isOwner(currentUser, path)) {
            return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
        }
        
        Resource resource = new UrlResource(requested.toUri());
        
        // ⚠️ Files.probeContentType ขึ้นกับ OS (Linux/Mac/Windows ต่างกัน,
        // Alpine container มัก return null) — ใช้ static map หรือ Apache Tika จะมั่นใจกว่า
        String contentType = resolveContentType(requested);
        
        // 🔴 cachePrivate() + Vary: Authorization — เพราะไฟล์ user-specific
        // ถ้าใช้ cachePublic() CDN/proxy จะ cache ให้ทุกคน → ข้อมูลรั่ว cross-user!
        return ResponseEntity.ok()
            .contentType(MediaType.parseMediaType(contentType))
            .cacheControl(CacheControl.maxAge(7, TimeUnit.DAYS).cachePrivate())
            .header(HttpHeaders.VARY, HttpHeaders.AUTHORIZATION)
            .body(resource);
    }
    
    private boolean isOwner(CustomUserDetails user, String relativePath) {
        // ตัวอย่างสำหรับ avatar — แต่ละ feature อาจ check ต่างกัน
        return userRepo.findById(user.getId())
            .map(u -> relativePath.equals(u.getAvatarPath()))
            .orElse(false);
    }
    
    // static MIME map — ปลอดภัยกว่า Files.probeContentType (ที่ขึ้นกับ OS)
    private static final Map<String, String> MIME = Map.of(
        "jpg",  "image/jpeg",
        "jpeg", "image/jpeg",
        "png",  "image/png",
        "webp", "image/webp",
        "gif",  "image/gif",
        "pdf",  "application/pdf"
    );
    
    private String resolveContentType(Path p) {
        String name = p.getFileName().toString().toLowerCase();
        int dot = name.lastIndexOf('.');
        if (dot < 0) return "application/octet-stream";
        return MIME.getOrDefault(name.substring(dot + 1), "application/octet-stream");
    }
}

⚠️ Production: ให้ nginx serve static file ตรง — ไม่ผ่าน Spring (เร็วกว่ามาก) สำหรับไฟล์ที่ต้อง auth/check ownership ให้ใช้ nginx X-Accel-Redirect — Spring ตอบ header X-Accel-Redirect: /internal/... แล้ว nginx ส่งไฟล์เอง ได้ทั้งความปลอดภัยและความเร็ว


7. ⚠️ Security ของ File Upload

ความเสี่ยงแก้
Path traversal (พาธ ทรา-เวอร์-ซัล = เดินทะลุนอกโฟลเดอร์, ../../etc/passwd)สร้างชื่อใหม่ + toRealPath() + check prefix + reject %2e%2e
Upload .php, .jsp → execute ได้เก็บใน directory ที่ server ไม่ execute + serve เป็น static
ปลอม content-typeตรวจ magic bytes
Upload ใหญ่จน disk เต็มจำกัด size + quota per user + limit ที่ gateway (nginx client_max_body_size)
ZIP bomb (ซิป-บอม = ไฟล์ ZIP ที่แตกออกได้ใหญ่มหาศาล เช่น 1 KB → 10 GB)ถ้ารับ zip → check ratio + limit decompressed size
Filename มี script tag — XSSUUID filename + escape HTML ตอน serve
Public URL → enumerationUUID + check ownership ก่อนส่ง
SVG มี <script> ฝัง → XSS เมื่อ browser แสดงreject SVG หรือ sanitize ฝั่ง server (ลบ tag/attr อันตราย)
Polyglot files (ไฟล์ JPEG+ZIP กายเดียว)decode + re-encode รูปฝั่ง server
ไวรัส / malware ใน uploadClamAV (แคลม-เอ-วี = antivirus open-source) สแกน หรือ AWS GuardDuty Malware Protection for S3
HEIC/AVIF/ImageMagick CVEupgrade library สม่ำเสมอ + sandbox process decode

Part 2: Frontend — React

8. Avatar Upload (Single File)

ข้ามมาฝั่ง frontend — เริ่มจากเคสง่ายสุด: อัปโหลด avatar ไฟล์เดียว ส่วนนี้แสดง validation ฝั่ง client (type/size ก่อนส่ง เพื่อ UX เร็ว แต่ backend ยังต้อง validate ซ้ำ), preview รูปก่อนอัป และ mutation ที่ส่ง FormData:

tsx
// src/features/profile/AvatarUpload.tsx
import { useState, useRef } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { uploadApi } from '@/api/upload';
import { useAuthStore } from '@/stores/auth';

const MAX_SIZE = 2 * 1024 * 1024;
const ALLOWED = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'];

export function AvatarUpload() {
    const fileRef = useRef<HTMLInputElement>(null);
    const [preview, setPreview] = useState<string | null>(null);
    const [error, setError] = useState<string | null>(null);
    const user = useAuthStore(s => s.user);
    const qc = useQueryClient();
    
    const uploadMutation = useMutation({
        mutationFn: (file: File) => uploadApi.avatar(file),
        onSuccess: (data) => {
            qc.invalidateQueries({ queryKey: ['auth', 'me'] });
            setPreview(null);
        },
        onError: () => {
            setError('Upload failed');
            setPreview(null);
        },
    });
    
    const handleFile = (file: File | undefined) => {
        if (!file) return;
        setError(null);
        
        // ตรวจความถูกต้องฝั่ง client (เพื่อ UX เร็ว — backend ยังต้อง validate ซ้ำ)
        if (!ALLOWED.includes(file.type)) {
            setError('Only JPEG/PNG/WebP/GIF allowed');
            return;
        }
        if (file.size > MAX_SIZE) {
            setError(`Max ${MAX_SIZE / 1024 / 1024} MB`);
            return;
        }
        
        // แสดงตัวอย่างรูปก่อนอัป (preview)
        const reader = new FileReader();
        reader.onloadend = () => setPreview(reader.result as string);
        reader.readAsDataURL(file);
        
        // อัปโหลดขึ้นเซิร์ฟเวอร์
        uploadMutation.mutate(file);
    };
    
    const currentAvatar = preview || (user?.avatarUrl ?? '/default-avatar.png');
    
    return (
        <div className="flex items-center gap-4">
            <img
                src={currentAvatar}
                alt="Avatar"
                className="w-20 h-20 rounded-full object-cover border"
            />
            
            <div>
                <input
                    ref={fileRef}
                    type="file"
                    accept={ALLOWED.join(',')}
                    className="hidden"
                    onChange={(e) => handleFile(e.target.files?.[0])}
                />
                
                <button
                    onClick={() => fileRef.current?.click()}
                    disabled={uploadMutation.isPending}
                    className="px-3 py-1 border rounded hover:bg-gray-50"
                >
                    {uploadMutation.isPending ? 'Uploading...' : 'Change avatar'}
                </button>
                
                {error && <p className="text-red-600 text-sm mt-1">{error}</p>}
                <p className="text-xs text-gray-500 mt-1">JPEG/PNG/WebP/GIF, max 2 MB</p>
            </div>
        </div>
    );
}

9. API Layer

ts
// src/api/upload.ts
import { useAuthStore } from '@/stores/auth';

export interface AvatarResponse {
    userId: number;
    url: string;
}

async function uploadFile<T>(
    path: string,
    formData: FormData,
    onProgress?: (percent: number) => void
): Promise<T> {
    const token = useAuthStore.getState().accessToken;
    // กัน bug ถ้า caller ลืมใส่ `/` นำหน้า
    if (!path.startsWith('/')) path = '/' + path;
    
    return new Promise((resolve, reject) => {
        // 💡 ทำไม XMLHttpRequest (XHR — API เก่าตั้งแต่ก่อน fetch)?
        // เพราะต้องการ upload progress event (xhr.upload.onprogress)
        // — fetch() native ยังไม่รองรับ upload progress แบบ cross-browser
        const xhr = new XMLHttpRequest();
        xhr.open('POST', `/api${path}`);
        if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`);
        
        if (onProgress) {
            xhr.upload.addEventListener('progress', (e) => {
                if (e.lengthComputable) {
                    onProgress(Math.round((e.loaded / e.total) * 100));
                }
            });
        }
        
        xhr.onload = () => {
            if (xhr.status >= 200 && xhr.status < 300) {
                resolve(xhr.response ? JSON.parse(xhr.response) : undefined);
            } else {
                try {
                    const body = JSON.parse(xhr.response);
                    reject(new Error(body.error?.message ?? `HTTP ${xhr.status}`));
                } catch {
                    reject(new Error(`HTTP ${xhr.status}`));
                }
            }
        };
        
        xhr.onerror = () => reject(new Error('Network error'));
        xhr.send(formData);
    });
}

export const uploadApi = {
    avatar: (file: File) => {
        const fd = new FormData();
        fd.append('file', file);
        return uploadFile<AvatarResponse>('/uploads/avatar', fd);
    },
    
    documents: (files: File[], description: string, onProgress?: (p: number) => void) => {
        const fd = new FormData();
        files.forEach(f => fd.append('files', f));
        fd.append('description', description);
        return uploadFile<DocumentResponse[]>('/uploads/documents', fd, onProgress);
    },
};

💡 ใช้ XMLHttpRequest แทน fetch เพราะ fetch ไม่มี upload progress แบบ cross-browser Chromium 105+ (Chrome/Edge) รองรับ ReadableStream body ใน fetch แล้ว สามารถทำ progress เองได้ แต่ Safari/Firefox ยังไม่รองรับเต็มที่ → XHR ยังคงเป็น cross-browser fallback ที่ใช้งานจริงในปี 2026


10. Multi-File + Progress Bar

ไฟล์ใหญ่ต้องมี progress bar ไม่งั้น user ไม่รู้ว่าค้างหรือกำลังอัป — ⚠️ จุดที่ต้องรู้คือ fetch ยังไม่รองรับ upload progress แบบ cross-browser ต้องใช้ XMLHttpRequest แทน (Chromium 105+ เริ่มทำได้ผ่าน ReadableStream แต่ Safari/Firefox ยังไม่ถึง) ส่วนนี้แสดง multi-file upload พร้อม progress bar ที่อัปเดต % แบบ real-time:

tsx
// src/features/documents/DocumentUpload.tsx
import { useState } from 'react';
import { useMutation } from '@tanstack/react-query';
import { uploadApi } from '@/api/upload';

export function DocumentUpload() {
    const [files, setFiles] = useState<File[]>([]);
    const [description, setDescription] = useState('');
    const [progress, setProgress] = useState(0);
    
    const uploadMutation = useMutation({
        mutationFn: () => uploadApi.documents(files, description, setProgress),
        onSuccess: () => {
            setFiles([]);
            setDescription('');
            setProgress(0);
        },
    });
    
    const handleSubmit = (e: React.FormEvent) => {
        e.preventDefault();
        if (files.length === 0) return;
        uploadMutation.mutate();
    };
    
    return (
        <form onSubmit={handleSubmit} className="space-y-4">
            <div>
                <label className="block text-sm font-medium mb-1">Files (max 5)</label>
                <input
                    type="file"
                    multiple
                    accept=".pdf,.doc,.docx,.txt"
                    onChange={(e) => {
                        const selected = Array.from(e.target.files ?? []).slice(0, 5);
                        setFiles(selected);
                    }}
                    className="block"
                />
                
                {files.length > 0 && (
                    <ul className="mt-2 text-sm space-y-1">
                        {files.map((f, i) => (
                            <li key={i} className="flex items-center justify-between bg-gray-50 px-2 py-1 rounded">
                                <span>{f.name}</span>
                                <span className="text-gray-500">{(f.size / 1024).toFixed(1)} KB</span>
                            </li>
                        ))}
                    </ul>
                )}
            </div>
            
            <div>
                <label className="block text-sm font-medium mb-1">Description</label>
                <input
                    value={description}
                    onChange={(e) => setDescription(e.target.value)}
                    className="w-full border rounded px-2 py-1"
                />
            </div>
            
            {uploadMutation.isPending && (
                <div className="space-y-1">
                    <div className="text-sm text-gray-700">Uploading... {progress}%</div>
                    <div className="w-full bg-gray-200 rounded-full h-2">
                        <div
                            className="bg-blue-500 h-2 rounded-full transition-all"
                            style={{ width: `${progress}%` }}
                        />
                    </div>
                </div>
            )}
            
            <button
                type="submit"
                disabled={uploadMutation.isPending || files.length === 0}
                className="bg-blue-500 hover:bg-blue-600 disabled:opacity-50 text-white px-4 py-2 rounded"
            >
                Upload
            </button>
        </form>
    );
}

11. Drag & Drop

UX การ upload ที่ดีรองรับลากไฟล์มาวาง (drag & drop) ไม่ใช่แค่กดเลือก — ใช้ drag event ของ HTML5 (onDragOver/onDrop) แล้วดึงไฟล์จาก dataTransfer พร้อม visual feedback ตอนลากเข้า ส่วนนี้แสดง dropzone component ที่ reuse ได้:

tsx
import { useState, DragEvent } from 'react';

export function FileDropzone({ onFiles }: { onFiles: (files: File[]) => void }) {
    const [isDragging, setIsDragging] = useState(false);
    
    const handleDrop = (e: DragEvent) => {
        e.preventDefault();
        setIsDragging(false);
        const files = Array.from(e.dataTransfer.files);
        onFiles(files);
    };
    
    return (
        <div
            onDragOver={(e) => { e.preventDefault(); setIsDragging(true); }}
            onDragLeave={() => setIsDragging(false)}
            onDrop={handleDrop}
            className={`border-2 border-dashed rounded-lg p-8 text-center transition-colors ${
                isDragging ? 'border-blue-500 bg-blue-50' : 'border-gray-300'
            }`}
        >
            <p className="text-gray-600">Drag files here or click to browse</p>
        </div>
    );
}

Part 3: Image Preview + Crop

12. Preview ก่อน Upload

tsx
function ImagePreview({ file, onConfirm, onCancel }: Props) {
    const [src, setSrc] = useState<string | null>(null);
    
    useEffect(() => {
        const url = URL.createObjectURL(file);
        setSrc(url);
        return () => URL.revokeObjectURL(url);
    }, [file]);
    
    return (
        <div>
            {src && <img src={src} alt="Preview" className="max-w-md" />}
            <button onClick={onConfirm}>Confirm</button>
            <button onClick={onCancel}>Cancel</button>
        </div>
    );
}

URL.createObjectURL = สร้าง blob: URL จาก File — เร็วกว่า FileReader
อย่าลืม revokeObjectURL เพื่อ free memory


13. Resize ก่อน Upload (Client-side)

รูปจากกล้องมือถือใหญ่หลาย MB การ resize ที่ฝั่ง client ก่อนส่งช่วยลด bandwidth/storage มหาศาล — ใช้ canvas (พื้นที่วาดรูปบน browser) วาดรูปย่อขนาดแล้วแปลงเป็น WebP (เล็กกว่า JPEG) จุดสำคัญคือทำให้ upload เร็วและประหยัดโดยที่ user แทบไม่รู้สึก:

💡 ทางลัด: ถ้าไม่อยากเขียน canvas เอง ใช้ library browser-image-compression ได้เลย — รับ File เข้า → ได้ File ออก ครบทุกอย่าง (resize, compress, format) ในไม่กี่บรรทัด

ts
// ใช้ canvas API ของ browser โดยตรง (ไม่ต้องลง library)
async function resizeImage(file: File, maxSize = 1024): Promise<Blob> {
    const img = new Image();
    img.src = URL.createObjectURL(file);
    await new Promise(r => img.onload = r);
    
    let { width, height } = img;
    if (width > maxSize || height > maxSize) {
        const ratio = Math.min(maxSize / width, maxSize / height);
        width = Math.round(width * ratio);
        height = Math.round(height * ratio);
    }
    
    const canvas = document.createElement('canvas');
    canvas.width = width;
    canvas.height = height;
    canvas.getContext('2d')!.drawImage(img, 0, 0, width, height);
    
    // ⚠️ Safari < iOS 14 encode WebP ไม่ได้ → blob เป็น null
    // เช็คก่อน แล้ว fallback เป็น JPEG
    const supportsWebP = canvas.toDataURL('image/webp').startsWith('data:image/webp');
    const mime = supportsWebP ? 'image/webp' : 'image/jpeg';
    
    return new Promise((resolve, reject) => {
        canvas.toBlob(blob => {
            if (blob) resolve(blob);
            else reject(new Error('canvas.toBlob returned null'));
        }, mime, 0.85);
    });
}

// ใช้
const resized = await resizeImage(file, 800);
const formData = new FormData();
formData.append('file', resized, 'avatar.webp');

→ ลด bandwidth + storage + เร็วขึ้นมาก


Part 4: S3 + Presigned URL (Production)

🚀 โซนขั้นสูง — ข้ามได้ Part 4 ทั้งหมด (S3 + presigned URL) เป็นเรื่อง production ที่ใช้ cloud storage — ตอนเรียน/ทำ MVP เก็บไฟล์ลง disk ของ server ตาม Part 1–3 พอแล้ว ค่อยกลับมาอ่าน Part นี้ตอนแอปขึ้นจริงและต้อง scale

14. ทำไมไม่เก็บ local

local disk (ดิสก์ในเครื่อง server เอง):

  • ❌ Scale (ขยายรองรับโหลด) ยาก (multi-instance = มี server หลายตัว ต้อง share volume กัน)
  • ❌ Backup (สำรองข้อมูล) ลำบาก
  • ❌ ไม่มี CDN (Content Delivery Network = เครือข่ายเซิร์ฟเวอร์กระจายไฟล์ให้โหลดเร็วใกล้ผู้ใช้) ในตัว
  • ❌ ใช้ disk ของ server

→ Production = ใช้ object storage (บริการเก็บไฟล์เป็น "อ็อบเจกต์" บนคลาวด์):

บริการคำอ่านจุดเด่น
S3 ของ AWSเอส-ทรีมาตรฐานที่ทุกคนรู้จัก, ecosystem ใหญ่สุด, ราคามาตรฐาน
R2 ของ Cloudflareอาร์-ทูzero egress fee (ไม่คิดค่า download ออก) — เหมาะกับเว็บที่มีคนโหลดเยอะ
B2 ของ Backblazeบี-ทู (Backblaze = แบ็ก-เบลซ)ราคา storage ถูกที่สุด ~1/4 ของ S3
MinIOมิน-ไอ-โอself-host เอง ใช้ S3 API เหมือนกัน — ไว้พัฒนา local หรือ on-prem
GCS ของ Googleจี-ซี-เอสสำหรับงานที่อยู่บน Google Cloud

ทั้งหมดข้างบนใช้ S3-compatible API เหมือนกัน → เขียนโค้ดครั้งเดียวสลับ provider ได้


15. Pattern — Direct Upload to S3

การ upload ผ่าน backend ทำให้ backend กิน bandwidth (ปริมาณข้อมูลรับ-ส่งผ่านเน็ต) สองเท่า (รับจาก client แล้วส่งต่อ S3) — presigned URL (URL ที่เซิร์ฟเวอร์เซ็นรับรองล่วงหน้า อายุสั้น) แก้ด้วยการให้ client upload ตรงไป S3 backend แค่ออก URL ชั่วคราวที่อนุญาตให้ upload ได้ ลดภาระ backend มากและ scale ได้ดีกว่า:

แทน upload ผ่าน backend (consume bandwidth):

1. Client → Backend: ขอ presigned URL
2. Backend → S3: generate URL ที่ใช้ upload ได้ (อายุ 5 นาที)
3. Backend → Client: presigned URL
4. Client → S3 (direct): PUT file
5. Client → Backend: บอกว่า upload เสร็จ + URL

16. Backend — Generate Presigned URL

ฝั่ง backend ใช้ AWS SDK สร้าง presigned URL ที่มีอายุสั้น (เช่น 15 นาที) สำหรับ PUT ไฟล์ — URL นี้ฝัง permission ชั่วคราวให้ client upload ได้โดยไม่ต้องมี AWS credential เอง ส่วนนี้แสดงการ generate URL และส่งให้ client

📋 ก่อนเริ่ม — Prereq:

  1. มี AWS account (สมัครฟรี — มีเครดิตเริ่มต้น)
  2. สร้าง S3 bucket (ถังเก็บไฟล์) ใน region ที่ใกล้ผู้ใช้
  3. สร้าง IAM user (ไอ-แอม = Identity and Access Management — จัดการสิทธิ์ใน AWS) ที่มีสิทธิ์ s3:PutObject + s3:GetObject บน bucket นั้น
  4. ตั้ง env var AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY ให้ Spring โหลด credential อัตโนมัติ
  5. (ถ้าใช้ R2/B2/MinIO) ตั้ง endpoint-override ใน S3Client.builder()
xml
<dependency>
    <groupId>software.amazon.awssdk</groupId>
    <artifactId>s3</artifactId>
    <version>2.30.0</version>
</dependency>
java
@Service
@RequiredArgsConstructor
public class S3Service {
    
    @Value("${app.s3.bucket}")
    private String bucket;
    
    private final S3Client s3Client;
    private final S3Presigner presigner;
    
    public PresignedUploadResponse createPresignedUpload(
        String filename, String contentType, Long userId
    ) {
        String key = "uploads/" + userId + "/" + UUID.randomUUID() + "-" + filename;
        
        PutObjectRequest putReq = PutObjectRequest.builder()
            .bucket(bucket)
            .key(key)
            .contentType(contentType)
            .build();
        
        // 💡 15 นาที = trade-off — สั้นเกินไป (เช่น 5 นาที) ไฟล์ใหญ่บนเน็ตช้าอาจอัปไม่ทัน
        //              ยาวเกินไปก็เสี่ยง URL รั่ว ใช้งานได้นาน
        PresignedPutObjectRequest presigned = presigner.presignPutObject(b ->
            b.signatureDuration(Duration.ofMinutes(15)).putObjectRequest(putReq)
        );
        
        // ⚠️ อย่า concat URL เอง! ("https://" + bucket + ".s3.amazonaws.com/...")
        // — bucket ที่มี "." ในชื่อ จะ SSL cert mismatch (เช่น my.site.com)
        // — region อื่นใช้รูปแบบต่างกัน (us-east-1 vs eu-west-1)
        // — path-style vs virtual-hosted-style ต่างกัน
        // ใช้ S3Utilities.getUrl() ให้ SDK คำนวณ URL ที่ถูกต้องตาม region/bucket
        String publicUrl = s3Client.utilities()
            .getUrl(b -> b.bucket(bucket).key(key))
            .toString();
        
        return new PresignedUploadResponse(
            presigned.url().toString(),
            key,
            publicUrl
        );
    }
}

public record PresignedUploadResponse(
    String uploadUrl,        // ที่ client ใช้ PUT
    String key,              // เก็บใน DB
    String publicUrl         // ใช้แสดง
) {}
java
@PostMapping("/presigned-upload")
public PresignedUploadResponse presigned(
    @RequestBody PresignedRequest req,
    @AuthenticationPrincipal CustomUserDetails user
) {
    return s3Service.createPresignedUpload(req.filename(), req.contentType(), user.getId());
}

17. Frontend — Direct Upload to S3

ts
async function uploadDirect(file: File, onProgress?: (p: number) => void) {
    // 1. ขอ presigned URL
    const { uploadUrl, publicUrl } = await http.post('/uploads/presigned-upload', {
        filename: file.name,
        contentType: file.type,
    });
    
    // 2. PUT ไป S3 ตรง ๆ
    // ⚠️ Content-Type ต้องตรงกับตอน sign (ใน createPresignedUpload — เราใช้ file.type ตอนขอ URL)
    //   ถ้าตรงนี้ส่ง Content-Type ไม่เหมือนเดิม S3 จะ reject ด้วย SignatureDoesNotMatch
    await new Promise<void>((resolve, reject) => {
        const xhr = new XMLHttpRequest();
        xhr.open('PUT', uploadUrl);
        xhr.setRequestHeader('Content-Type', file.type);
        
        if (onProgress) {
            xhr.upload.addEventListener('progress', e => {
                if (e.lengthComputable) onProgress(Math.round((e.loaded / e.total) * 100));
            });
        }
        
        xhr.onload = () => xhr.status === 200 ? resolve() : reject();
        xhr.onerror = reject;
        xhr.send(file);
    });
    
    // 3. บอก backend (optional — ขึ้นกับ flow)
    return publicUrl;
}

ข้อดี:

  • ✅ Bandwidth ไม่ผ่าน backend
  • ✅ Scale ได้ดี
  • ✅ ใช้ CDN (CloudFront = บริการ CDN ของ AWS) ฟรี ๆ
  • ⚠️ ต้อง config CORS ที่ S3 bucket (bucket = "ถัง" เก็บไฟล์ของ S3) ให้อนุญาตเว็บเราเรียกได้

18. ไฟล์ใหญ่ > 100 MB — S3 Multipart Upload

PUT เดียวจบเหมาะกับไฟล์ < 100 MB — ถ้าใหญ่กว่านี้ (วิดีโอ, backup, dataset) ใช้ S3 multipart upload (มัล-ติ-พาร์ต อัปโหลด) ซึ่ง:

  • ตัดไฟล์เป็น part (ส่วน) ละ 5 MB ขึ้นไป upload ขนานกัน
  • ถ้าบาง part fail → retry แค่ part นั้น ไม่ต้องเริ่มใหม่หมด
  • รองรับไฟล์สูงสุด 5 TB

Pattern ปี 2026 (พื้นฐาน):

1. Client → Backend: createMultipartUpload(filename) → ได้ uploadId
2. Client → Backend: ขอ presigned URL ของแต่ละ part (1..N)
3. Client → S3 (ขนาน): PUT แต่ละ part → ได้ ETag กลับ
4. Client → Backend: completeMultipartUpload(uploadId, parts[]) — รวมร่าง

ฝั่ง JavaScript ใช้ @aws-sdk/lib-storage ในฝั่ง Node/edge:

ts
import { Upload } from '@aws-sdk/lib-storage';
import { S3Client } from '@aws-sdk/client-s3';

const upload = new Upload({
    client: new S3Client({ region: 'ap-southeast-1' }),
    params: { Bucket: bucket, Key: key, Body: fileStream },
    partSize: 10 * 1024 * 1024,   // 10 MB ต่อ part
    queueSize: 4,                  // upload 4 part พร้อมกัน
});

upload.on('httpUploadProgress', p => console.log(`${p.loaded}/${p.total}`));
await upload.done();

⚠️ จาก browser ตรง ๆ ทำ multipart upload ได้แต่ซับซ้อน — production ส่วนใหญ่ใช้ library พร้อมใช้ เช่น Uppy หรือ AWS Amplify Storage


Part 5: ⚠️ Common Pitfalls

Pitfallแก้
ลืม validate content type / size ที่ backendserver-side validation เสมอ — client validation = UX เท่านั้น
Path traversal — ../../passwdtoRealPath() + check prefix (กัน symlink + %2e%2e)
User เข้าถึง file คนอื่นได้@PreAuthorize + check ownership ก่อน serve
cachePublic() บนไฟล์ที่ authใช้ cachePrivate() + Vary: Authorization ไม่งั้น CDN รั่ว cross-user
Memory blow up ตอน upload ไฟล์ใหญ่ใช้ InputStream + chunked, อย่า getBytes()
ไม่จำกัดที่ gatewayตั้ง client_max_body_size ใน nginx — กัน DDoS ก่อนถึง app
ลืม revoke object URLURL.revokeObjectURL() ใน cleanup
ไม่ progress bar — user คิดว่าค้างXHR + onProgress
ลืม cleanup ตอน delete usercascade delete + remove file from disk/S3
Filename ปลอม → XSS ใน UIescape / sanitize ตอน show
ลบ avatar เก่าก่อน save ใหม่สำเร็จsave → update DB → ค่อยลบไฟล์เก่า
Files.probeContentType คืน null ใน containerใช้ static MIME map หรือ Apache Tika

19. Checkpoint

🛠️ Checkpoint 4.1 — Avatar
ทำหน้า /profile:

  • แสดง avatar (default ถ้ายังไม่มี)
  • คลิก → file picker
  • preview ก่อน upload
  • save ไป server → reload avatar

🛠️ Checkpoint 4.2 — Multi-file with Progress
หน้า /documents:

  • drag & drop หลายไฟล์
  • preview list
  • ลบ 1 ไฟล์ก่อน upload ได้
  • progress bar ตอน upload
  • success → list ใหม่

🛠️ Checkpoint 4.3 — Image Resize
ก่อน upload avatar → resize ที่ client (max 800×800, WebP, 85% quality)
ดู bytes ก่อน/หลัง

🛠️ Checkpoint 4.4 — Validation Magic Bytes
ลองเปลี่ยน .txt ไฟล์เป็น .jpg (rename) → upload → ดูว่า backend reject (magic bytes check)


20. สรุปบท

✅ Multipart/form-data = standard สำหรับ upload file
✅ Backend: MultipartFile + validate (size, type, magic bytes) + save ที่ปลอดภัย
✅ Generate filename ใหม่ (UUID) — อย่าใช้ filename ของ user ตรง ๆ
✅ Frontend: ใช้ XMLHttpRequest ถ้าต้องการ progress (fetch ยังไม่รองรับ upload progress แบบ cross-browser ในปี 2026)
URL.createObjectURL สำหรับ preview — อย่าลืม revokeObjectURL
✅ Resize ที่ client ก่อน upload → ประหยัด bandwidth
✅ Production: S3 + Presigned URL = client upload ตรง, bypass backend bandwidth
✅ Security: path traversal, magic bytes, ownership check, ไม่ trust filename


← บทที่ 3 | บทที่ 5 → Deployment