檔案上傳是 Web 應用基本功能。現代 Servlet 容器 (Tomcat 7+/Jetty 9+/WildFly) 內建 **Servlet 3.0 `multipart` 支援**,無需第三方庫即可處理上傳;舊版環境則使用 **Apache Commons FileUpload**。本文提供兩種方案完整程式碼、配置細節、安全性檢查、進階功能 (進度條、多檔、雲端儲存) 實作指南。
package com.example.upload;
import jakarta.servlet.*;
import jakarta.servlet.annotation.*;
import jakarta.servlet.http.*;
import java.io.*;
import java.nio.file.*;
import java.util.*;
@WebServlet("/upload")
@MultipartConfig(
location = "/tmp/uploads", // 臨時檔案目錄 (超過 fileSizeThreshold 時使用)
maxFileSize = 10 * 1024 * 1024, // 單檔上限 10MB
maxRequestSize = 50 * 1024 * 1024, // 總請求上限 50MB
fileSizeThreshold = 1024 * 1024 // 1MB 以上寫入臨時檔
)
public class FileUploadServlet extends HttpServlet {
private static final String UPLOAD_DIR = "uploads"; // 相對於 webapp 根目錄
private static final Set ALLOWED_EXT = Set.of("jpg", "jpeg", "png", "gif", "pdf", "doc", "docx", "zip");
private static final long MAX_SIZE = 10 * 1024 * 1024;
@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
req.setCharacterEncoding("UTF-8");
String action = req.getParameter("action");
// 取得真實儲存路徑
String appPath = getServletContext().getRealPath("");
Path saveDir = Paths.get(appPath, UPLOAD_DIR);
Files.createDirectories(saveDir);
Collection parts = req.getParts();
List messages = new ArrayList<>();
for (Part part : parts) {
if (part.getName().equals("file")) { // 僅處理 file 欄位
String fileName = Paths.get(part.getSubmittedFileName()).getFileName().toString(); // 防止路徑遍歷
if (fileName.isEmpty()) {
messages.add("未選擇檔案");
continue;
}
// 檔案類型驗證 (副檔名 + Content-Type 雙重檢查)
String ext = getExtension(fileName).toLowerCase();
String contentType = part.getContentType();
if (!ALLOWED_EXT.contains(ext) || !isAllowedContentType(contentType, ext)) {
messages.add("禁止的檔案類型: " + fileName);
continue;
}
// 檔案大小驗證
if (part.getSize() > MAX_SIZE) {
messages.add("檔案過大 (>10MB): " + fileName);
continue;
}
// 生成唯一檔名避免覆蓋 (UUID + 原副檔名)
String storedName = UUID.randomUUID() + "." + ext;
Path target = saveDir.resolve(storedName);
try (InputStream in = part.getInputStream()) {
Files.copy(in, target, StandardCopyOption.REPLACE_EXISTING);
messages.add("上傳成功: " + fileName + " → " + storedName);
} catch (IOException e) {
messages.add("上傳失敗: " + fileName + " - " + e.getMessage());
}
// 刪除臨時檔 (若 part 為臨時檔)
part.delete();
}
}
req.setAttribute("messages", messages);
req.getRequestDispatcher("/upload_result.jsp").forward(req, resp);
}
private String getExtension(String filename) {
int dot = filename.lastIndexOf('.');
return (dot > 0) ? filename.substring(dot + 1) : "";
}
private boolean isAllowedContentType(String contentType, String ext) {
if (contentType == null) return false;
return switch (ext) {
case "jpg", "jpeg" -> contentType.equals("image/jpeg");
case "png" -> contentType.equals("image/png");
case "gif" -> contentType.equals("image/gif");
case "pdf" -> contentType.equals("application/pdf");
case "doc" -> contentType.equals("application/msword");
case "docx" -> contentType.equals("application/vnd.openxmlformats-officedocument.wordprocessingml.document");
case "zip" -> contentType.equals("application/zip");
default -> false;
};
}
}
<form action="upload" method="post" enctype="multipart/form-data">
<input type="file" name="file" accept="image/*,.pdf,.doc,.docx,.zip" required />
<input type="hidden" name="action" value="upload" />
<button type="submit">上傳</button>
</form>
<!-- 多檔上傳 (HTML5) -->
<input type="file" name="files" multiple />
<!-- Servlet 端: req.getParts() 迴圈處理每個 part -->
標準 API 無進度事件。現代架構採用 **前端直傳雲端儲存 (S3/OSS/GCS)**:
1. 後端簽發預簽名 URL (Presigned URL)。
2. 前端 `fetch/XHR` 直接 `PUT` 至雲端,原生支援 `onprogress`。
3. 上傳完成回調後端驗證並入庫。
此法卸載伺服器頻寬、支援大檔斷點續傳、天然分散式。
<dependency>
<groupId>commons-fileupload</groupId>
<artifactId>commons-fileupload</artifactId>
<version>1.5</version> <!-- 需 commons-io 2.6+ -->
</dependency>
@WebServlet("/upload-legacy")
public class LegacyUploadServlet extends HttpServlet {
private static final String UPLOAD_DIR = "uploads";
@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
boolean isMultipart = ServletFileUpload.isMultipartContent(req);
if (!isMultipart) { resp.sendError(HttpServletResponse.SC_BAD_REQUEST, "非 multipart 請求"); return; }
DiskFileItemFactory factory = new DiskFileItemFactory();
factory.setRepository(new File(System.getProperty("java.io.tmpdir"))); // 臨時目錄
factory.setSizeThreshold(1024 * 1024); // 1MB 閾值
ServletFileUpload upload = new ServletFileUpload(factory);
upload.setFileSizeMax(10 * 1024 * 1024);
upload.setSizeMax(50 * 1024 * 1024);
upload.setHeaderEncoding("UTF-8"); // 解決中文檔名亂碼
String appPath = getServletContext().getRealPath("");
File saveDir = new File(appPath, UPLOAD_DIR);
saveDir.mkdirs();
try {
List items = upload.parseRequest(req);
for (FileItem item : items) {
if (item.isFormField()) {
// 普通表單欄位
String fieldName = item.getFieldName();
String value = item.getString("UTF-8");
System.out.println("Form field: " + fieldName + " = " + value);
} else {
// 檔案欄位
String fileName = new File(item.getName()).getName(); // 防路徑遍歷
if (fileName.isEmpty()) continue;
// 驗證邏輯同方案一...
String ext = getExtension(fileName).toLowerCase();
String storedName = UUID.randomUUID() + "." + ext;
File target = new File(saveDir, storedName);
item.write(target); // 自動處理記憶體/臨時檔
item.delete(); // 釋放臨時檔
}
}
} catch (Exception e) {
throw new ServletException("上傳處理失敗", e);
}
}
// getExtension()...
}
| 風險 | 對策 |
|---|---|
| 惡意檔案類型 (雙副檔名、偽裝 Content-Type) | 副檔名白名單 + Content-Type 驗證 + **魔數檢查** (讀取檔頭 bytes) |
| 路徑遍歷 (`../../etc/passwd`) | `Paths.get(submittedName).getFileName()` / `new File(name).getName()` 只取檔名部份 |
| 超大檔案耗盡磁碟/記憶體 | `maxFileSize`、`maxRequestSize`、`sizeThreshold` 三層限制 |
| 檔名衝突覆蓋 | UUID/時間戳/雜湊重命名,資料庫記錄原始檔名 |
| 腳本執行 (上傳 .jsp/.php/.asp) | **儲存目錄設為不可執行** (Nginx/Apache `location ~ \.php$ { deny all; }`、IIS 移除執行權限)、副檔名白名單 |
| DoS (大量小檔、慢速傳輸) | 限制單位時間上傳次數、連線逾時、請求體大小、WAF 規則 |
| 病毒/惡意代碼 | 整合 ClamAV / 商業反病毒 API 掃描入庫前 |
public boolean isValidImage(File file) throws IOException {
try (InputStream is = new BufferedInputStream(Files.newInputStream(file.toPath()))) {
byte[] header = new byte[8];
is.read(header);
// JPEG: FF D8 FF
if (header[0] == (byte)0xFF && header[1] == (byte)0xD8 && header[2] == (byte)0xFF) return true;
// PNG: 89 50 4E 47 0D 0A 1A 0A
if (header[0] == (byte)0x89 && header[1] == (byte)0x50 && header[2] == (byte)0x4E && header[3] == (byte)0x47) return true;
// GIF: 47 49 46 38 (GIF8)
if (header[0] == 'G' && header[1] == 'I' && header[2] == 'F' && header[3] == '8') return true;
// PDF: 25 50 44 46 (%PDF)
if (header[0] == '%' && header[1] == 'P' && header[2] == 'D' && header[3] == 'F') return true;
return false;
}
}
CREATE TABLE uploaded_files (
id BIGSERIAL PRIMARY KEY,
original_name VARCHAR(255) NOT NULL,
stored_name VARCHAR(255) NOT NULL UNIQUE,
content_type VARCHAR(100) NOT NULL,
size_bytes BIGINT NOT NULL,
storage_path VARCHAR(500) NOT NULL, -- 相對路徑或 S3 Key
storage_type VARCHAR(20) NOT NULL, -- LOCAL, S3, OSS, MINIO
uploader_id BIGINT REFERENCES users(id),
upload_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
checksum_md5 CHAR(32), -- 可選:完整性驗證
status VARCHAR(20) DEFAULT 'ACTIVE' -- ACTIVE, DELETED, QUARANTINED
);