Web Day 14 檔案上傳與靜態檔案
執行需求:CPU 可跑。前幾天我們處理的是純文字資料(JSON、密碼、token),今天進入「檔案」的世界。你會學到 FastAPI 的 UploadFile 與 File 怎麼處理使用者上傳的頭像或附件、怎麼限制檔案大小與 MIME 型別、怎麼用隨機檔名避免衝突與覆寫攻擊,以及 StaticFiles 怎麼把上傳後的檔案透過 URL 對外提供。最後我們會做完整的「使用者頭像上傳 → 儲存 → 透過 URL 顯示」流程,這是後續「預約管理系統」中客戶頭像與預約附件功能的基礎。
引言
後端 API 處理的資料大致分兩類:純文字(JSON、表單欄位)與二進位檔案(圖片、PDF、影片)。到目前為止,我們處理的都是純文字。但真實世界的應用幾乎都需要處理檔案:社群平台要存使用者頭像、電商網站要存商品照片、教育平台要讓學生交作業 PDF、預約系統要讓店家上傳服務照片。如果後端不處理檔案,前端只能把檔案編碼成 base64 塞進 JSON,不僅浪費頻寬,記憶體也吃很重。
FastAPI 對檔案處理有兩種層次的支援:第一種是 File 與 Form,處理傳統 multipart/form-data 上傳;第二種是 UploadFile,把上傳的檔案包成一個非同步可讀的物件,搭配 spooled temporary file 機制,小檔案放記憶體、大檔案自動 spill 到磁碟。對絕大多數應用來說 UploadFile 是正確選擇。我們今天就用它。UploadFile 內部使用 Python 的 SpooledTemporaryFile:當檔案小於某個臨界值(預設 1 MB)時,內容放在記憶體中的 BytesIO,讀取非常快;超過臨界值則自動切換到磁碟上的暫存檔,避免 OOM。這套機制讓 FastAPI 對 100 KB 的頭像與 1 GB 的影片都能用同一支 API 處理,開發者不必為不同大小的檔案寫兩套程式。
今天的目標有四個:第一,理解 UploadFile 的設計與限制;第二,建立「上傳頭像」端點,含大小、MIME 型別、檔名安全檢查;第三,建立 StaticFiles 把上傳目錄對外提供為靜態 URL;第四,把檔案路徑存進 User 模型,讓前端可以用統一的 URL 顯示頭像。整個流程會接上昨天的認證系統(必須登入才能上傳),也會用到昨天的 Alembic 遷移(新增頭像欄位)。
檔案上傳的安全前提
在談怎麼寫程式之前,先談四個檔案上傳的資安原則,這些不是技術細節而是「觀念」,漏一個就可能出大事。第一,「絕對不信任使用者上傳的檔名」。使用者可能送 ../../../etc/passwd 試圖覆寫系統檔案,或送 shell.php.jpg 試圖讓 Apache 把 JPEG 當 PHP 執行。我們必須自己產生隨機檔名,只保留副檔名作為提示。第二,「絕對不信任使用者上傳的 MIME 型別」。瀏覽器送的 Content-Type 是使用者可控的,攻擊者可以送 Content-Type: image/jpeg 但實際內容是 JavaScript。我們必須讀檔案的「magic bytes」(檔頭的幾個位元組)來驗證真實型別。第三,「限制檔案大小」。沒有上限的上傳會被用來灌爆磁碟、發動 DoS 攻擊。我們要在接收階段就拒絕過大的檔案,不要等整個檔案都讀完才檢查。第四,「不要把上傳目錄設成可執行」。即使你擋掉了 .php、.exe,使用者還是可能塞 .htaccess、.svg(SVG 內含 JavaScript)。把上傳目錄放在專門的 static 路徑下,且絕對不允許伺服器把這個路徑當程式執行。
這四個原則今天都會實作到位。順帶一提,「上傳後的檔案要怎麼存」有三種主流做法:第一種是存到本機磁碟(適合中小型應用、單機部署);第二種是存到物件儲存服務(AWS S3、GCP Cloud Storage、Azure Blob,適合大型應用、CDN 整合);第三種是存到資料庫(不建議,除非檔案很小且數量少)。我們今天用本機磁碟,後續貫穿專案會在 Day 41「Docker Compose 一鍵上線」提到怎麼切換到 S3 相容儲存。S3 相容儲存的優勢除了無限容量(相對於本機磁碟),還有「預簽章 URL」機制:可以產生一個 15 分鐘有效的 URL,讓前端直接 PUT 檔案到 S3,繞過 FastAPI 這層。這對大檔案(影片、原始照片)特別有用,省下 FastAPI 的 CPU 與頻寬。我們今天用本機磁碟,原因是「開發階段本機最簡單」,但部署時請務必評估是否要切換。
UploadFile 的基本用法
先看一支最小可運作的檔案上傳端點:
# app/routers/files.py
import os
from fastapi import APIRouter, File, UploadFile
router = APIRouter(prefix="/files", tags=["files"])
UPLOAD_DIR = "./uploads"
@router.post("/upload")
async def upload(file: UploadFile = File(...)) -> dict:
# 最簡單的版本:直接把檔案內容寫到磁碟
content = await file.read()
save_path = os.path.join(UPLOAD_DIR, file.filename or "unnamed")
with open(save_path, "wb") as f:
f.write(content)
return {"filename": file.filename, "size": len(content)}
這個範例暴露了三個嚴重問題:第一,完全沒檢查檔案大小,使用者可以送 10 GB 的檔案;第二,直接用使用者送的 file.filename,可能含路徑穿越字元;第三,沒檢查 MIME 型別,攻擊者可以塞 .exe 當作 .jpg 上傳。我們接下來要把這三個洞補起來。
檔案大小限制
FastAPI 的 UploadFile 預設沒有大小限制,要靠我們自己在讀檔時檢查:
# app/routers/files.py(加入大小限制)
MAX_FILE_SIZE = 5 * 1024 * 1024 # 5 MB
@router.post("/upload")
async def upload(file: UploadFile = File(...)) -> dict:
# 分段讀取,每段檢查累計大小
total = 0
chunks = []
while chunk := await file.read(1024 * 64): # 每次讀 64 KB
total += len(chunk)
if total > MAX_FILE_SIZE:
# 超過限制直接拒絕,不繼續讀
raise ValidationFailedError(
message=f"檔案大小超過限制({MAX_FILE_SIZE // (1024 * 1024)} MB)",
details={"max_size": MAX_FILE_SIZE},
)
chunks.append(chunk)
content = b"".join(chunks)
return {"filename": file.filename, "size": total}
分塊讀取(read(64 * 1024))並在累積超過上限時立刻中斷,這樣即使攻擊者送 100 GB 的檔案,我們也只會讀前幾 MB 就拒絕,伺服器記憶體不會被吃光。ValidationFailedError 是 Web Day 10 設計的 422 例外,這裡剛好對應「檔案太大」這種語意錯誤。
MIME 型別驗證
瀏覽器送的 Content-Type 是不可信的。我們用 Python 標準庫的 imghdr 與 struct 檢查檔頭的 magic bytes:
# app/security.py(新增檔案型別檢查)
import struct
ALLOWED_IMAGE_TYPES = {
# MIME: (副檔名, magic bytes 檢查函式)
"image/jpeg": (".jpg", lambda b: b[:3] == b"\xff\xd8\xff"),
"image/png": (".png", lambda b: b[:8] == b"\x89PNG\r\n\x1a\n"),
"image/gif": (".gif", lambda b: b[:6] in (b"GIF87a", b"GIF89a")),
"image/webp": (".webp", lambda b: b[:4] == b"RIFF" and b[8:12] == b"WEBP"),
}
def detect_image_type(head_bytes: bytes) -> str | None:
# 從檔頭判斷真實的圖片型別
for mime, (_, check) in ALLOWED_IMAGE_TYPES.items():
if check(head_bytes):
return mime
return None
這個函式接收檔案開頭的若干位元組,檢查是否符合已知圖片格式的 magic bytes。JPEG 開頭是 FF D8 FF、PNG 是 89 50 4E 47 0D 0A 1A 0A、GIF 是 GIF87a 或 GIF89a、WEBP 是 RIFF + 4 bytes 任意 + WEBP。攻擊者就算把惡意檔案命名為 image.jpg 並設 Content-Type: image/jpeg,magic bytes 對不上就會被拒絕。注意 magic bytes 檢查只能「過濾掉明顯偽裝的檔案」,對於精心製作的 polyglot 檔案(例如同時是合法 JPEG 與合法 PHP)就無能為力。實務上更穩的做法是「不允許任何瀏覽器可執行的型別」,並搭配「上傳目錄不可執行」的伺服器設定,雙重保險。
隨機檔名與路徑安全
即使做了型別檢查,仍然不該用使用者送的檔名。我們用 Python 的 secrets 模組產生隨機字串當檔名:
# app/security.py(新增隨機檔名)
import secrets
def generate_safe_filename(original_name: str, mime: str) -> str:
# 用隨機字串當主檔名,只保留 mime 對應的副檔名
ext = ALLOWED_IMAGE_TYPES[mime][0]
random_part = secrets.token_urlsafe(16)
return f"{random_part}{ext}"
def is_safe_filename(name: str) -> bool:
# 檢查檔名是否含路徑穿越或特殊字元
if not name:
return False
if "/" in name or "\\" in name:
return False
if name.startswith("."): # 不允許 .htaccess、.git 等
return False
return True
secrets.token_urlsafe(16) 產生 16 位元組的隨機資料、Base64 編碼後約 22 字元,碰撞機率在 10 億筆檔案中仍然微乎其微。副檔名從我們自己的對照表拿,不從使用者送的檔名推斷,這樣使用者送 evil.php 也只會被存成 abc123.jpg,無法被當作 PHP 執行。
完整的上傳端點
把所有東西組起來,建立一支「上傳使用者頭像」的端點(接上昨天的認證):
# app/routers/files.py(完整版)
import os
from fastapi import APIRouter, Depends, File, UploadFile
from sqlmodel import Session
from app.db import get_session
from app.dependencies import CurrentUser
from app.errors import ValidationFailedError
from app.models import User
from app.security import (
detect_image_type,
generate_safe_filename,
)
router = APIRouter(prefix="/files", tags=["files"])
UPLOAD_DIR = "./uploads"
MAX_FILE_SIZE = 5 * 1024 * 1024
@router.post("/avatar")
async def upload_avatar(
file: UploadFile = File(...),
user: CurrentUser = None,
session: Session = Depends(get_session),
) -> dict:
# 1. 檢查 Content-Type(基本防線;真正的驗證靠 magic bytes)
if file.content_type not in (
"image/jpeg", "image/png", "image/gif", "image/webp"
):
raise ValidationFailedError(message="不支援的檔案型別")
# 2. 分塊讀取並檢查大小
total = 0
chunks = []
while chunk := await file.read(1024 * 64):
total += len(chunk)
if total > MAX_FILE_SIZE:
raise ValidationFailedError(
message=f"檔案大小超過 {MAX_FILE_SIZE // (1024 * 1024} MB 上限"
)
chunks.append(chunk)
content = b"".join(chunks)
# 3. 用 magic bytes 驗證真實型別
detected = detect_image_type(content[:32])
if detected is None:
raise ValidationFailedError(message="檔案內容不是有效的圖片")
# 4. 產生安全檔名並寫入磁碟
os.makedirs(UPLOAD_DIR, exist_ok=True)
safe_name = generate_safe_filename(file.filename or "avatar", detected)
save_path = os.path.join(UPLOAD_DIR, safe_name)
with open(save_path, "wb") as f:
f.write(content)
# 5. 把檔案路徑存進使用者模型
user.avatar_url = f"/static/avatars/{safe_name}"
session.add(user)
session.commit()
return {"avatar_url": user.avatar_url, "size": total}
這支端點把前面講的所有原則都實作到位:先看 Content-Type 做初步過濾(不必每個位元組都檢查,省資源);分塊讀取並檢查大小;用 magic bytes 驗證真實型別(這才是真正的把關);隨機檔名寫入磁碟;最後把對外的 URL 存進使用者模型。前端拿到 avatar_url 直接放在 <img src=""> 就能顯示。
注意我們需要在 User 模型加 avatar_url 欄位,並跑一支 Alembic 遷移把欄位加到資料庫(昨天的搬遷流程):
# app/models.py(新增欄位)
class User(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
email: str = Field(index=True, unique=True)
password_hash: str
is_active: bool = Field(default=True)
role: UserRole = Field(default=UserRole.USER)
created_at: datetime = Field(default_factory=datetime.utcnow)
# 新增:使用者頭像的對外 URL
avatar_url: Optional[str] = Field(default=None)
然後跑遷移:
alembic revision --autogenerate -m "add user avatar_url"
alembic upgrade head
用 StaticFiles 提供靜態檔案
檔案寫到磁碟後,前端怎麼存取?最簡單的做法是用 FastAPI 內建的 StaticFiles:
# app/main.py(掛上 StaticFiles)
import os
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from app.exception_handlers import register_exception_handlers
from app.routers import admin, auth, files, heroes, users
app = FastAPI(title="Web Day 14 範例", version="0.1.0")
register_exception_handlers(app)
# 確保上傳目錄存在
os.makedirs("./uploads/avatars", exist_ok=True)
# 把 /static/avatars/ 路徑對應到 ./uploads/avatars/ 目錄
app.mount(
"/static/avatars",
StaticFiles(directory="./uploads/avatars"),
name="avatars",
)
# 註冊路由
app.include_router(auth.router, prefix="/api")
app.include_router(users.router, prefix="/api")
app.include_router(heroes.router, prefix="/api")
app.include_router(admin.router, prefix="/api")
app.include_router(files.router, prefix="/api")
app.mount(...) 把 /static/avatars/abc123.jpg 對應到磁碟上的 ./uploads/avatars/abc123.jpg。FastAPI 會自動處理 HTTP range requests(瀏覽器下載大檔案時分段請求)、ETag(快取驗證)、Content-Type 推斷。實務上你會想讓 Nginx 或 CDN 在前面代理這個路徑,本機 FastAPI 直接服務靜態檔案的效率在生產環境可能不夠。
注意我們把 static 路徑掛在 /static/avatars 而非 /:這樣可以避免「使用者上傳的 admin.html 覆寫了你的管理後台路徑」。把每種資源放在獨立的命名空間(/static/avatars/、/static/attachments/)是業界常見做法。
驗證上傳與顯示
啟動服務(命令列):
uvicorn app.main:app --reload --port 8000
用 httpx 模擬完整流程(這裡假設你有一個本地檔案 test.png,可以用任何 100×100 像素的 PNG):
# test_upload.py
import httpx
BASE = "http://127.0.0.1:8000/api"
with httpx.Client(base_url=BASE, timeout=10.0) as client:
# 登入取得 token
r = client.post(
"/auth/login",
json={"email": "alice@example.com", "password": "AliceP@ss-2025"},
)
token = r.json()["access_token"]
headers = {"Authorization": f"Bearer {token}"}
# 上傳一張測試 PNG(這裡示範最小可用的 PNG)
# 真實測試時請用實際圖檔
png_bytes = (
b"\x89PNG\r\n\x1a\n" # PNG magic
+ b"\x00" * 100 # 假裝是 PNG 內容
)
files = {"file": ("avatar.png", png_bytes, "image/png")}
r = client.post("/files/avatar", files=files, headers=headers)
print(f"上傳:{r.status_code}", r.json())
# 輸出(範例):
# 200 {'avatar_url': '/static/avatars/xyz123.jpg', 'size': 108}
# 嘗試存取剛剛上傳的 URL
avatar_url = r.json()["avatar_url"]
full_url = f"http://127.0.0.1:8000{avatar_url}"
r = client.get(full_url)
print(f"存取頭像:{r.status_code}, content-type={r.headers.get('content-type')}")
# 輸出(範例):200, content-type=image/jpeg
# 嘗試上傳偽裝的圖片(magic bytes 對不上)
fake_bytes = b"MZ\x00\x00" + b"\x00" * 100 # 看起來像 PE 執行檔
files = {"file": ("evil.jpg", fake_bytes, "image/jpeg")}
r = client.post("/files/avatar", files=files, headers=headers)
print(f"偽裝上傳:{r.status_code}", r.json())
# 輸出(範例):
# 422 {'error': {'code': 'VALIDATION_FAILED', ...}}
三個情境:合法上傳、存取頭像 URL、偽裝型別拒絕。前兩個驗證 happy path 與 static 服務,第三個驗證資安把關。實務上你還會想加上「檔案總量限制」:每位使用者最多只能上傳 N 張頭像、超過就刪除最舊的,避免被當作免費圖床濫用。這套機制可以用一個簡單的 SQL 查詢 + cron job 實作,或者在每次上傳時直接檢查並刪除。我們今天不做這層,但記得在正式部署時補上。
常見錯誤與踩雷
第一個最常見的踩雷是「UploadFile.read() 不帶參數一次讀全部」。很多人寫成 content = await file.read(),這樣使用者送 10 GB 檔案時,伺服器會嘗試把它整個讀進記憶體。我們今天用分塊讀取 read(64 * 1024) 並即時檢查大小,避免這種 DoS 風險。
第二個是「把使用者送的 filename 當成最終檔名」。即使加了路徑穿越檢查,仍可能有 Unicode 變形攻擊(例如全形斜線、null byte 注入)。最安全的做法是完全不信任使用者送的檔名,自己產生隨機字串。檔案的「副檔名」也只從我們自己的對照表拿,不從 os.path.splitext(filename)[1] 推斷。
第三個是「忘記設定上傳目錄的權限」。把 ./uploads/ 設成 755、檔案設成 644 是基本要求。如果整個專案目錄都被 Web 伺服器可寫,攻擊者塞進來的 PHP 或 Python 檔案可能會被執行。在 Docker 環境下,可以把 ./uploads/ 放在獨立的 volume,避免污染應用程式碼目錄。
第四個是「忘記處理 Content-Disposition 與中文檔名」。瀏覽器下載時如果檔名是中文,可能出現亂碼。我們今天的隨機檔名都是 ASCII,不會遇到這問題;但若未來要支援使用者下載自己上傳的檔案(保留原始檔名),記得用 Content-Disposition: attachment; filename*=UTF-8''... 這種 RFC 5987 編碼。
效能與實務提醒
檔案上傳的效能瓶頸通常在「寫入磁碟」這一步。SSD 隨機寫入的 IOPS 通常是幾萬到幾十萬,對中小型應用(每秒數百次上傳)綽綽有餘。但如果你要做「影音平台」這類高頻大檔案場景,本機磁碟就不夠了,要改用 S3 之類的物件儲存。FastAPI 對 S3 的整合很直接:用 aioboto3(非同步 S3 client)呼叫 put_object,整個上傳變成「前端 → FastAPI → S3」的串流,磁碟完全不沾。本機 SSD 在連續寫入時可以到每秒 500 MB 以上,但隨機小檔案(頭像、文件)的 IOPS 會掉到幾千次/秒,這對一般網站足夠,但遇到「使用者批量匯入照片」這種場景就要排隊。SSD 容量擴充成本也高,一台 4 TB 的 NVMe SSD 價格遠高於 S3 1 TB 月租。
另一個效能議題是「StaticFiles 不適合服務大型檔案」。它每次都把整個檔案讀進記憶體再回應,對於 100 MB 以上的影片或 PDF 會吃光 RAM。生產環境應該讓 Nginx 或 CDN 直接服務 /static/ 路徑,FastAPI 只負責 API 邏輯。Day 33「反向代理」會示範這個架構。
最後是「CDN 與快取」。如果你用 S3,可以直接綁 CloudFront 或 Cloudflare,讓靜態檔案走 CDN,使用者從最近的節點下載,速度比從你的伺服器拉快非常多。我們今天的範例用本機磁碟,後續專案篇會在 Day 41 改用 S3 + CloudFront。CDN 的另一個好處是「邊緣快取」:同一張頭像可能被數百位使用者請求,CDN 會在邊緣節點保留一份,後續請求不必回源,大幅降低你的伺服器負擔。在 Cloudflare 設定「標準快取」可以讓靜態資源的快取命中率提升到 90% 以上,這對成本與速度都是顯著改善。
小結
今天把後端從「純文字」延伸到「檔案」。我們用 FastAPI 的 UploadFile 處理上傳、用 magic bytes 驗證真實型別、用隨機檔名避免攻擊、用 StaticFiles 對外提供靜態 URL。所有資安原則(不信任使用者檔名、不信任 Content-Type、限制大小、不讓上傳目錄可執行)都實作到位,並把檔案路徑存進 User 模型讓前端統一存取。整個系統接上了昨天的 JWT 認證(必須登入才能上傳)與昨天的 Alembic 遷移(新增 avatar_url 欄位)。明天進入安全主題的最後一篇:輸入防護(CORS、SQL 注入、XSS),把後端對外來資料的最後一道防線建立起來。
結語
今天把檔案處理的基礎打好了。明天進入安全主題的最後一篇「輸入防護」,把後端對「使用者輸入」的整體防護做一次整合。你會學到 CORS 為什麼對純 API 不太需要、但在前後端分離架構下要做什麼設定;SQL 注入在我們用 SQLModel 與 ORM 的前提下其實不會發生,但要怎麼證明這件事;XSS 主要靠前端 escape,但後端能做哪些事降低風險;以及 FastAPI 內建的 CORSMiddleware 怎麼設定。明天收尾之後,安全主題的四篇(密碼、JWT、角色、輸入防護)就完整了,接下來進入品質主題的測試篇。
延伸資源
- FastAPI 官方文件:Request Files(0.116,2025):
https://fastapi.tiangolo.com/tutorial/request-files/ - FastAPI 官方文件:Static Files(0.116,2025):
https://fastapi.tiangolo.com/tutorial/static-files/ - OWASP 檔案上傳 cheat sheet(2025):
https://cheatsheetseries.owasp.org/cheatsheets/File_Upload_Cheat_Sheet.html - Magic bytes 對照表(Wikipedia,2025):
https://en.wikipedia.org/wiki/List_of_file_signatures
留言
張貼留言