175 lines
6.6 KiB
Python
175 lines
6.6 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) -> tuple[list[dict], list[dict], bool]:
|
|
"""枚举 current 下条目 + 拼面包屑。size raw bytes,mtime ISO 串(前端 humanize)。
|
|
Dotfile 一律隐藏(`.memory/` 等系统区不暴露给 UI,同 `/v1/folders` 约定)。
|
|
"""
|
|
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("."):
|
|
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
|