Files
opencode-build/fs-backend/app/routers/fs.py
T
2026-07-29 10:07:13 +08:00

473 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
文件系统相关路由
"""
import os
import shutil
import subprocess
from typing import List
from fastapi import APIRouter, HTTPException, Query
from app.core.config import settings
from app.schemas.fs import (
FileContentResponse,
FileContentUpdateRequest,
FileContentUpdateResponse,
FileCreateRequest,
FileOperationResponse,
FileTreeItem,
)
from app.schemas.project import ProjectCreateRequest, ProjectCreateResponse
router = APIRouter()
def _is_git_ignored(directory: str, absolute_path: str) -> bool:
"""
判断指定路径是否被 git 忽略。
依赖目标目录是否为 git 仓库:
- 若是 git 仓库,使用 `git check-ignore` 判定;
- 若不是 git 仓库或 git 不可用,默认返回 False。
"""
try:
result = subprocess.run(
["git", "-C", directory, "check-ignore", absolute_path],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
timeout=5,
)
# git check-ignore 退出码:0 表示被忽略,1 表示未被忽略
return result.returncode == 0
except (FileNotFoundError, subprocess.TimeoutExpired):
# git 未安装或超时,按非忽略处理
return False
def _assert_path_allowed(real_directory: str, real_target: str) -> None:
"""
确保解析后的真实路径在允许范围内。
允许范围:
- 项目目录 real_directory 内(常规文件访问);
- BASE_WORKSPACE 工作空间内(允许 tables_meta 这类指向工作空间根的共享软链接)。
这样既阻止通过 `..` 或外部软链接逃逸出工作空间,又允许项目内合法的共享软链接。
"""
real_workspace = os.path.realpath(settings.BASE_WORKSPACE)
within_dir = real_target == real_directory or real_target.startswith(real_directory + os.sep)
within_workspace = real_target == real_workspace or real_target.startswith(real_workspace + os.sep)
if not (within_dir or within_workspace):
raise HTTPException(
status_code=400,
detail="非法的查询路径,检测到路径逃逸尝试"
)
@router.get("/file", response_model=List[FileTreeItem])
async def list_file_tree(
directory: str = Query(..., description="项目顶层绝对目录(URL 编码传输)"),
path: str = Query(..., description="相对于 directory 的查询路径,. 表示根目录"),
):
"""
查询指定目录下的文件树列表
- directory:项目顶层绝对目录,固定值(如 /root/workspace/test
- path:相对查询路径
- 传 `.`:读取 directory 根目录下所有文件 / 文件夹
- 传 `文件夹名/`(如 `ningxia/`):读取 directory/文件夹名 子目录内文件树
"""
# 规范化 directory:去除尾部斜杠
directory = os.path.normpath(directory)
# 安全校验:directory 必须存在且为目录
if not os.path.isdir(directory):
raise HTTPException(
status_code=400,
detail=f"目录不存在或不是文件夹:{directory}"
)
# 规范化 path:仅允许 `.` 或以 `/` 结尾的相对子路径
# 处理 path 为 `.` 的根目录情形
if path == "." or path == "":
target_dir = directory
else:
# 去除首尾空白,确保以 / 结尾(约定:文件夹末尾带 /)
rel_path = path.strip()
if not rel_path.endswith("/"):
rel_path = rel_path + "/"
# 去除结尾的 / 用于拼接
rel_path_clean = rel_path.rstrip("/")
# 禁止路径逃逸
if ".." in rel_path_clean.split("/"):
raise HTTPException(
status_code=400,
detail="非法的相对路径,禁止路径逃逸 (..)"
)
# 禁止绝对路径
if os.path.isabs(rel_path_clean):
raise HTTPException(
status_code=400,
detail="相对路径 path 不能为绝对路径"
)
target_dir = os.path.join(directory, rel_path_clean)
# 解析真实物理路径,确保最终路径在允许范围内(防逃逸,允许工作空间内软链接)
real_directory = os.path.realpath(directory)
real_target = os.path.realpath(target_dir)
_assert_path_allowed(real_directory, real_target)
if not os.path.isdir(real_target):
raise HTTPException(
status_code=404,
detail=f"查询的子目录不存在:{path}"
)
# 计算 path 前缀(基于 directory 的相对路径,用于拼接每个条目的 path)
# path 为 `.` 时前缀为空;否则前缀为 `文件夹名/`
if path == "." or path == "":
rel_prefix = ""
else:
rel_prefix = path.strip()
if not rel_prefix.endswith("/"):
rel_prefix = rel_prefix + "/"
items: List[FileTreeItem] = []
try:
entries = sorted(os.listdir(real_target))
except OSError as e:
raise HTTPException(
status_code=500,
detail=f"读取目录失败:{str(e)}"
)
for entry in entries:
abs_path = os.path.join(real_target, entry)
is_dir = os.path.isdir(abs_path)
# 基于 directory 的相对路径:文件夹末尾带 /,文件不带
rel_path = rel_prefix + entry + ("/" if is_dir else "")
items.append(
FileTreeItem(
name=entry,
path=rel_path,
absolute=abs_path,
type="directory" if is_dir else "file",
ignored=_is_git_ignored(directory, abs_path),
)
)
return items
def _resolve_file_path(directory: str, path: str):
"""
解析并校验「相对文件路径」对应的真实物理路径。
校验规则:
- directory 必须存在且为目录
- path 必须指向具体文件,不能为根目录(. / 空)
- 禁止路径逃逸(..)与绝对路径
- realpath 解析后必须仍位于 directory 内(防符号链接逃逸)
返回:(real_directory, real_target, rel_path)
"""
# 规范化 directory
directory = os.path.normpath(directory)
if not os.path.isdir(directory):
raise HTTPException(
status_code=400,
detail=f"目录不存在或不是文件夹:{directory}"
)
rel_path = path.strip().rstrip("/")
# 仅允许相对路径,禁止路径逃逸
if rel_path in ("", "."):
raise HTTPException(
status_code=400,
detail="path 必须指向具体文件,不能为根目录"
)
if ".." in rel_path.split("/"):
raise HTTPException(
status_code=400,
detail="非法的相对路径,禁止路径逃逸 (..)"
)
if os.path.isabs(rel_path):
raise HTTPException(
status_code=400,
detail="相对路径 path 不能为绝对路径"
)
target = os.path.join(directory, rel_path)
# 解析真实物理路径,确保最终路径在允许范围内(防逃逸,允许工作空间内软链接)
real_directory = os.path.realpath(directory)
real_target = os.path.realpath(target)
_assert_path_allowed(real_directory, real_target)
return real_directory, real_target, rel_path
@router.get("/file/content", response_model=FileContentResponse)
async def get_file_content(
directory: str = Query(..., description="项目顶层绝对目录(URL 编码传输)"),
path: str = Query(..., description="相对于 directory 的文件路径,如 appfile/main.py"),
):
"""
预览文件内容
- directory:项目顶层绝对目录
- path:相对于 directory 的文件路径(不带尾部 /,指向具体文件)
- 返回文本文件内容;二进制文件返回 type=binary 且 content 为空
"""
_real_directory, real_target, _rel_path = _resolve_file_path(directory, path)
if not os.path.exists(real_target):
raise HTTPException(
status_code=404,
detail=f"文件不存在:{path}"
)
if os.path.isdir(real_target):
raise HTTPException(
status_code=400,
detail=f"目标路径是文件夹,不是文件:{path}"
)
# 限制预览文件大小,避免读取超大文件
max_size = 5 * 1024 * 1024 # 5MB
try:
file_size = os.path.getsize(real_target)
if file_size > max_size:
raise HTTPException(
status_code=413,
detail=f"文件过大,无法预览({file_size} 字节,上限 {max_size} 字节)"
)
except OSError as e:
raise HTTPException(
status_code=500,
detail=f"读取文件信息失败:{str(e)}"
)
# 先按二进制读取前若干字节判断是否为文本
try:
with open(real_target, "rb") as f:
chunk = f.read(1024)
# 含 NUL 字节通常视为二进制
if b"\x00" in chunk:
return FileContentResponse(type="binary", content="")
# 尝试以 UTF-8 解码全文
with open(real_target, "r", encoding="utf-8") as f:
content = f.read()
return FileContentResponse(type="text", content=content)
except UnicodeDecodeError:
# 非 UTF-8 文本,按二进制处理
return FileContentResponse(type="binary", content="")
except OSError as e:
raise HTTPException(
status_code=500,
detail=f"读取文件失败:{str(e)}"
)
@router.put("/file/content", response_model=FileContentUpdateResponse)
async def update_file_content(
request: FileContentUpdateRequest,
directory: str = Query(..., description="项目顶层绝对目录(URL 编码传输)"),
path: str = Query(..., description="相对于 directory 的文件路径,如 appfile/main.py"),
):
"""
修改(覆盖写入)文件内容
- directory:项目顶层绝对目录
- path:相对于 directory 的文件路径(不带尾部 /,指向具体文件)
- 请求体 content:文件新内容(UTF-8 文本,整文件覆盖写入)
"""
_real_directory, real_target, rel_path = _resolve_file_path(directory, path)
if not os.path.exists(real_target):
raise HTTPException(
status_code=404,
detail=f"文件不存在:{path}"
)
if os.path.isdir(real_target):
raise HTTPException(
status_code=400,
detail=f"目标路径是文件夹,不是文件:{path}"
)
try:
# 整文件覆盖写入(UTF-8
with open(real_target, "w", encoding="utf-8") as f:
f.write(request.content)
except OSError as e:
raise HTTPException(
status_code=500,
detail=f"写入文件失败:{str(e)}"
)
return FileContentUpdateResponse(
success=True,
message="文件已保存",
path=rel_path,
absolute=real_target,
)
@router.post("/file", response_model=FileOperationResponse)
async def create_file_entry(
request: FileCreateRequest,
directory: str = Query(..., description="项目顶层绝对目录(URL 编码传输)"),
path: str = Query(..., description="相对于 directory 的新建项路径,如 appfile/new.py"),
):
"""
新建文件 / 文件夹
- directory:项目顶层绝对目录
- path:相对于 directory 的新建项路径(不带尾部 /)
- 请求体 typefile / directorytype=file 时可用 content 指定初始内容
- 目标已存在返回 409;新建文件时会自动创建所需父目录
"""
_real_directory, real_target, rel_path = _resolve_file_path(directory, path)
if os.path.exists(real_target):
raise HTTPException(
status_code=409,
detail=f"目标已存在:{path}"
)
item_type = request.type
try:
if item_type == "directory":
os.makedirs(real_target, exist_ok=False)
else:
# 文件:确保父目录存在后写入初始内容
parent_dir = os.path.dirname(real_target)
if parent_dir:
os.makedirs(parent_dir, exist_ok=True)
with open(real_target, "w", encoding="utf-8") as f:
f.write(request.content or "")
except OSError as e:
raise HTTPException(
status_code=500,
detail=f"新建失败:{str(e)}"
)
return FileOperationResponse(
success=True,
message=f"{'文件夹' if item_type == 'directory' else '文件'}已创建",
path=rel_path,
absolute=real_target,
type=item_type,
)
@router.delete("/file", response_model=FileOperationResponse)
async def delete_file_entry(
directory: str = Query(..., description="项目顶层绝对目录(URL 编码传输)"),
path: str = Query(..., description="相对于 directory 的待删除项路径"),
):
"""
删除文件 / 文件夹
- directory:项目顶层绝对目录
- path:相对于 directory 的待删除项路径(文件夹可带尾部 /)
- 文件夹递归删除;不存在返回 404
- path 不能为根目录(.),不会删除 directory 本身
"""
_real_directory, real_target, rel_path = _resolve_file_path(directory, path)
if not os.path.exists(real_target):
raise HTTPException(
status_code=404,
detail=f"目标不存在:{path}"
)
is_dir = os.path.isdir(real_target)
try:
if is_dir:
shutil.rmtree(real_target)
else:
os.remove(real_target)
except OSError as e:
raise HTTPException(
status_code=500,
detail=f"删除失败:{str(e)}"
)
return FileOperationResponse(
success=True,
message=f"{'文件夹' if is_dir else '文件'}已删除",
path=rel_path,
absolute=real_target,
type="directory" if is_dir else "file",
)
@router.post("/project/create", response_model=ProjectCreateResponse)
async def create_project(request: ProjectCreateRequest):
"""
创建新项目
- 校验项目名格式
- 拼接完整路径
- 确保路径在安全目录内
- 创建项目目录
- 软链接 BASE_WORKSPACE/tables_meta 到新项目下(共享表元数据)
"""
project_name = request.project_name
# 二次校验:禁止路径逃逸字符(防御性编程)
if ".." in project_name or "/" in project_name or "\\" in project_name:
raise HTTPException(
status_code=400,
detail="项目名称不能包含路径逃逸字符 (../ 或 / 或 \\)"
)
# 拼接完整路径
project_path = os.path.join(settings.BASE_WORKSPACE, project_name)
# 安全校验:确保最终路径在 base_workspace 内
real_workspace = os.path.realpath(settings.BASE_WORKSPACE)
real_project_path = os.path.realpath(os.path.dirname(project_path))
# 检查路径是否逃逸
if not real_project_path.startswith(real_workspace):
raise HTTPException(
status_code=400,
detail="非法的项目路径,检测到路径逃逸尝试"
)
try:
# 创建项目目录
os.makedirs(project_path, exist_ok=True)
except OSError as e:
raise HTTPException(
status_code=500,
detail=f"创建项目目录失败:{str(e)}"
)
# 软链接 BASE_WORKSPACE/tables_meta 到新项目下(共享表元数据)
# 使用相对路径,便于工作空间整体迁移;已存在则不覆盖
tables_meta_src = os.path.join(settings.BASE_WORKSPACE, "tables_meta")
tables_meta_link = os.path.join(project_path, "tables_meta")
symlink_note = ""
try:
if os.path.lexists(tables_meta_link):
# 已存在(可能是历史软链接或实际文件),不覆盖
pass
elif not os.path.exists(tables_meta_src):
symlink_note = "(未找到 tables_meta 源,已跳过软链接)"
else:
rel_src = os.path.relpath(tables_meta_src, project_path)
os.symlink(rel_src, tables_meta_link)
except OSError as e:
symlink_note = f"(软链接 tables_meta 失败:{str(e)}"
return ProjectCreateResponse(
success=True,
message=f"项目 '{project_name}' 创建成功{symlink_note}",
project_path=project_path
)