zcbot/web/userfiles.py

181 lines
6.8 KiB
Python

"""user_root 文件操作的安全原语(从 app.py 析出,2026-07-23 拆分)。
所有 files/kb 类端点的路径边界收口:user_root 解析、越界校验(`../` / 绝对路径 /
symlink)、目录枚举、批量 transfer 预检。安全关键 —— 改动需过 tests/test_pptx_render.py
的越界用例。
"""
from __future__ import annotations
from datetime import datetime as _dt
from pathlib import Path
from uuid import UUID
from fastapi import HTTPException
from sqlalchemy import func, or_, select
from core.storage import session_scope
from core.storage.models import Task
from .common import CHANNEL_MIRROR_KINDS
def load_user_root(user_id: UUID) -> Path:
"""user_root = `<workspace>/users/<user_id>/`,所有 files API 的边界。
若目录尚未存在自动 mkdir(空 user 首次访问也能拿到根)。
"""
from core.agent_builder import resolve_workspace, user_root
ws = resolve_workspace(None)
return user_root(ws, user_id)
def safe_join(root: Path, rel: str) -> Path:
"""归一用户路径到 absolute,并校验仍在 root 内。防 `../` / 绝对 path / symlink 越界。"""
rel = (rel or "").strip()
if not rel:
return root.resolve()
if rel[0] in ("/", "\\"):
raise HTTPException(400, f"absolute-style path not allowed: {rel!r}")
if Path(rel).is_absolute():
raise HTTPException(400, f"absolute path not allowed: {rel!r}")
target = (root / rel).resolve()
try:
target.relative_to(root.resolve())
except ValueError:
raise HTTPException(400, f"path escapes user_root: {rel!r}")
return target
def rel_to(root: Path, target: Path) -> str:
try:
rel = target.resolve().relative_to(root.resolve()).as_posix()
except ValueError:
return ""
return "" if rel == "." else rel
def enumerate_files(
root: Path,
current: Path,
*,
include_hidden_dirs: bool = False,
) -> tuple[list[dict], list[dict], bool]:
"""枚举 current 下条目 + 拼面包屑。size raw bytes,mtime ISO 串(前端 humanize)。
dotfile 始终隐藏;调用方仅在已验证的 task 工作目录子树内允许展示 dotdir。
即使开关开启,`.env` 等点文件仍不展示。
"""
entries: list[dict] = []
exists = current.exists()
if exists and current.is_dir():
try:
raw = sorted(current.iterdir(), key=lambda p: (p.is_file(), p.name.lower()))
except OSError:
raw = []
for p in raw:
if p.name.startswith(".") and not (include_hidden_dirs and p.is_dir()):
continue
try:
st = p.stat()
except OSError:
continue
entries.append({
"name": p.name,
"is_dir": p.is_dir(),
"size": st.st_size if p.is_file() else None,
"mtime": _dt.fromtimestamp(st.st_mtime).isoformat(timespec="seconds"),
"rel": rel_to(root, p),
})
cur_rel = rel_to(root, current)
crumbs = [{"label": "/", "rel": ""}]
# cur_rel == "." 表示当前就在 root(target.relative_to(root) 返 Path(".")),
# 不该再追加一个无意义的 "." crumb
if cur_rel and cur_rel != ".":
acc = ""
for part in cur_rel.split("/"):
acc = f"{acc}/{part}" if acc else part
crumbs.append({"label": part, "rel": acc})
return entries, crumbs, exists
def system_wd_names(user_id: UUID) -> set[str]:
"""有任务入口的系统工作目录末段名(定时任务执行目录 + 渠道镜像对话目录)。
这些目录点开对应入口(定时任务运行历史 / 渠道固定卡片)时文件面板会自动跳进去,
根目录列表隐藏它们只是降噪,不是权限拦截 —— 带 path 直接访问照常放行(任务跳转
依赖这条路)。孤儿目录(task 已软删 / job 物理删后的漏网)没有别的 UI 入口,
刻意不隐藏,留在根目录可见。
"""
with session_scope() as s:
rows = s.execute(
select(Task.working_dir).where(
Task.user_id == user_id,
Task.deleted_at.is_(None),
Task.working_dir.isnot(None),
or_(
Task.scheduled_job_id.isnot(None),
func.coalesce(Task.channel, "web").in_(CHANNEL_MIRROR_KINDS),
),
)
).all()
return {wd.rstrip("/").rsplit("/", 1)[-1] for (wd,) in rows if wd}
def validate_transfer(
root: Path, paths: list[str], dest_dir: str,
) -> tuple[list[Path], Path]:
"""预检批量 transfer:解析所有源 + 目标,任意一项不合法即整批 abort(无 FS 副作用)。
返回 (sources, dest_dir_path)。不区分 copy / move(顶层 working_dir 闸由路由各自加)。
校验项:
- paths 非空;每个源在 user_root 内 + 存在;不能是 user_root 本身
- dest_dir 存在 + 是目录(可以是 user_root)
- 源不能与 dest_dir 相同(自移动)
- dest_dir 不能在源的子树内(不能把 a/ 搬进 a/b/)
- 源不能已是 dest_dir 直接子项(原地移动,no-op)
- 同批次源 leaf 名不能重复(俩 a.txt 会撞 dest/a.txt)
- dest_dir/<name> 不能已存在(整批 409,不静默覆盖)
"""
if not paths:
raise HTTPException(400, "paths is empty")
dest = safe_join(root, dest_dir)
if not dest.exists():
raise HTTPException(404, f"dest_dir not found: {dest_dir!r}")
if not dest.is_dir():
raise HTTPException(400, f"dest_dir is not a directory: {dest_dir!r}")
dest_r = dest.resolve()
sources: list[Path] = []
seen_names: set[str] = set()
for p in paths:
src = safe_join(root, p)
if not src.exists():
raise HTTPException(404, f"source not found: {p!r}")
src_r = src.resolve()
if src_r == root.resolve():
raise HTTPException(400, "cannot transfer user_root")
if src_r == dest_r:
raise HTTPException(400, f"source equals dest_dir: {p!r}")
# dest 在 src 子树内 → 自嵌套
try:
dest_r.relative_to(src_r)
raise HTTPException(
400, f"cannot transfer {p!r} into its own subtree"
)
except ValueError:
pass
# 已是 dest 直接子项 → no-op
if src.parent.resolve() == dest_r:
raise HTTPException(
400, f"{p!r} already directly under dest_dir"
)
name = src.name
if name in seen_names:
raise HTTPException(400, f"duplicate source leaf name in batch: {name!r}")
seen_names.add(name)
target = dest / name
if target.exists():
raise HTTPException(
409, f"target already exists: {rel_to(root, target)!r}"
)
sources.append(src)
return sources, dest