📚 SMB/NAS 檔案系統系列
- SMB 協定入門:用 Python 連接公司 NAS
- 檔案管理 API:FastAPI 實作上傳下載刪除 ← 目前閱讀
📖 前置知識:Linux 終端機入門
這篇文章要解決什麼問題?
上一篇我們用 smbprotocol 實作了 NAS 檔案操作,但那是 Python 程式碼。前端 JavaScript 怎麼呼叫?
這篇我們要把 SMB 操作包裝成 RESTful API,讓前端可以:
- 瀏覽資料夾:
GET /api/nas/browse?path=/home/文件 - 下載檔案:
GET /api/nas/download?path=/home/文件/報告.pdf - 上傳檔案:
POST /api/nas/upload - 刪除檔案:
DELETE /api/nas/file - 重命名:
PATCH /api/nas/rename - 建立資料夾:
POST /api/nas/mkdir
業務:「我在客戶那邊,急需一份報價單,但我的 Mac 連不上公司 NAS!」
倉管:「我用平板盤點,想直接上傳照片到 NAS,但平板沒辦法設定網路磁碟機…」
老闆:「怎麼什麼裝置都有問題?」
後端工程師:「因為 SMB 協定對不同裝置支援度不一樣。我們做成 Web API,不管 Mac、平板還是手機,開瀏覽器就能存取。」
業務:「太好了,這樣我在外面也能即時調檔案給客戶!」
技術概念
路徑設計
所有 API 的路徑採用統一格式:
/共享名稱/子資料夾/檔案名稱
│ │ └── 檔案
│ └── 子路徑(可多層)
└── SMB 共享名稱
範例:
/home/文件/報告.pdf
/共用區/2024/Q4/財報.xlsx
內部解析成兩部分:
share_name:homesub_path:文件/報告.pdf
認證流程
前端請求
│
│ Authorization: Bearer <token>
▼
FastAPI → 驗證 token → 從 Session 取得 NAS 帳密
│
│ username, password
▼
SMBService → 連接 NAS → 執行操作
密碼不存資料庫,只存在 Session 記憶體中,8 小時後自動清除。
跟著做:Step by Step
Step 1:定義 Pydantic Models
# models/nas.py
from pydantic import BaseModel
class ShareInfo(BaseModel):
"""共享資訊"""
name: str
type: str # "disk"
class SharesResponse(BaseModel):
"""列出共享回應"""
shares: list[ShareInfo]
class FileItem(BaseModel):
"""檔案/資料夾項目"""
name: str
type: str # "file" or "directory"
size: int | None = None
modified: str | None = None
class BrowseResponse(BaseModel):
"""瀏覽資料夾回應"""
path: str
items: list[FileItem]
class DeleteRequest(BaseModel):
"""刪除請求"""
path: str
recursive: bool = False # 是否遞迴刪除
class RenameRequest(BaseModel):
"""重命名請求"""
path: str
new_name: str
class MkdirRequest(BaseModel):
"""建立資料夾請求"""
path: str
class OperationResponse(BaseModel):
"""操作回應"""
success: bool
message: str
Step 2:路徑解析工具函數
# api/nas.py
from fastapi import HTTPException, status
def _parse_path(path: str) -> tuple[str, str]:
"""解析路徑為 (share_name, sub_path)
Args:
path: 完整路徑,如 "/home/文件/報告.pdf"
Returns:
(share_name, sub_path) = ("home", "文件/報告.pdf")
"""
path = path.strip("/")
if not path:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="請指定檔案路徑",
)
parts = path.split("/", 1)
share_name = parts[0]
sub_path = parts[1] if len(parts) > 1 else ""
return share_name, sub_path
Step 3:列出共享
from fastapi import APIRouter, Depends
from services.smb import create_smb_service, SMBError, SMBConnectionError
router = APIRouter(prefix="/api/nas", tags=["nas"])
@router.get("/shares", response_model=SharesResponse)
async def list_shares(
session: SessionData = Depends(get_current_session),
) -> SharesResponse:
"""列出 NAS 上的共享資料夾"""
smb = create_smb_service(
username=session.username,
password=session.password,
)
try:
with smb:
shares = smb.list_shares()
return SharesResponse(
shares=[ShareInfo(name=s["name"], type=s["type"]) for s in shares]
)
except SMBConnectionError:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="無法連線至檔案伺服器",
)
except SMBError as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=str(e),
)
Step 4:瀏覽資料夾
@router.get("/browse", response_model=BrowseResponse)
async def browse_directory(
path: str = "/",
session: SessionData = Depends(get_current_session),
) -> BrowseResponse:
"""瀏覽指定資料夾內容
Args:
path: 資料夾路徑,格式為 /share_name/folder/subfolder
"""
share_name, sub_path = _parse_path(path)
smb = create_smb_service(
username=session.username,
password=session.password,
)
try:
with smb:
items = smb.browse_directory(share_name, sub_path)
return BrowseResponse(
path=f"/{path.strip('/')}",
items=[
FileItem(
name=item["name"],
type=item["type"],
size=item.get("size"),
modified=item.get("modified"),
)
for item in items
],
)
except SMBError as e:
error_msg = str(e)
if "權限" in error_msg:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="無權限存取此資料夾",
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=str(e),
)
Step 5:讀取檔案(預覽用)
import mimetypes
from fastapi.responses import Response
def _get_mime_type(filename: str) -> str:
"""根據檔名取得 MIME 類型"""
mime_type, _ = mimetypes.guess_type(filename)
return mime_type or "application/octet-stream"
@router.get("/file")
async def read_file(
path: str,
session: SessionData = Depends(get_current_session),
) -> Response:
"""讀取檔案內容(用於預覽)
回傳檔案內容,瀏覽器會根據 Content-Type 顯示或下載。
"""
share_name, sub_path = _parse_path(path)
if not sub_path:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="請指定檔案路徑",
)
smb = create_smb_service(
username=session.username,
password=session.password,
)
try:
with smb:
content = smb.read_file(share_name, sub_path)
mime_type = _get_mime_type(sub_path)
return Response(
content=content,
media_type=mime_type,
)
except SMBError as e:
error_msg = str(e)
if "不存在" in error_msg:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="檔案不存在",
)
if "權限" in error_msg:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="無權限讀取此檔案",
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=str(e),
)
Step 6:下載檔案
from urllib.parse import quote
@router.get("/download")
async def download_file(
path: str,
session: SessionData = Depends(get_current_session),
) -> Response:
"""下載檔案
與 read_file 的差別是加了 Content-Disposition header,
瀏覽器會彈出下載對話框而非直接顯示。
"""
share_name, sub_path = _parse_path(path)
if not sub_path:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="請指定檔案路徑",
)
# 取得檔名(最後一段)
filename = sub_path.split("/")[-1]
smb = create_smb_service(
username=session.username,
password=session.password,
)
try:
with smb:
content = smb.read_file(share_name, sub_path)
mime_type = _get_mime_type(filename)
# 處理中文檔名編碼
encoded_filename = quote(filename)
return Response(
content=content,
media_type=mime_type,
headers={
# RFC 5987 格式,支援中文檔名
"Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}",
},
)
except SMBError as e:
# ... 錯誤處理同上 ...
Step 7:上傳檔案
from typing import Annotated
from fastapi import File, Form, UploadFile
@router.post("/upload", response_model=OperationResponse)
async def upload_file(
path: Annotated[str, Form(description="目標資料夾路徑")],
file: UploadFile = File(...),
session: SessionData = Depends(get_current_session),
) -> OperationResponse:
"""上傳檔案
使用 multipart/form-data 格式。
Args:
path: 目標資料夾路徑(不含檔名)
file: 上傳的檔案
"""
share_name, sub_path = _parse_path(path)
# 組合完整檔案路徑
filename = file.filename or "unnamed"
file_path = f"{sub_path}/{filename}" if sub_path else filename
smb = create_smb_service(
username=session.username,
password=session.password,
)
try:
# 讀取上傳的檔案內容
content = await file.read()
with smb:
smb.write_file(share_name, file_path, content)
return OperationResponse(success=True, message="上傳成功")
except SMBError as e:
error_msg = str(e)
if "權限" in error_msg:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="無權限上傳檔案",
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=str(e),
)
Step 8:刪除檔案/資料夾
@router.delete("/file", response_model=OperationResponse)
async def delete_file(
request: DeleteRequest,
session: SessionData = Depends(get_current_session),
) -> OperationResponse:
"""刪除檔案或資料夾
Args:
request.path: 要刪除的路徑
request.recursive: 是否遞迴刪除(非空資料夾需要)
"""
share_name, sub_path = _parse_path(request.path)
if not sub_path:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="無法刪除共享根目錄",
)
smb = create_smb_service(
username=session.username,
password=session.password,
)
try:
with smb:
smb.delete_item(share_name, sub_path, recursive=request.recursive)
return OperationResponse(success=True, message="刪除成功")
except SMBError as e:
error_msg = str(e)
if "不存在" in error_msg:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="檔案或資料夾不存在",
)
if "不是空的" in error_msg:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="資料夾不是空的,請使用 recursive=true",
)
if "權限" in error_msg:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="無權限刪除此項目",
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=str(e),
)
Step 9:重命名和建立資料夾
@router.patch("/rename", response_model=OperationResponse)
async def rename_item(
request: RenameRequest,
session: SessionData = Depends(get_current_session),
) -> OperationResponse:
"""重命名檔案或資料夾"""
share_name, sub_path = _parse_path(request.path)
if not sub_path:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="無法重命名共享根目錄",
)
smb = create_smb_service(
username=session.username,
password=session.password,
)
try:
with smb:
smb.rename_item(share_name, sub_path, request.new_name)
return OperationResponse(success=True, message="重命名成功")
except SMBError as e:
if "已存在" in str(e):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="目標名稱已存在",
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=str(e),
)
@router.post("/mkdir", response_model=OperationResponse)
async def create_directory(
request: MkdirRequest,
session: SessionData = Depends(get_current_session),
) -> OperationResponse:
"""建立資料夾"""
share_name, sub_path = _parse_path(request.path)
if not sub_path:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="請指定資料夾名稱",
)
smb = create_smb_service(
username=session.username,
password=session.password,
)
try:
with smb:
smb.create_directory(share_name, sub_path)
return OperationResponse(success=True, message="建立成功")
except SMBError as e:
if "已存在" in str(e):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="資料夾已存在",
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=str(e),
)
進階技巧與踩坑紀錄
1. Query Parameter 認證(用於 img/iframe)
有些場景無法設定 Authorization header,例如:
<!-- img src 無法帶 header -->
<img src="/api/nas/file?path=/home/photo.jpg">
解決方案:允許 query parameter 傳 token:
from fastapi import Query
async def get_session_from_token_or_query(
authorization: str | None = Header(None),
token: str | None = Query(None),
) -> SessionData:
"""從 header 或 query 取得 session"""
# 優先使用 header
if authorization and authorization.startswith("Bearer "):
actual_token = authorization[7:]
elif token:
actual_token = token
else:
raise HTTPException(status_code=401, detail="未授權")
return get_session(actual_token)
@router.get("/file")
async def read_file(
path: str,
session: SessionData = Depends(get_session_from_token_or_query),
):
# ...
前端使用:
<img src="/api/nas/file?path=/home/photo.jpg&token=xxx">
2. 中文檔名處理
下載檔案時,中文檔名要特殊編碼:
from urllib.parse import quote
filename = "報告.pdf"
encoded = quote(filename) # "%E5%A0%B1%E5%91%8A.pdf"
headers = {
# RFC 5987 格式
"Content-Disposition": f"attachment; filename*=UTF-8''{encoded}"
}
3. 大檔案 Streaming
目前實作是一次讀取整個檔案到記憶體,大檔案會有問題。改用 streaming:
from fastapi.responses import StreamingResponse
async def stream_file(share_name: str, path: str, chunk_size: int = 65536):
"""Generator:分段讀取檔案"""
with create_smb_service(...) as smb:
# 取得檔案大小
file_info = smb.get_file_info(share_name, path)
file_size = file_info["size"]
offset = 0
while offset < file_size:
chunk = smb.read_file_chunk(share_name, path, offset, chunk_size)
yield chunk
offset += len(chunk)
@router.get("/download-large")
async def download_large_file(path: str, ...):
share_name, sub_path = _parse_path(path)
return StreamingResponse(
stream_file(share_name, sub_path),
media_type="application/octet-stream",
headers={...}
)
4. 統一錯誤處理
可以用 exception handler 減少重複程式碼:
from fastapi import Request
from fastapi.responses import JSONResponse
@app.exception_handler(SMBError)
async def smb_error_handler(request: Request, exc: SMBError):
error_msg = str(exc)
if "權限" in error_msg:
return JSONResponse(
status_code=403,
content={"detail": "無權限執行此操作"}
)
if "不存在" in error_msg:
return JSONResponse(
status_code=404,
content={"detail": "檔案或資料夾不存在"}
)
if "已存在" in error_msg:
return JSONResponse(
status_code=409,
content={"detail": "目標已存在"}
)
return JSONResponse(
status_code=500,
content={"detail": error_msg}
)
小結
這篇我們完成了:
- 路徑設計:統一的
/share/path/file格式 - 完整 CRUD:列出、瀏覽、讀取、上傳、刪除、重命名、建立
- 認證整合:header 和 query parameter 雙軌支援
- 錯誤處理:權限、不存在、已存在等情況
API 總覽:
| 方法 | 路徑 | 功能 |
|---|---|---|
| GET | /api/nas/shares |
列出共享 |
| GET | /api/nas/browse?path= |
瀏覽資料夾 |
| GET | /api/nas/file?path= |
讀取檔案(預覽) |
| GET | /api/nas/download?path= |
下載檔案 |
| POST | /api/nas/upload |
上傳檔案 |
| DELETE | /api/nas/file |
刪除 |
| PATCH | /api/nas/rename |
重命名 |
| POST | /api/nas/mkdir |
建立資料夾 |
到這裡,SMB/NAS 系列就完成了!你現在有一套完整的檔案管理 API,可以讓 Web 前端操作公司 NAS。
完整程式碼
前端呼叫範例
// 使用 fetch API
const API_BASE = '/api/nas';
const TOKEN = localStorage.getItem('token');
const headers = {
'Authorization': `Bearer ${TOKEN}`,
'Content-Type': 'application/json',
};
// 列出共享
const shares = await fetch(`${API_BASE}/shares`, { headers })
.then(r => r.json());
// 瀏覽資料夾
const items = await fetch(`${API_BASE}/browse?path=/home/文件`, { headers })
.then(r => r.json());
// 下載檔案(開新視窗)
window.open(`${API_BASE}/download?path=/home/報告.pdf&token=${TOKEN}`);
// 上傳檔案
const formData = new FormData();
formData.append('path', '/home/文件');
formData.append('file', fileInput.files[0]);
await fetch(`${API_BASE}/upload`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${TOKEN}` },
body: formData,
});
// 刪除檔案
await fetch(`${API_BASE}/file`, {
method: 'DELETE',
headers,
body: JSON.stringify({ path: '/home/文件/舊檔案.txt' }),
});
// 建立資料夾
await fetch(`${API_BASE}/mkdir`, {
method: 'POST',
headers,
body: JSON.stringify({ path: '/home/文件/新資料夾' }),
});