Files
7z-encrypt-server/api.py
T

180 lines
6.7 KiB
Python

"""
API 层 (api)
模块: 服务端 / API 层
输入: HTTP 请求 (带认证)
输出: 响应 / 路由到各模块
零知识 + 零合并设计: 服务端只存卷/传卷, 不合并不解密, 不持有密钥。
端点:
POST /api/transfer/init {init json} -> {transfer_id}
GET /api/transfer/{id}/chunks -> {received: [n...]}
PUT /api/transfer/{id}/chunk/{n} 卷密文 -> {ok} / 409
POST /api/transfer/{id}/complete -> {status, file_id} / 409 (秒回, 不合并)
GET /api/files -> {files: [...]}
GET /api/files/{id}/chunk/{n} -> 单卷密文 (头 X-Enc-Params / X-Chunk-Count)
全部端点需 Authorization: Bearer <token> (与客户端预共享)
"""
import base64
import json
import shutil
from pathlib import Path
from fastapi import Depends, FastAPI, Header, HTTPException, Request
from fastapi.responses import FileResponse, JSONResponse
from db import ServerDB
from receiver import Receiver, ReceiverError
from settings import QUOTA_BYTES, STORAGE_ROOT, TMP_ROOT, TOKEN
from storage import Storage
from task_manager import TaskManager
# ---------- 依赖装配 (单例) ----------
db = ServerDB()
tasks = TaskManager(db)
receiver = Receiver(tasks, TMP_ROOT)
storage = Storage(tasks, STORAGE_ROOT)
app = FastAPI(title="7z-encrypt 服务端 (零知识 + 零合并)")
# ---------- 认证 ----------
def verify_token(authorization: str | None = Header(default=None)) -> None:
"""Bearer token 校验, 失败 401"""
if authorization != f"Bearer {TOKEN}":
raise HTTPException(status_code=401, detail="未授权: token 无效或缺失")
def _enc_headers(rec: dict) -> dict[str, str]:
"""下载响应头: 解密参数 + 卷数"""
ep = json.dumps(rec["enc_params"], ensure_ascii=False)
return {
"X-Enc-Params": base64.b64encode(ep.encode()).decode(),
"X-Chunk-Count": str(rec["chunk_count"]),
}
# ---------- 文件: ls / 逐卷下载 ----------
@app.get("/api/files", dependencies=[Depends(verify_token)])
async def list_files():
"""文件列表 (ls)"""
return {"files": db.list_files()}
@app.get("/api/quota", dependencies=[Depends(verify_token)])
async def quota():
"""用户空间配额: 已用 (files size 总和) / 配额 / 剩余"""
used = db.sum_files_size()
remain = max(QUOTA_BYTES - used, 0)
return {
"used_bytes": used,
"quota_bytes": QUOTA_BYTES,
"remain_bytes": remain,
"percent": round(used * 100 / QUOTA_BYTES, 1) if QUOTA_BYTES else 0,
}
@app.delete("/api/files/{file_id}", dependencies=[Depends(verify_token)])
async def delete_file(file_id: str):
"""删除文件 (卷目录 + 入库记录)"""
rec = db.get_file(file_id)
if rec is None:
return JSONResponse(status_code=404, content={"error": "文件不存在"})
try:
p = Path(rec["path"])
if p.is_dir():
shutil.rmtree(p, ignore_errors=True)
elif p.is_file():
p.unlink(missing_ok=True)
db.delete_file(file_id)
except OSError as e:
return JSONResponse(status_code=409, content={"error": f"删除失败: {e}"})
return {"ok": True, "file_id": file_id}
@app.get("/api/files/{file_id}/chunk/{idx}", dependencies=[Depends(verify_token)])
async def download_chunk(file_id: str, idx: int):
"""下载单卷密文 (零合并: 服务端不拼接, 客户端逐卷拉取本地合并)"""
rec = db.get_file(file_id)
if rec is None:
return JSONResponse(status_code=404, content={"error": "文件不存在"})
if not 1 <= idx <= rec["chunk_count"]:
return JSONResponse(status_code=404, content={"error": f"卷号越界: {idx}"})
chunk_path = Path(rec["path"]) / f"chunk_{idx:04d}"
if not chunk_path.exists():
return JSONResponse(status_code=404, content={"error": f"卷文件缺失: {chunk_path}"})
return FileResponse(
chunk_path,
filename=rec["file_name"],
media_type="application/octet-stream",
headers=_enc_headers(rec),
)
# ---------- 传输 ----------
@app.post("/api/transfer/init", dependencies=[Depends(verify_token)])
async def init_transfer(request: Request):
try:
init_json = await request.json()
transfer_id = tasks.create(init_json)
except (KeyError, TypeError, ValueError) as e:
return JSONResponse(status_code=400, content={"error": f"init json 非法: {e}"})
return {"transfer_id": transfer_id}
@app.get("/api/transfer/{transfer_id}/chunks", dependencies=[Depends(verify_token)])
async def get_chunks(transfer_id: str):
if tasks.get(transfer_id) is None:
return JSONResponse(status_code=404, content={"error": "任务不存在"})
return {"received": sorted(tasks.received(transfer_id))}
@app.put("/api/transfer/{transfer_id}/chunk/{idx}", dependencies=[Depends(verify_token)])
async def put_chunk(transfer_id: str, idx: int, request: Request):
if tasks.get(transfer_id) is None:
return JSONResponse(status_code=404, content={"error": "任务不存在"})
data = await request.body()
try:
receiver.receive(transfer_id, idx, data)
except ReceiverError as e:
return JSONResponse(status_code=409, content={"error": str(e)})
return {"ok": True}
@app.post("/api/transfer/{transfer_id}/complete", dependencies=[Depends(verify_token)])
async def complete(transfer_id: str):
if tasks.get(transfer_id) is None:
return JSONResponse(status_code=404, content={"error": "任务不存在"})
# 幂等: 已完成的任务直接返回已有 file_id
done_rec = db.get_file_by_transfer(transfer_id)
if done_rec is not None:
return {"status": "done", "file_id": done_rec["file_id"]}
try:
transfer = tasks.get(transfer_id)
assert transfer is not None
# 零合并: 只校验卷齐, 卷目录直接 move 入存储区, 秒回
got = tasks.received(transfer_id)
if len(got) != transfer["chunk_count"]:
return JSONResponse(status_code=409, content={
"status": "incomplete",
"received": sorted(got),
"error": f"缺卷: {transfer['chunk_count'] - len(got)} 卷未上传",
})
tasks.set_status(transfer_id, "storing")
final_dir = storage.store_chunks(transfer_id, TMP_ROOT / transfer_id)
file_id = db.insert_file(
transfer_id,
transfer["file_name"],
str(final_dir),
transfer["file_size"],
transfer["total_sha256"],
)
tasks.set_status(transfer_id, "done")
return {"status": "done", "file_id": file_id}
except OSError as e:
tasks.set_status(transfer_id, "failed")
return JSONResponse(status_code=409, content={"error": str(e), "status": "failed"})