Files
wehub-resource-sync 1443d3fdf9
Ruff Format Check / Ruff Format & Lint (push) Failing after 7m39s
Deploy VitePress site to Pages / build (push) Failing after 9m11s
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:32:26 +08:00

383 lines
16 KiB
Python

from __future__ import annotations
import asyncio
import contextlib
import hashlib
import io
import shutil
from pathlib import Path, PurePosixPath
from urllib.parse import quote
import aiofiles
from fastapi import HTTPException, UploadFile
from fastapi.responses import FileResponse, StreamingResponse
from yuxi.agents.backends.sandbox.paths import _global_user_data_dir, ensure_workspace_default_files
from yuxi.services.file_preview import (
MAX_BINARY_PREVIEW_SIZE_BYTES,
OfficePreviewConversionError,
convert_office_to_pdf,
detect_media_type,
detect_preview_type,
is_binary_preview_type,
is_office_pdf_preview_file,
render_preview_payload,
render_preview_too_large_payload,
)
from yuxi.services.mention_search_service import invalidate_workspace_mention_cache
from yuxi.storage.postgres.models_business import User
from yuxi.utils.datetime_utils import utc_isoformat_from_timestamp
from yuxi.utils.paths import VIRTUAL_PATH_WORKSPACE, WORKSPACE_DIR_NAME
from yuxi.utils.upload_utils import MAX_UPLOAD_SIZE_BYTES, write_upload_to_buffer
EDITABLE_WORKSPACE_SUFFIXES = {".md", ".markdown", ".mdx", ".txt"}
MAX_WORKSPACE_UPLOAD_SIZE_BYTES = MAX_UPLOAD_SIZE_BYTES
MAX_WORKSPACE_UPLOAD_FILES = 50
def _workspace_root(user: User) -> Path:
try:
user_data_root = _global_user_data_dir(str(user.uid)).resolve()
root = user_data_root / WORKSPACE_DIR_NAME
except ValueError as exc:
raise HTTPException(status_code=403, detail="Access denied") from exc
if root.is_symlink():
raise HTTPException(status_code=403, detail="Access denied")
root.mkdir(parents=True, exist_ok=True)
resolved_root = root.resolve()
try:
resolved_root.relative_to(user_data_root)
except ValueError as exc:
raise HTTPException(status_code=403, detail="Access denied") from exc
ensure_workspace_default_files(resolved_root)
return resolved_root
def _normalize_workspace_path(path: str | None) -> PurePosixPath:
raw_path = (path or "/").strip() or "/"
if not raw_path.startswith("/"):
raw_path = f"/{raw_path}"
normalized = PurePosixPath(raw_path)
if ".." in normalized.parts:
raise HTTPException(status_code=403, detail="Access denied")
return normalized
def _resolve_workspace_path(user: User, path: str | None) -> Path:
root = _workspace_root(user)
normalized = _normalize_workspace_path(path)
relative_parts = [part for part in normalized.parts if part not in {"/", ""}]
target = (root.joinpath(*relative_parts) if relative_parts else root).resolve()
try:
target.relative_to(root)
except ValueError as exc:
raise HTTPException(status_code=403, detail="Access denied") from exc
return target
def _entry_for_path(root: Path, path: Path) -> dict:
stat = path.stat()
is_dir = path.is_dir()
relative = path.relative_to(root).as_posix()
display_path = f"/{relative}" if relative else "/"
if is_dir and display_path != "/" and not display_path.endswith("/"):
display_path = f"{display_path}/"
virtual_path = VIRTUAL_PATH_WORKSPACE if display_path == "/" else f"{VIRTUAL_PATH_WORKSPACE}{display_path}"
return {
"path": display_path,
"virtual_path": virtual_path,
"name": path.name or "工作区",
"is_dir": is_dir,
"size": 0 if is_dir else stat.st_size,
"modified_at": utc_isoformat_from_timestamp(stat.st_mtime) or "",
}
def _sort_entries(entries: list[dict]) -> list[dict]:
return sorted(entries, key=lambda item: (not bool(item.get("is_dir")), str(item.get("name") or "").lower()))
def _validate_child_name(name: str, *, field_name: str) -> str:
clean_name = str(name or "").strip()
if not clean_name:
raise HTTPException(status_code=422, detail=f"{field_name} 不能为空")
if clean_name in {".", ".."} or "/" in clean_name or "\\" in clean_name:
raise HTTPException(status_code=422, detail=f"{field_name} 不能包含路径分隔符")
if PurePosixPath(clean_name).name != clean_name:
raise HTTPException(status_code=422, detail=f"{field_name} 不能包含路径分隔符")
return clean_name
def _resolve_parent_directory(user: User, parent_path: str) -> Path:
parent = _resolve_workspace_path(user, parent_path)
if not parent.exists():
raise HTTPException(status_code=404, detail="目标目录不存在")
if not parent.is_dir():
raise HTTPException(status_code=400, detail="目标路径不是目录")
return parent
def _resolve_new_child(root: Path, parent: Path, name: str) -> Path:
target = parent / name
try:
target.resolve(strict=False).relative_to(root)
except ValueError as exc:
raise HTTPException(status_code=403, detail="Access denied") from exc
if target.exists():
raise HTTPException(status_code=400, detail="同名文件或文件夹已存在")
return target
def _list_directory(root: Path, target: Path, *, recursive: bool = False, files_only: bool = False) -> list[dict]:
children = list(target.iterdir())
entries = [_entry_for_path(root, child) for child in children if not files_only or child.is_file()]
if recursive:
for child in children:
if child.is_dir() and not child.is_symlink():
entries.extend(_list_directory(root, child, recursive=True, files_only=files_only))
return _sort_entries(entries)
async def list_workspace_tree(
*, path: str, recursive: bool = False, files_only: bool = False, current_user: User
) -> dict:
root = _workspace_root(current_user)
target = _resolve_workspace_path(current_user, path)
if not target.exists():
return {"entries": []}
if not target.is_dir():
raise HTTPException(status_code=400, detail="当前路径不是目录")
entries = await asyncio.to_thread(_list_directory, root, target, recursive=recursive, files_only=files_only)
return {"entries": entries}
def resolve_workspace_file_path(*, path: str, current_user: User) -> Path:
target = _resolve_workspace_path(current_user, path)
if not target.exists():
raise HTTPException(status_code=404, detail=f"工作区文件不存在: {path}")
if not target.is_file():
raise HTTPException(status_code=400, detail=f"当前路径不是文件: {path}")
return target
async def read_workspace_file_content(*, path: str, current_user: User) -> dict | StreamingResponse:
target = _resolve_workspace_path(current_user, path)
if not target.exists():
raise HTTPException(status_code=404, detail="文件不存在")
if not target.is_file():
raise HTTPException(status_code=400, detail="当前路径是目录")
stat = await asyncio.to_thread(target.stat)
if stat.st_size > MAX_BINARY_PREVIEW_SIZE_BYTES:
return render_preview_too_large_payload()
if is_office_pdf_preview_file(path):
pdf_content = await _convert_workspace_office_to_pdf(current_user, target, target.name)
return _preview_binary_response(
filename=f"{target.stem or 'preview'}.pdf",
content=pdf_content,
media_type="application/pdf",
preview_type="pdf",
)
raw_content = await asyncio.to_thread(target.read_bytes)
preview_type, supported, message = detect_preview_type(path, raw_content)
if is_binary_preview_type(preview_type) and supported:
return _preview_binary_response(
filename=target.name or "preview",
content=raw_content,
media_type=detect_media_type(path, raw_content),
preview_type=preview_type,
)
if not supported:
return {
"content": None,
"preview_type": preview_type,
"supported": False,
"message": message,
"truncated": False,
"limit": None,
}
return render_preview_payload(path, raw_content)
def _preview_binary_response(*, filename: str, content: bytes, media_type: str, preview_type: str) -> StreamingResponse:
headers = {
"Content-Disposition": f"inline; filename*=UTF-8''{quote(filename)}",
"X-Yuxi-Preview-Type": preview_type,
"X-Yuxi-Preview-Filename": quote(filename),
}
return StreamingResponse(io.BytesIO(content), media_type=media_type, headers=headers)
async def write_workspace_file_content(*, path: str, content: str, current_user: User) -> dict:
root = _workspace_root(current_user)
target = _resolve_workspace_path(current_user, path)
if not target.exists():
raise HTTPException(status_code=404, detail="文件不存在")
if not target.is_file():
raise HTTPException(status_code=400, detail="当前路径是目录")
if target.suffix.lower() not in EDITABLE_WORKSPACE_SUFFIXES:
raise HTTPException(status_code=400, detail="当前文件类型不支持编辑")
raw_content = await asyncio.to_thread(target.read_bytes)
preview_type, supported, _message = detect_preview_type(path, raw_content)
if preview_type not in {"markdown", "text"} or not supported:
raise HTTPException(status_code=400, detail="当前文件类型不支持编辑")
try:
raw_content.decode("utf-8")
except UnicodeDecodeError as exc:
raise HTTPException(status_code=400, detail="当前文件不是 UTF-8 文本") from exc
try:
await asyncio.to_thread(target.write_text, content, encoding="utf-8")
except PermissionError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return {
"success": True,
"path": _normalize_workspace_path(path).as_posix(),
"entry": _entry_for_path(root, target),
}
async def delete_workspace_path(*, path: str, current_user: User) -> dict:
root = _workspace_root(current_user)
target = _resolve_workspace_path(current_user, path)
if target == root:
raise HTTPException(status_code=400, detail="工作区根目录不允许删除")
if not target.exists():
raise HTTPException(status_code=404, detail="文件不存在")
try:
if target.is_dir():
await asyncio.to_thread(shutil.rmtree, target)
else:
await asyncio.to_thread(target.unlink)
except PermissionError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
await invalidate_workspace_mention_cache(str(current_user.uid))
return {"success": True, "path": _normalize_workspace_path(path).as_posix()}
async def create_workspace_directory(*, parent_path: str, name: str, current_user: User) -> dict:
root = _workspace_root(current_user)
directory_name = _validate_child_name(name, field_name="文件夹名")
parent = _resolve_parent_directory(current_user, parent_path)
target = _resolve_new_child(root, parent, directory_name)
try:
await asyncio.to_thread(target.mkdir)
except FileExistsError as exc:
raise HTTPException(status_code=400, detail="同名文件或文件夹已存在") from exc
except PermissionError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
await invalidate_workspace_mention_cache(str(current_user.uid))
return {"success": True, "entry": _entry_for_path(root, target)}
async def _write_workspace_upload(file: UploadFile, target: Path) -> None:
created_file = False
upload_completed = False
try:
async with aiofiles.open(target, "xb") as buffer:
created_file = True
await write_upload_to_buffer(
file,
buffer,
max_size_bytes=MAX_WORKSPACE_UPLOAD_SIZE_BYTES,
too_large_message="文件过大,当前仅支持 100 MB 以内的文件",
)
upload_completed = True
except FileExistsError as exc:
raise HTTPException(status_code=400, detail="同名文件或文件夹已存在") from exc
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except PermissionError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
finally:
if created_file and not upload_completed and target.exists():
with contextlib.suppress(OSError):
await asyncio.to_thread(target.unlink)
async def upload_workspace_files(*, parent_path: str, files: list[UploadFile], current_user: User) -> dict:
if not files:
raise HTTPException(status_code=400, detail="请选择至少一个文件")
if len(files) > MAX_WORKSPACE_UPLOAD_FILES:
raise HTTPException(status_code=400, detail=f"一次最多上传 {MAX_WORKSPACE_UPLOAD_FILES} 个文件")
root = _workspace_root(current_user)
parent = _resolve_parent_directory(current_user, parent_path)
seen_names = set()
upload_targets: list[tuple[UploadFile, Path]] = []
for file in files:
file_name = _validate_child_name(Path(file.filename or "").name, field_name="文件名")
if file_name in seen_names:
raise HTTPException(status_code=400, detail=f"选择的文件中存在重复文件名: {file_name}")
seen_names.add(file_name)
upload_targets.append((file, _resolve_new_child(root, parent, file_name)))
completed_targets: list[Path] = []
try:
for file, target in upload_targets:
await _write_workspace_upload(file, target)
completed_targets.append(target)
except HTTPException:
for target in completed_targets:
with contextlib.suppress(OSError):
await asyncio.to_thread(target.unlink)
raise
await invalidate_workspace_mention_cache(str(current_user.uid))
return {"success": True, "entries": [_entry_for_path(root, target) for _file, target in upload_targets]}
async def _convert_workspace_office_to_pdf(user: User, target: Path, file_name: str) -> bytes:
user_data_root = _global_user_data_dir(str(user.uid)).resolve()
cache_dir = user_data_root / ".office_preview_cache"
stat = await asyncio.to_thread(target.stat)
digest = hashlib.sha256(str(target).encode("utf-8")).hexdigest()
cache_path = cache_dir / f"{digest}-{stat.st_mtime_ns}-{stat.st_size}.pdf"
cached = await asyncio.to_thread(lambda: cache_path.read_bytes() if cache_path.exists() else None)
if cached is not None:
return cached
content = await asyncio.to_thread(target.read_bytes)
try:
pdf_content = await convert_office_to_pdf(file_name, content)
except OfficePreviewConversionError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
await asyncio.to_thread(_store_office_pdf_cache, cache_dir, digest, cache_path, pdf_content)
return pdf_content
def _store_office_pdf_cache(cache_dir: Path, digest: str, cache_path: Path, pdf_content: bytes) -> None:
cache_dir.mkdir(parents=True, exist_ok=True)
for stale in cache_dir.glob(f"{digest}-*.pdf"):
if stale != cache_path:
stale.unlink(missing_ok=True)
cache_path.write_bytes(pdf_content)
async def download_workspace_file(*, path: str, current_user: User) -> StreamingResponse | FileResponse:
target = _resolve_workspace_path(current_user, path)
if not target.exists():
raise HTTPException(status_code=404, detail="文件不存在")
if not target.is_file():
raise HTTPException(status_code=400, detail="当前路径是目录")
file_name = target.name or "download"
media_type = detect_media_type(file_name)
headers = {"Content-Disposition": f"attachment; filename*=UTF-8''{quote(file_name)}"}
if target.stat().st_size > 1024 * 1024 * 16:
return FileResponse(path=target, media_type=media_type, headers=headers)
content = await asyncio.to_thread(target.read_bytes)
return StreamingResponse(io.BytesIO(content), media_type=media_type, headers=headers)