fix(files): prevent silent upload overwrites
This commit is contained in:
parent
6b37ce141f
commit
1ac9aa1ba0
|
|
@ -5,6 +5,10 @@
|
||||||
> 所以不是每个版本号都有条目。条目格式 `## <版本> — <日期>`,新条目加在最上面。
|
> 所以不是每个版本号都有条目。条目格式 `## <版本> — <日期>`,新条目加在最上面。
|
||||||
> 工程口径的完整记录见 `PROGRESS.md` / git log。
|
> 工程口径的完整记录见 `PROGRESS.md` / git log。
|
||||||
|
|
||||||
|
## 0.64.3 — 2026-08-12
|
||||||
|
|
||||||
|
- 粘贴、拖拽或上传同名文件时不再静默覆盖,系统会自动编号保留两份;需要覆盖时,可在文件菜单选择「替换文件」并确认。
|
||||||
|
|
||||||
## 0.64.1 — 2026-08-12
|
## 0.64.1 — 2026-08-12
|
||||||
|
|
||||||
- PDF 和 PPT 连续预览现在支持鼠标滚轮与手机手势滑动,不再只能拖动右侧滚动条。
|
- PDF 和 PPT 连续预览现在支持鼠标滚轮与手机手势滑动,不再只能拖动右侧滚动条。
|
||||||
|
|
|
||||||
|
|
@ -287,7 +287,7 @@ scheduled_jobs(§8.5) channel_bindings(§8.7,判别列+JSONB)
|
||||||
- **path-as-identity 而非 folder_id**:folder 真实存在于 FS,folder_id 是第二份 source of truth;rename 走 DB-aware 同事务 cascade。
|
- **path-as-identity 而非 folder_id**:folder 真实存在于 FS,folder_id 是第二份 source of truth;rename 走 DB-aware 同事务 cascade。
|
||||||
- **files API 单一 mutation 入口**(2026-05-18):"顶层目录分支"从数据状态派生而非客户端意图,放服务端才有强制力;双命名空间(/folders vs /files)把分支搬给 client,失强制力且端点翻倍。
|
- **files API 单一 mutation 入口**(2026-05-18):"顶层目录分支"从数据状态派生而非客户端意图,放服务端才有强制力;双命名空间(/folders vs /files)把分支搬给 client,失强制力且端点翻倍。
|
||||||
- **task 软删除(2026-06-17 推翻 hard cascade)**:公测后对话轨迹是训练/研究语料,`deleted_at` 置位 + restore,避免用户误删立即永久丢失。**当前实现仍无限期保留软删数据**,物理清理仅有管理员手段;后续生命周期已定为“软删除后保留 30 天再物理清理”(待容量信号实施,见 §8.5),届时恢复能力明确限于宽限期内。
|
- **task 软删除(2026-06-17 推翻 hard cascade)**:公测后对话轨迹是训练/研究语料,`deleted_at` 置位 + restore,避免用户误删立即永久丢失。**当前实现仍无限期保留软删数据**,物理清理仅有管理员手段;后续生命周期已定为“软删除后保留 30 天再物理清理”(待容量信号实施,见 §8.5),届时恢复能力明确限于宽限期内。
|
||||||
- **文件留存**:普通用户文件仍是 FS 直接删除;已写入结构化 `messages.artifact_refs` 的已发布产物,在统一 files delete 入口删除时原子移动到用户根目录的平台隐藏区 `.zcbot_artifact_trash/`,原路径与历史卡片立即表现为已删除。递归目录只回收其中 artifact,其他文件照常删除;回收内容仍计入用户配额。该机制只防误删产物,不防覆盖、agent/shell 绕过 files API 或整盘损坏。完整地基仍采用 restic/borg 定时增量备份(与应用解耦,捕获删除+覆盖+所有写入口),后续容量需要时再补 `data_events` 用户意图事件和回收区清理/恢复管理。
|
- **文件留存**:普通用户文件仍是 FS 直接删除;已写入结构化 `messages.artifact_refs` 的已发布产物,在统一 files delete 入口删除时原子移动到用户根目录的平台隐藏区 `.zcbot_artifact_trash/`,原路径与历史卡片立即表现为已删除。递归目录只回收其中 artifact,其他文件照常删除;回收内容仍计入用户配额。上传入口默认以原子独占创建保留同名两份,只有用户从文件菜单明确选择并确认「替换文件」时才允许原子覆盖;替换保持原路径和 artifact 身份、内容更新。该机制仍不防 agent/shell 绕过 files API 或整盘损坏。完整地基仍采用 restic/borg 定时增量备份(与应用解耦,捕获删除+覆盖+所有写入口),后续容量需要时再补 `data_events` 用户意图事件和回收区清理/恢复管理。
|
||||||
- **0004 删 runs/usage_events 旧表**:只写不读的死代码;代价是失历史 run 元数据,真要细粒度审计再补(届时是新需求非技术债)。
|
- **0004 删 runs/usage_events 旧表**:只写不读的死代码;代价是失历史 run 元数据,真要细粒度审计再补(届时是新需求非技术债)。
|
||||||
- **本地也用 PG 不用 SQLite**:dogfood ≡ 真实路径;Docker 已是必然依赖;双 adapter 维护税 > 一次性配置。
|
- **本地也用 PG 不用 SQLite**:dogfood ≡ 真实路径;Docker 已是必然依赖;双 adapter 维护税 > 一次性配置。
|
||||||
- **API-only,UI 由 platform 实现**(2026-05-15):本仓库再维护一套 UI 是双套浪费;SSE payload 从 HTML 切 JSON;沉淀的 sink/broker/路径安全全保留。**dev SPA 留一份**作 dogfood 主路径(SSE 调试 curl/Swagger 都覆盖不了)。
|
- **API-only,UI 由 platform 实现**(2026-05-15):本仓库再维护一套 UI 是双套浪费;SSE payload 从 HTML 切 JSON;沉淀的 sink/broker/路径安全全保留。**dev SPA 留一份**作 dogfood 主路径(SSE 调试 curl/Swagger 都覆盖不了)。
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`。
|
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`。
|
||||||
|
|
||||||
最后更新:2026-08-12(数据库热路径与消息序号优化,bump 0.64.2)
|
最后更新:2026-08-12(同名上传保护 + 明确替换文件,bump 0.64.3)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -23,6 +23,8 @@
|
||||||
|
|
||||||
### 2026-08-12
|
### 2026-08-12
|
||||||
|
|
||||||
|
- **08-12 / 0.64.3 / 同名上传保护 + 明确替换文件**:粘贴、拖拽与普通上传默认由服务端原子独占创建,同名按 `name (n).ext` 自动编号且并发请求不会互相覆盖;文件菜单新增「替换文件」,选择本地文件并二次确认后才以同目录临时文件原子替换指定目标。Python 冲突策略 4 项、Node 前端契约 2 项、JavaScript/Python 语法及 diff 检查通过;DB 路由回归已补,未配置显式测试库时安全跳过;无 schema、migration 或依赖变化,未连接生产 DB。
|
||||||
|
|
||||||
- **08-12 / 0.64.2 / 数据库热路径 + 消息序号原子化**:新增 0029 migration 平滑回填 `tasks.next_message_idx`,Web、agent 与渠道消息统一在 task 行锁下分配序号,并以实际 `max(idx)` 兼容蓝绿旧实例并行;任务列表、调度扫描/历史和 usage 时间序列补部分/BRIN 索引,列表追加 UUID 稳定排序,artifact 目录操作只查询并锁定目标子树。完整 532 项 unittest 全绿(17 skip),0029 PostgreSQL DDL 编译、Alembic 单 head、Ruff 致命规则及 diff 检查通过;未配置显式测试库,DB 集成测试安全跳过,未连接或写入生产 DB。
|
- **08-12 / 0.64.2 / 数据库热路径 + 消息序号原子化**:新增 0029 migration 平滑回填 `tasks.next_message_idx`,Web、agent 与渠道消息统一在 task 行锁下分配序号,并以实际 `max(idx)` 兼容蓝绿旧实例并行;任务列表、调度扫描/历史和 usage 时间序列补部分/BRIN 索引,列表追加 UUID 稳定排序,artifact 目录操作只查询并锁定目标子树。完整 532 项 unittest 全绿(17 skip),0029 PostgreSQL DDL 编译、Alembic 单 head、Ruff 致命规则及 diff 检查通过;未配置显式测试库,DB 集成测试安全跳过,未连接或写入生产 DB。
|
||||||
|
|
||||||
- **08-12 / 0.64.1 / PDF/PPT 连续预览滚轮与触摸修复**:外层预览的防滚动穿透逻辑不再拦截来自 PDF 内层 viewport 的普通 wheel 事件,保留图片 Ctrl+滚轮缩放和其他预览的边界隔离;PDF viewport 同步声明触摸平移与 WebView/iOS 惯性滚动,使鼠标滚轮、触控板和手机上下滑动都直接驱动连续页列表。Node 前端预览 11 项、JavaScript 语法及 diff 检查通过;无 schema、migration、HTTP API、依赖或运行方式变化,未连接生产 DB。
|
- **08-12 / 0.64.1 / PDF/PPT 连续预览滚轮与触摸修复**:外层预览的防滚动穿透逻辑不再拦截来自 PDF 内层 viewport 的普通 wheel 事件,保留图片 Ctrl+滚轮缩放和其他预览的边界隔离;PDF viewport 同步声明触摸平移与 WebView/iOS 惯性滚动,使鼠标滚轮、触控板和手机上下滑动都直接驱动连续页列表。Node 前端预览 11 项、JavaScript 语法及 diff 检查通过;无 schema、migration、HTTP API、依赖或运行方式变化,未连接生产 DB。
|
||||||
|
|
|
||||||
2
RUN.md
2
RUN.md
|
|
@ -375,7 +375,7 @@ $env:ZCBOT_EVAL_TOKEN = "<dedicated-eval-user-jwt>"
|
||||||
| `GET /v1/files/download?path=` | 下单文件 | 必填 |
|
| `GET /v1/files/download?path=` | 下单文件 | 必填 |
|
||||||
| `GET /v1/tasks/{id}/files/download?path=` | 下载结构化产物;`path` 以该 task 当前 working_dir 为根,顶层目录改名后无需改历史消息;跨用户/越界/不存在均拒绝。`legacy=true` 仅供前端读取迁移前卡片,按旧 user-root→两种 task-relative 含义顺序兼容 | 必填 |
|
| `GET /v1/tasks/{id}/files/download?path=` | 下载结构化产物;`path` 以该 task 当前 working_dir 为根,顶层目录改名后无需改历史消息;跨用户/越界/不存在均拒绝。`legacy=true` 仅供前端读取迁移前卡片,按旧 user-root→两种 task-relative 含义顺序兼容 | 必填 |
|
||||||
| `GET /v1/tasks/{id}/files/preview_pdf?path=` | PPT/PPTX 结构化产物按 task-relative 路径转 PDF 预览;状态码与旧 `/v1/files/preview_pdf` 一致;旧卡片可同样传 `legacy=true` | 必填 |
|
| `GET /v1/tasks/{id}/files/preview_pdf?path=` | PPT/PPTX 结构化产物按 task-relative 路径转 PDF 预览;状态码与旧 `/v1/files/preview_pdf` 一致;旧卡片可同样传 `legacy=true` | 必填 |
|
||||||
| `POST /v1/files/upload` | multipart 上传到 `<user_root>/<path>/`;路径不存在自动 mkdir,重名覆盖 | 必填 |
|
| `POST /v1/files/upload` | multipart 上传到 `<user_root>/<path>/`;路径不存在自动 mkdir;`conflict` 默认 `rename`,重名原子保存为 `name (n).ext`;仅显式 `conflict=overwrite` 才覆盖,单文件可用 `target_name` 指定要替换的目标文件名 | 必填 |
|
||||||
| `POST /v1/files/mkdir` | `{path?="", name}`;在现有 `<user_root>/<path>/` 下新建直接子目录,禁止点目录和路径式名称;重名 → 409 | 必填 |
|
| `POST /v1/files/mkdir` | `{path?="", name}`;在现有 `<user_root>/<path>/` 下新建直接子目录,禁止点目录和路径式名称;重名 → 409 | 必填 |
|
||||||
| `POST /v1/files/delete` | `{path, recursive?=false}`;`recursive=false` 文件或空目录(非空 → 400);`recursive=true` `shutil.rmtree` —— 顶层目录被 task 引用 → 409(先 DELETE task);空目录两种模式都可删,task.working_dir 字段不动,下次 build_agent 按需 mkdir 重建 | 必填 |
|
| `POST /v1/files/delete` | `{path, recursive?=false}`;`recursive=false` 文件或空目录(非空 → 400);`recursive=true` `shutil.rmtree` —— 顶层目录被 task 引用 → 409(先 DELETE task);空目录两种模式都可删,task.working_dir 字段不动,下次 build_agent 按需 mkdir 重建 | 必填 |
|
||||||
| `POST /v1/files/rename` | `{path, new_name}`;sibling 已存在 → 409;**path 顶层目录** → 同事务 UPDATE tasks.working_dir + FOR UPDATE 锁;有 running/cancelling → 409;check_no_subtask 防嵌套 → 409 | 必填 |
|
| `POST /v1/files/rename` | `{path, new_name}`;sibling 已存在 → 409;**path 顶层目录** → 同事务 UPDATE tasks.working_dir + FOR UPDATE 锁;有 running/cancelling → 409;check_no_subtask 防嵌套 → 409 | 必填 |
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
|
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
|
||||||
# 改版本只动这一行。
|
# 改版本只动这一行。
|
||||||
__version__ = "0.64.2"
|
__version__ = "0.64.3"
|
||||||
|
|
|
||||||
|
|
@ -75,7 +75,7 @@
|
||||||
|
|
||||||
## 5. 文件操作
|
## 5. 文件操作
|
||||||
|
|
||||||
- **上传**:点右栏 `⬆`、或直接拖文件进右栏、或在输入框 **Ctrl+V 粘贴**图片(随消息发给助手)。
|
- **上传**:点右栏 `⬆`、或直接拖文件进右栏、或在输入框 **Ctrl+V 粘贴**图片(随消息发给助手)。遇到同名文件会自动编号保留两份;确需覆盖时,在目标文件的 `⋯` 菜单选择「替换文件」并确认。
|
||||||
- **选入**:点 `⊕` 从其他目录勾选文件,复制 / 移动到当前目录复用。
|
- **选入**:点 `⊕` 从其他目录勾选文件,复制 / 移动到当前目录复用。
|
||||||
- **预览**:点文件即在线看——图片可 Ctrl+滚轮缩放,Word/PDF/PPT/文本/表格直接渲染,无需下载。
|
- **预览**:点文件即在线看——图片可 Ctrl+滚轮缩放,Word/PDF/PPT/文本/表格直接渲染,无需下载。
|
||||||
- **下载**:预览弹窗右上角 `下载`。
|
- **下载**:预览弹窗右上角 `下载`。
|
||||||
|
|
|
||||||
|
|
@ -224,7 +224,7 @@
|
||||||
|
|
||||||
1. **点 `⬆` 按钮**,选文件上传到当前目录。
|
1. **点 `⬆` 按钮**,选文件上传到当前目录。
|
||||||
2. **直接把文件拖进右栏**(出现「松开以上传」提示时放手)。
|
2. **直接把文件拖进右栏**(出现「松开以上传」提示时放手)。
|
||||||
3. **在对话输入框 Ctrl+V 粘贴**文件(如截图、图片),会生成一个可预览的小卡片,随消息一起发给助手。
|
3. **在对话输入框 Ctrl+V 粘贴**文件(如截图、图片),会生成一个可预览的小卡片,随消息一起发给助手。同名文件会自动编号保留两份;确需覆盖时,在右侧目标文件的 `⋯` 菜单选择「替换文件」并确认。
|
||||||
|
|
||||||
### 6.2 选入(从其他目录带文件进来)
|
### 6.2 选入(从其他目录带文件进来)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
const filesJs = fs.readFileSync("web/static/js/files.js", "utf8");
|
||||||
|
|
||||||
|
test("file menu exposes an explicit confirmed replacement action", () => {
|
||||||
|
assert.match(filesJs, /label: "替换文件"/);
|
||||||
|
assert.match(filesJs, /title: "替换文件"/);
|
||||||
|
assert.match(filesJs, /okText: "确认替换"/);
|
||||||
|
assert.match(filesJs, /danger: true/);
|
||||||
|
assert.match(filesJs, /conflict: "overwrite"/);
|
||||||
|
assert.match(filesJs, /targetName: name/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ordinary uploads default to rename conflict policy", () => {
|
||||||
|
assert.match(filesJs, /fd\.append\("conflict", opts\.conflict \|\| "rename"\)/);
|
||||||
|
assert.match(filesJs, /同名文件已保留为/);
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from web.routers.files import _numbered_upload_name, _replace_upload, _write_upload_without_overwrite
|
||||||
|
|
||||||
|
|
||||||
|
class FileUploadConflictTests(unittest.TestCase):
|
||||||
|
def test_number_is_inserted_before_complete_extension(self):
|
||||||
|
self.assertEqual(_numbered_upload_name("data.tar.gz", 2), "data (2).tar.gz")
|
||||||
|
self.assertEqual(_numbered_upload_name("报告", 1), "报告 (1)")
|
||||||
|
self.assertEqual(_numbered_upload_name(".env", 1), ".env (1)")
|
||||||
|
|
||||||
|
def test_existing_file_is_preserved(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
root = Path(tmp)
|
||||||
|
(root / "报告.pdf").write_bytes(b"old")
|
||||||
|
saved = _write_upload_without_overwrite(root, "报告.pdf", b"new")
|
||||||
|
self.assertEqual(saved.name, "报告 (1).pdf")
|
||||||
|
self.assertEqual((root / "报告.pdf").read_bytes(), b"old")
|
||||||
|
self.assertEqual(saved.read_bytes(), b"new")
|
||||||
|
|
||||||
|
def test_concurrent_uploads_get_distinct_names(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
root = Path(tmp)
|
||||||
|
with ThreadPoolExecutor(max_workers=4) as pool:
|
||||||
|
saved = list(pool.map(
|
||||||
|
lambda i: _write_upload_without_overwrite(root, "same.txt", str(i).encode()),
|
||||||
|
range(4),
|
||||||
|
))
|
||||||
|
self.assertEqual(len({path.name for path in saved}), 4)
|
||||||
|
self.assertEqual(len(list(root.glob("same*.txt"))), 4)
|
||||||
|
|
||||||
|
def test_explicit_replace_changes_only_target(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
root = Path(tmp)
|
||||||
|
target = root / "target.txt"
|
||||||
|
target.write_bytes(b"old")
|
||||||
|
_replace_upload(target, b"new")
|
||||||
|
self.assertEqual(target.read_bytes(), b"new")
|
||||||
|
self.assertFalse(list(root.glob(".zcbot-upload-*")))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
|
|
@ -527,6 +527,21 @@ class FilesDbAwareTests(unittest.TestCase):
|
||||||
files={"files": ("hello.txt", b"hi", "text/plain")}, headers=_AUTH)
|
files={"files": ("hello.txt", b"hi", "text/plain")}, headers=_AUTH)
|
||||||
self.assertEqual(r.status_code, 200, r.text)
|
self.assertEqual(r.status_code, 200, r.text)
|
||||||
self.assertEqual(r.json()["count"], 1)
|
self.assertEqual(r.json()["count"], 1)
|
||||||
|
self.assertEqual(r.json()["saved"][0]["name"], "hello.txt")
|
||||||
|
# 默认重名保留两份,完整扩展名之前追加编号。
|
||||||
|
r = _client.post("/v1/files/upload", data={"path": "上传目录"},
|
||||||
|
files={"files": ("hello.txt", b"new", "text/plain")}, headers=_AUTH)
|
||||||
|
self.assertEqual(r.status_code, 200, r.text)
|
||||||
|
self.assertEqual(r.json()["saved"][0]["name"], "hello (1).txt")
|
||||||
|
self.assertEqual((_user_root() / "上传目录" / "hello.txt").read_bytes(), b"hi")
|
||||||
|
self.assertEqual((_user_root() / "上传目录" / "hello (1).txt").read_bytes(), b"new")
|
||||||
|
# 明确覆盖可用本地文件内容替换指定目标名。
|
||||||
|
r = _client.post("/v1/files/upload",
|
||||||
|
data={"path": "上传目录", "conflict": "overwrite", "target_name": "hello.txt"},
|
||||||
|
files={"files": ("local-name.txt", b"replaced", "text/plain")}, headers=_AUTH)
|
||||||
|
self.assertEqual(r.status_code, 200, r.text)
|
||||||
|
self.assertEqual(r.json()["saved"][0]["name"], "hello.txt")
|
||||||
|
self.assertEqual((_user_root() / "上传目录" / "hello.txt").read_bytes(), b"replaced")
|
||||||
names = {e["name"] for e in _client.get("/v1/files", headers=_AUTH).json()["entries"]}
|
names = {e["name"] for e in _client.get("/v1/files", headers=_AUTH).json()["entries"]}
|
||||||
self.assertIn("上传目录", names)
|
self.assertIn("上传目录", names)
|
||||||
# 非法文件名 → 400
|
# 非法文件名 → 400
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,8 @@ web/userfiles.py。
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
|
|
@ -43,6 +45,47 @@ from ..userfiles import (
|
||||||
_pptx_preview_locks: dict[str, asyncio.Lock] = {}
|
_pptx_preview_locks: dict[str, asyncio.Lock] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _numbered_upload_name(filename: str, index: int) -> str:
|
||||||
|
"""在完整扩展名前追加编号,例如 data.tar.gz -> data (1).tar.gz。"""
|
||||||
|
path = Path(filename)
|
||||||
|
suffix = "" if filename.startswith(".") and filename.count(".") == 1 else "".join(path.suffixes)
|
||||||
|
stem = filename[:-len(suffix)] if suffix else filename
|
||||||
|
return f"{stem} ({index}){suffix}"
|
||||||
|
|
||||||
|
|
||||||
|
def _write_upload_without_overwrite(dest_dir: Path, filename: str, data: bytes) -> Path:
|
||||||
|
"""以原子独占创建保存上传;并发重名时继续递增编号。"""
|
||||||
|
index = 0
|
||||||
|
while True:
|
||||||
|
candidate = filename if index == 0 else _numbered_upload_name(filename, index)
|
||||||
|
dest = dest_dir / candidate
|
||||||
|
try:
|
||||||
|
fd = os.open(dest, os.O_WRONLY | os.O_CREAT | os.O_EXCL)
|
||||||
|
except FileExistsError:
|
||||||
|
index += 1
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
with os.fdopen(fd, "wb") as handle:
|
||||||
|
handle.write(data)
|
||||||
|
except Exception:
|
||||||
|
dest.unlink(missing_ok=True)
|
||||||
|
raise
|
||||||
|
return dest
|
||||||
|
|
||||||
|
|
||||||
|
def _replace_upload(dest: Path, data: bytes) -> None:
|
||||||
|
"""先完整写入同目录临时文件,再原子替换明确选择的目标。"""
|
||||||
|
fd, tmp_name = tempfile.mkstemp(prefix=".zcbot-upload-", dir=dest.parent)
|
||||||
|
tmp = Path(tmp_name)
|
||||||
|
try:
|
||||||
|
with os.fdopen(fd, "wb") as handle:
|
||||||
|
handle.write(data)
|
||||||
|
os.replace(tmp, dest)
|
||||||
|
except Exception:
|
||||||
|
tmp.unlink(missing_ok=True)
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
def _pptx_lock_for(abs_path: str) -> asyncio.Lock:
|
def _pptx_lock_for(abs_path: str) -> asyncio.Lock:
|
||||||
lock = _pptx_preview_locks.get(abs_path)
|
lock = _pptx_preview_locks.get(abs_path)
|
||||||
if lock is None:
|
if lock is None:
|
||||||
|
|
@ -293,11 +336,14 @@ def register_file_routes(app, *, require_user) -> None:
|
||||||
@app.post("/v1/files/upload", tags=["files"])
|
@app.post("/v1/files/upload", tags=["files"])
|
||||||
async def upload_files(
|
async def upload_files(
|
||||||
path: str = Form(""),
|
path: str = Form(""),
|
||||||
|
conflict: str = Form("rename"),
|
||||||
|
target_name: str = Form(""),
|
||||||
files: list[UploadFile] = File(...),
|
files: list[UploadFile] = File(...),
|
||||||
user_id: UUID = Depends(require_user),
|
user_id: UUID = Depends(require_user),
|
||||||
):
|
):
|
||||||
"""multipart 多文件上传到 `<user_root>/<path>/`。
|
"""multipart 多文件上传到 `<user_root>/<path>/`。
|
||||||
路径不存在自动 mkdir(parents=True);重名直接覆盖。
|
路径不存在自动 mkdir(parents=True);默认重名自动编号保留两份。
|
||||||
|
仅显式 `conflict=overwrite` 可覆盖;`target_name` 用于替换指定文件。
|
||||||
文件名严格校验(含 `/ \\ ..` 或为空 → 400)。
|
文件名严格校验(含 `/ \\ ..` 或为空 → 400)。
|
||||||
"""
|
"""
|
||||||
# 磁盘配额 gate(§7.5 #4):超额 413 阻止上传,提示 user 清旧产物
|
# 磁盘配额 gate(§7.5 #4):超额 413 阻止上传,提示 user 清旧产物
|
||||||
|
|
@ -316,9 +362,14 @@ def register_file_routes(app, *, require_user) -> None:
|
||||||
raise HTTPException(400, f"upload target is a file, not a directory: {path}")
|
raise HTTPException(400, f"upload target is a file, not a directory: {path}")
|
||||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
if conflict not in ("rename", "overwrite"):
|
||||||
|
raise HTTPException(400, f"invalid conflict policy: {conflict!r}")
|
||||||
|
if target_name and (conflict != "overwrite" or len(files or []) != 1):
|
||||||
|
raise HTTPException(400, "target_name requires one file and conflict=overwrite")
|
||||||
|
|
||||||
saved: list[dict] = []
|
saved: list[dict] = []
|
||||||
for up in files or []:
|
for up in files or []:
|
||||||
raw_name = up.filename or ""
|
raw_name = target_name or up.filename or ""
|
||||||
if (
|
if (
|
||||||
not raw_name
|
not raw_name
|
||||||
or raw_name in (".", "..")
|
or raw_name in (".", "..")
|
||||||
|
|
@ -326,14 +377,20 @@ def register_file_routes(app, *, require_user) -> None:
|
||||||
or any(part in (".", "..") for part in Path(raw_name).parts)
|
or any(part in (".", "..") for part in Path(raw_name).parts)
|
||||||
):
|
):
|
||||||
raise HTTPException(400, f"invalid filename: {raw_name!r}")
|
raise HTTPException(400, f"invalid filename: {raw_name!r}")
|
||||||
dest = dest_dir / raw_name
|
requested_dest = dest_dir / raw_name
|
||||||
try:
|
try:
|
||||||
dest.resolve().relative_to(root.resolve())
|
requested_dest.resolve().relative_to(root.resolve())
|
||||||
except ValueError:
|
except ValueError:
|
||||||
raise HTTPException(400, f"path escapes user_root: {raw_name!r}")
|
raise HTTPException(400, f"path escapes user_root: {raw_name!r}")
|
||||||
data = await up.read()
|
data = await up.read()
|
||||||
dest.write_bytes(data)
|
if conflict == "overwrite":
|
||||||
saved.append({"name": raw_name, "size": len(data), "rel": rel_to(root, dest)})
|
if requested_dest.exists() and not requested_dest.is_file():
|
||||||
|
raise HTTPException(409, f"upload target is not a file: {raw_name!r}")
|
||||||
|
_replace_upload(requested_dest, data)
|
||||||
|
dest = requested_dest
|
||||||
|
else:
|
||||||
|
dest = _write_upload_without_overwrite(dest_dir, raw_name, data)
|
||||||
|
saved.append({"name": dest.name, "size": len(data), "rel": rel_to(root, dest)})
|
||||||
if not saved:
|
if not saved:
|
||||||
raise HTTPException(400, "no files uploaded")
|
raise HTTPException(400, "no files uploaded")
|
||||||
return {"count": len(saved), "saved": saved}
|
return {"count": len(saved), "saved": saved}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
// 文件面板:右栏列表浏览/导航/删除/重命名、刷新、"选入"弹框(跨目录勾选复制/移动)、
|
// 文件面板:右栏列表浏览/导航/删除/重命名/明确替换、刷新、"选入"弹框(跨目录勾选复制/移动)、
|
||||||
// 拖拽上传 overlay + 上传(XHR 带进度)+ 上传状态条。
|
// 拖拽上传 overlay + 上传(XHR 带进度)+ 上传状态条。
|
||||||
// 导出 loadFiles / scheduleFilesRefresh(SSE 文件事件触发刷新)/ closeSrcPicker(main Esc 关栈)
|
// 导出 loadFiles / scheduleFilesRefresh(SSE 文件事件触发刷新)/ closeSrcPicker(main Esc 关栈)
|
||||||
// / uploadFiles(聊天区粘贴或拖拽文件复用)。其余入口在本模块顶层自绑。
|
// / uploadFiles(聊天区粘贴或拖拽文件复用)。其余入口在本模块顶层自绑。
|
||||||
|
|
@ -327,6 +327,8 @@ function fileMenuItems(e) {
|
||||||
onclick: () => renameFile(e.rel, e.name, e.is_dir) },
|
onclick: () => renameFile(e.rel, e.name, e.is_dir) },
|
||||||
];
|
];
|
||||||
if (!e.is_dir) {
|
if (!e.is_dir) {
|
||||||
|
items.push({ act: "replace", label: "替换文件", cls: "act-replace",
|
||||||
|
onclick: () => replaceFile(e.rel, e.name) });
|
||||||
items.push({ act: "download", label: "下载", cls: "act-download",
|
items.push({ act: "download", label: "下载", cls: "act-download",
|
||||||
onclick: () => downloadFile(e.rel) });
|
onclick: () => downloadFile(e.rel) });
|
||||||
}
|
}
|
||||||
|
|
@ -335,6 +337,31 @@ function fileMenuItems(e) {
|
||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function replaceFile(rel, name) {
|
||||||
|
const input = document.createElement("input");
|
||||||
|
input.type = "file";
|
||||||
|
input.onchange = async () => {
|
||||||
|
const file = input.files && input.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
const confirmed = await dialogConfirm({
|
||||||
|
title: "替换文件",
|
||||||
|
message: `将用 "${file.name}" 的内容替换 "${name}",原文件将被覆盖且无法恢复。`,
|
||||||
|
okText: "确认替换",
|
||||||
|
danger: true,
|
||||||
|
});
|
||||||
|
if (!confirmed) return;
|
||||||
|
const slash = rel.lastIndexOf("/");
|
||||||
|
const path = slash >= 0 ? rel.slice(0, slash) : "";
|
||||||
|
const saved = await uploadFiles([file], {
|
||||||
|
path,
|
||||||
|
conflict: "overwrite",
|
||||||
|
targetName: name,
|
||||||
|
});
|
||||||
|
if (saved && saved.length) message(`已替换 ${name}`, "success");
|
||||||
|
};
|
||||||
|
input.click();
|
||||||
|
}
|
||||||
|
|
||||||
async function deleteFile(rel, name, isDir) {
|
async function deleteFile(rel, name, isDir) {
|
||||||
let recursive = false;
|
let recursive = false;
|
||||||
if (!isDir) {
|
if (!isDir) {
|
||||||
|
|
@ -452,7 +479,9 @@ async function uploadFilesWithPaneStatus(files) {
|
||||||
export async function uploadFiles(files, opts = {}) {
|
export async function uploadFiles(files, opts = {}) {
|
||||||
if (!files || !files.length) return null;
|
if (!files || !files.length) return null;
|
||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
fd.append("path", state.filesPath || "");
|
fd.append("path", opts.path ?? state.filesPath ?? "");
|
||||||
|
fd.append("conflict", opts.conflict || "rename");
|
||||||
|
if (opts.targetName) fd.append("target_name", opts.targetName);
|
||||||
for (const f of files) fd.append("files", f);
|
for (const f of files) fd.append("files", f);
|
||||||
try {
|
try {
|
||||||
const data = await new Promise((resolve, reject) => {
|
const data = await new Promise((resolve, reject) => {
|
||||||
|
|
@ -479,7 +508,13 @@ export async function uploadFiles(files, opts = {}) {
|
||||||
xhr.send(fd);
|
xhr.send(fd);
|
||||||
});
|
});
|
||||||
await loadFiles();
|
await loadFiles();
|
||||||
return data.saved || [];
|
const saved = data.saved || [];
|
||||||
|
if ((opts.conflict || "rename") === "rename") {
|
||||||
|
const renamed = saved.filter((item, i) => item.name !== files[i]?.name);
|
||||||
|
if (renamed.length === 1) message(`同名文件已保留为 ${renamed[0].name}`, "success");
|
||||||
|
else if (renamed.length > 1) message(`${renamed.length} 个同名文件已自动重命名`, "success");
|
||||||
|
}
|
||||||
|
return saved;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e.status === 401) { logout(); return null; }
|
if (e.status === 401) { logout(); return null; }
|
||||||
message("上传失败:" + e.message, "error");
|
message("上传失败:" + e.message, "error");
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue