โหมดมืด
บทที่ 4 — File Upload (Avatar, Document, Image)
ทุก 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()— bytesgetBytes()— contentgetInputStream()— 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 ตอบ headerX-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 — XSS | UUID filename + escape HTML ตอน serve |
| Public URL → enumeration | UUID + check ownership ก่อนส่ง |
SVG มี <script> ฝัง → XSS เมื่อ browser แสดง | reject SVG หรือ sanitize ฝั่ง server (ลบ tag/attr อันตราย) |
| Polyglot files (ไฟล์ JPEG+ZIP กายเดียว) | decode + re-encode รูปฝั่ง server |
| ไวรัส / malware ใน upload | ClamAV (แคลม-เอ-วี = antivirus open-source) สแกน หรือ AWS GuardDuty Malware Protection for S3 |
| HEIC/AVIF/ImageMagick CVE | upgrade 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) รองรับReadableStreambody ใน 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 เสร็จ + URL16. Backend — Generate Presigned URL
ฝั่ง backend ใช้ AWS SDK สร้าง presigned URL ที่มีอายุสั้น (เช่น 15 นาที) สำหรับ PUT ไฟล์ — URL นี้ฝัง permission ชั่วคราวให้ client upload ได้โดยไม่ต้องมี AWS credential เอง ส่วนนี้แสดงการ generate URL และส่งให้ client
📋 ก่อนเริ่ม — Prereq:
- มี AWS account (สมัครฟรี — มีเครดิตเริ่มต้น)
- สร้าง S3 bucket (ถังเก็บไฟล์) ใน region ที่ใกล้ผู้ใช้
- สร้าง IAM user (ไอ-แอม = Identity and Access Management — จัดการสิทธิ์ใน AWS) ที่มีสิทธิ์
s3:PutObject+s3:GetObjectบน bucket นั้น- ตั้ง env var
AWS_ACCESS_KEY_ID+AWS_SECRET_ACCESS_KEYให้ Spring โหลด credential อัตโนมัติ- (ถ้าใช้ 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 ที่ backend | server-side validation เสมอ — client validation = UX เท่านั้น |
Path traversal — ../../passwd | toRealPath() + 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 URL | URL.revokeObjectURL() ใน cleanup |
| ไม่ progress bar — user คิดว่าค้าง | XHR + onProgress |
| ลืม cleanup ตอน delete user | cascade delete + remove file from disk/S3 |
| Filename ปลอม → XSS ใน UI | escape / 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