From b54ceff639bf2db3bf0e0e2ae34ba90259e91a5c Mon Sep 17 00:00:00 2001 From: lou Date: Mon, 10 Aug 2026 22:04:20 +0800 Subject: [PATCH] =?UTF-8?q?=E7=9B=AE=E5=BD=95=E6=89=A9=E5=B1=95:=20init=20?= =?UTF-8?q?=E6=8E=A5=20dir=5Fid=20(=E5=BD=92=E5=B1=9E=E6=A0=A1=E9=AA=8C),?= =?UTF-8?q?=20files=20=E6=8C=82=E7=9B=AE=E5=BD=95,=20PUT=20/move=20+=20POS?= =?UTF-8?q?T=20/clone=20(=E7=89=A9=E7=90=86=E5=A4=8D=E5=88=B6=E5=8D=B7),?= =?UTF-8?q?=20list=20=E5=B8=A6=20dir=5Fname?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api.py | 61 ++++++++++++++++++++++++++++++++++++++++++++++++- db.py | 61 ++++++++++++++++++++++++++++++++++++++++--------- task_manager.py | 5 ++-- 3 files changed, 113 insertions(+), 14 deletions(-) diff --git a/api.py b/api.py index 96e0a61..9e75fb9 100644 --- a/api.py +++ b/api.py @@ -216,6 +216,59 @@ async def delete_file(file_id: str, request: Request, username: str = Depends(ve return {"ok": True, "file_id": file_id} +def _resolve_dir(dir_id: int | None, username: str) -> int | None: + """目录归属校验: 0/None=根目录, 否则必须属于该用户 (跨账户 403)""" + if dir_id in (None, 0): + return None + d = db.get_dir(dir_id) + if d is None or d["user_id"] != username: + raise HTTPException(status_code=403, detail="目录不存在或无权使用") + return dir_id + + +@app.put("/api/files/{file_id}/move") +async def move_file(file_id: str, request: Request, username: str = Depends(verify_token)): + """移动文件到目录 (body {dir_id}; 0/缺省=根目录)""" + rec = db.get_file(file_id) + if rec is None: + return JSONResponse(status_code=404, content={"error": "文件不存在"}) + _check_owner(rec, username) + body = await request.json() + try: + dir_id = _resolve_dir(body.get("dir_id"), username) + except HTTPException as e: + return JSONResponse(status_code=e.status_code, content={"error": e.detail}) + db.move_file(file_id, dir_id) + log.info("move %s %s -> dir=%s", request.client.host if request.client else "?", file_id, dir_id) + return {"ok": True, "file_id": file_id, "dir_id": dir_id} + + +@app.post("/api/files/{file_id}/clone") +async def clone_file(file_id: str, request: Request, username: str = Depends(verify_token)): + """克隆文件 (物理复制卷目录 + 新记录), body {dir_id} 可选""" + rec = db.get_file(file_id) + if rec is None: + return JSONResponse(status_code=404, content={"error": "文件不存在"}) + _check_owner(rec, username) + body = await request.json() + try: + dir_id = _resolve_dir(body.get("dir_id"), username) + except HTTPException as e: + return JSONResponse(status_code=e.status_code, content={"error": e.detail}) + src_dir = Path(rec["path"]) + new_dir = src_dir.with_name(src_dir.name + "_clone") + try: + shutil.copytree(src_dir, new_dir) + except OSError as e: + return JSONResponse(status_code=409, content={"error": f"克隆失败: {e}"}) + new_id = db.clone_file(file_id, dir_id, str(new_dir)) + if not new_id: + shutil.rmtree(new_dir, ignore_errors=True) + return JSONResponse(status_code=404, content={"error": "源文件记录丢失"}) + log.info("clone %s %s -> %s", request.client.host if request.client else "?", file_id, new_id) + return {"ok": True, "file_id": new_id} + + @app.get("/api/files/{file_id}/chunk/{idx}") async def download_chunk(file_id: str, idx: int, request: Request, username: str = Depends(verify_token)): """下载单卷密文 (零合并: 服务端不拼接, 客户端逐卷拉取本地合并)""" @@ -247,7 +300,12 @@ async def init_transfer(request: Request, username: str = Depends(verify_token)) return JSONResponse(status_code=413, content={"error": "init json 过大"}) try: init_json = await request.json() - transfer_id = tasks.create(init_json, username) + dir_id = init_json.pop("dir_id", None) + if dir_id is not None: + d = db.get_dir(int(dir_id)) + if d is None or d["user_id"] != username: + return JSONResponse(status_code=403, content={"error": "目录不存在或无权使用"}) + transfer_id = tasks.create(init_json, username, dir_id) log.info("init %s %s卷 任务=%s", request.client.host if request.client else "?", init_json.get("chunk_count"), transfer_id) except (KeyError, TypeError, ValueError) as e: @@ -382,6 +440,7 @@ async def complete(transfer_id: str, request: Request, username: str = Depends(v transfer["file_size"], transfer["total_sha256"], username, + transfer.get("dir_id"), ) tasks.set_status(transfer_id, "done") log.info("complete %s 任务=%s %s卷 -> file=%s", diff --git a/db.py b/db.py index a608b3d..3fcb2ce 100644 --- a/db.py +++ b/db.py @@ -112,6 +112,14 @@ class ServerDB: conn.execute("ALTER TABLE files ADD COLUMN user_id TEXT") except sqlite3.OperationalError: pass # 列已存在 + try: + conn.execute("ALTER TABLE transfers ADD COLUMN dir_id INTEGER") + except sqlite3.OperationalError: + pass # 列已存在 + try: + conn.execute("ALTER TABLE files ADD COLUMN dir_id INTEGER") + except sqlite3.OperationalError: + pass # 列已存在 # ---------- users ---------- @@ -199,11 +207,12 @@ class ServerDB: # ---------- transfers ---------- - def create_transfer(self, transfer_id: str, init_json: dict[str, Any], user_id: str) -> None: + def create_transfer(self, transfer_id: str, init_json: dict[str, Any], user_id: str, + dir_id: int | None = None) -> None: with self._conn() as conn: conn.execute( - "INSERT INTO transfers (transfer_id, file_name, file_size, chunk_count, total_sha256, enc_params, user_id, status) " - "VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + "INSERT INTO transfers (transfer_id, file_name, file_size, chunk_count, total_sha256, enc_params, user_id, dir_id, status) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", ( transfer_id, init_json["file_name"], @@ -212,6 +221,7 @@ class ServerDB: init_json["total_sha256"], json.dumps(init_json["enc"], ensure_ascii=False), user_id, + dir_id, "uploading", ), ) @@ -286,13 +296,14 @@ class ServerDB: size: int, sha256: str, user_id: str, + dir_id: int | None = None, ) -> str: file_id = uuid.uuid4().hex[:12] with self._conn() as conn: conn.execute( - "INSERT INTO files (file_id, transfer_id, file_name, path, size, sha256, user_id) " - "VALUES (?, ?, ?, ?, ?, ?, ?)", - (file_id, transfer_id, file_name, path, size, sha256, user_id), + "INSERT INTO files (file_id, transfer_id, file_name, path, size, sha256, user_id, dir_id) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + (file_id, transfer_id, file_name, path, size, sha256, user_id, dir_id), ) return file_id @@ -306,22 +317,50 @@ class ServerDB: return dict(row) if row else None def list_files(self, user_id: str) -> list[dict[str, Any]]: - """文件记录 (ls 端点): 每个账户只看自己的空间 (admin 含无主旧文件)""" + """文件记录 (ls 端点): 每个账户只看自己的空间 (admin 含无主旧文件), 带目录名""" with self._conn() as conn: if user_id == "admin": rows = conn.execute( - "SELECT file_id, file_name, size, sha256, created_at FROM files " - "WHERE user_id = ? OR user_id IS NULL ORDER BY created_at DESC", + "SELECT f.file_id, f.file_name, f.size, f.sha256, f.created_at, f.dir_id, d.name AS dir_name " + "FROM files f LEFT JOIN dirs d ON d.id = f.dir_id " + "WHERE f.user_id = ? OR f.user_id IS NULL ORDER BY f.created_at DESC", ("admin",), ).fetchall() else: rows = conn.execute( - "SELECT file_id, file_name, size, sha256, created_at FROM files " - "WHERE user_id = ? ORDER BY created_at DESC", + "SELECT f.file_id, f.file_name, f.size, f.sha256, f.created_at, f.dir_id, d.name AS dir_name " + "FROM files f LEFT JOIN dirs d ON d.id = f.dir_id " + "WHERE f.user_id = ? ORDER BY f.created_at DESC", (user_id,), ).fetchall() return [dict(r) for r in rows] + def move_file(self, file_id: str, dir_id: int | None) -> bool: + """移动文件到目录 (dir_id=None=根目录), 返回是否更新""" + with self._conn() as conn: + cur = conn.execute( + "UPDATE files SET dir_id = ? WHERE file_id = ?", (dir_id, file_id) + ) + return cur.rowcount > 0 + + def clone_file(self, file_id: str, dir_id: int | None, new_path: str) -> str: + """克隆文件记录 (卷目录已物理复制到 new_path), 返回新 file_id""" + new_id = uuid.uuid4().hex[:12] + with self._conn() as conn: + row = conn.execute( + "SELECT transfer_id, file_name, size, sha256, user_id FROM files WHERE file_id = ?", + (file_id,), + ).fetchone() + if row is None: + return "" + conn.execute( + "INSERT INTO files (file_id, transfer_id, file_name, path, size, sha256, user_id, dir_id) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + (new_id, row["transfer_id"], row["file_name"], new_path, + row["size"], row["sha256"], row["user_id"], dir_id), + ) + return new_id + def delete_file(self, file_id: str) -> bool: """删除文件记录, 返回是否删除 (del 端点)""" with self._conn() as conn: diff --git a/task_manager.py b/task_manager.py index 39fb64a..75a6611 100644 --- a/task_manager.py +++ b/task_manager.py @@ -26,7 +26,8 @@ class TaskManager: def __init__(self, db: ServerDB) -> None: self.db = db - def create(self, init_json: dict[str, Any], user_id: str) -> str: + def create(self, init_json: dict[str, Any], user_id: str, + dir_id: int | None = None) -> str: """POST /init: 建任务, 返回 transfer_id (输入清洗: 文件名/卷数上限) 文件名只做可打印字符过滤 + 长度截断。不删 / \\ 等路径分隔符: @@ -42,7 +43,7 @@ class TaskManager: raise ValueError(f"卷数超上限: {len(chunks)} > 10000") init_json["chunk_count"] = len(chunks) transfer_id = uuid.uuid4().hex[:12] - self.db.create_transfer(transfer_id, init_json, user_id) + self.db.create_transfer(transfer_id, init_json, user_id, dir_id) return transfer_id def get(self, transfer_id: str) -> dict[str, Any] | None: