blender-render-studio / uploads.py
cpuai's picture
Deploy Blender Render Studio with Gradio API and MCP
94a0de3 verified
Raw History Blame Contribute Delete
10.2 kB
"""安全复制 Blender 项目及资源 ZIP;任何上传内容都不会被执行。"""
from __future__ import annotations
import os
import shutil
import stat
import tempfile
import zipfile
from pathlib import Path
from typing import BinaryIO
from render_config import positive_env_int, safe_relative_path
_CHUNK_SIZE = 1024 * 1024
_SCRIPT_SUFFIXES = {".py", ".pyc", ".pyo", ".pyw"}
_TAR_SUFFIXES = (".tar", ".tar.gz", ".tar.bz2", ".tar.xz", ".tgz", ".tbz", ".tbz2", ".txz")
class UploadError(ValueError):
"""可直接向调用者展示的上传错误。"""
class _Staging:
def __init__(self, root: Path, byte_limit: int, file_limit: int):
self.root = root
self.byte_limit = byte_limit
self.file_limit = file_limit
self.bytes_written = 0
self.entry_count = 0
# 文件和文件夹共用名称空间,大小写不同也视为冲突。
self.entries: dict[str, tuple[str, bool, bool]] = {}
self.blends: list[str] = []
def register(self, name: str, directory: bool = False) -> Path:
self.entry_count += 1
if self.entry_count > self.file_limit:
raise UploadError("上传条目数超过服务端 MAX_ARCHIVE_FILES 限制。")
try:
safe_relative_path(name)
except ValueError as exc:
raise UploadError(f"不安全的资源路径 {name!r}:{exc}") from exc
if not directory and Path(name).suffix.casefold() in _SCRIPT_SUFFIXES:
raise UploadError("不允许上传或解压 Python 脚本文件。")
if not directory and name.casefold().endswith(_TAR_SUFFIXES):
raise UploadError("不支持 TAR 压缩包,请使用 ZIP。")
components = name.split("/")
for index in range(1, len(components)):
parent = "/".join(components[:index])
folded = parent.casefold()
existing = self.entries.get(folded)
if existing and (existing[0] != parent or not existing[1]):
raise UploadError(f"路径存在大小写或文件/文件夹冲突:{parent}")
if not existing:
self.entries[folded] = (parent, True, False)
folded = name.casefold()
existing = self.entries.get(folded)
# 已创建的隐式父文件夹允许一次显式 ZIP 目录条目。
if existing and not (
directory and existing == (name, True, False)
):
raise UploadError(f"上传包含重复或冲突路径:{name}")
self.entries[folded] = (name, directory, True)
target = self.root.joinpath(*components)
if directory:
target.mkdir(parents=True, exist_ok=True)
else:
target.parent.mkdir(parents=True, exist_ok=True)
if name.casefold().endswith(".blend"):
self.blends.append(name)
return target
def copy_stream(self, source: BinaryIO, target: Path, declared_size: int) -> None:
if declared_size < 0 or self.bytes_written + declared_size > self.byte_limit:
raise UploadError("解压/暂存总大小超过服务端 MAX_EXTRACT_MB 限制。")
actual = 0
with target.open("xb") as output:
while chunk := source.read(_CHUNK_SIZE):
actual += len(chunk)
self.bytes_written += len(chunk)
if self.bytes_written > self.byte_limit or actual > declared_size:
raise UploadError("文件实际大小超过声明值或服务端大小限制。")
output.write(chunk)
if actual != declared_size:
raise UploadError("上传文件不完整,实际大小与声明值不一致。")
def unpack(self, source: BinaryIO) -> None:
with zipfile.ZipFile(source) as archive:
infos = archive.infolist()
if len(infos) + self.entry_count > self.file_limit:
raise UploadError("压缩包条目数超过服务端 MAX_ARCHIVE_FILES 限制。")
for info in infos:
# ZipInfo.filename 会截断 NUL,必须检查保存的原始名称。
original = getattr(info, "orig_filename", info.filename)
if original != info.filename or "\x00" in original:
raise UploadError("ZIP 文件名包含 NUL 字符。")
mode = (info.external_attr >> 16) & 0xFFFF
kind = stat.S_IFMT(mode)
if kind not in (0, stat.S_IFREG, stat.S_IFDIR):
raise UploadError("ZIP 中不允许符号链接或特殊文件。")
directory = info.is_dir()
if kind == stat.S_IFDIR and not directory:
raise UploadError("ZIP 文件夹属性与路径不一致。")
if info.flag_bits & 1:
raise UploadError("不支持加密 ZIP。")
if info.file_size / max(info.compress_size, 1) > 500:
raise UploadError("ZIP 压缩比超过 500,拒绝潜在压缩炸弹。")
name = original[:-1] if directory else original
target = self.register(name, directory)
if directory:
if info.file_size:
raise UploadError("ZIP 文件夹条目不能包含文件数据。")
continue
# 内嵌 ZIP 作为普通资源复制,不递归解压。
with archive.open(info, "r") as member:
self.copy_stream(member, target, info.file_size)
def _open_regular_file(source: Path) -> BinaryIO:
source_mode = source.lstat().st_mode
if stat.S_ISLNK(source_mode):
raise UploadError("不允许上传符号链接。")
if not stat.S_ISREG(source_mode):
raise UploadError("上传对象必须是现有普通文件。")
# NONBLOCK 避免检查后被替换为 FIFO 的路径阻塞任务。
flags = (os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)
| getattr(os, "O_NONBLOCK", 0) | getattr(os, "O_BINARY", 0))
descriptor = os.open(source, flags)
try:
if not stat.S_ISREG(os.fstat(descriptor).st_mode):
raise UploadError("上传对象必须是现有普通文件。")
return os.fdopen(descriptor, "rb")
except BaseException:
os.close(descriptor)
raise
def stage_uploads(files: list[str], project_dir: Path, blend_file: str | None) -> Path:
"""原子暂存项目,成功返回所选 .blend 的绝对路径。
project_dir 必须不存在或为空。失败仅清理本次创建的临时目录,
不会删除调用者已有文件;项目目录不会出现部分解压结果。
"""
project_dir = Path(project_dir).absolute()
temporary: Path | None = None
try:
file_limit = positive_env_int("MAX_ARCHIVE_FILES", 5000)
upload_limit = positive_env_int("MAX_UPLOAD_MB", 1024) * 1024 * 1024
extract_limit = positive_env_int("MAX_EXTRACT_MB", 4096) * 1024 * 1024
if not files:
raise UploadError("请上传 .blend 文件及所需资源,或包含项目的 ZIP。")
if len(files) > file_limit:
raise UploadError("上传文件数量超过服务端 MAX_ARCHIVE_FILES 限制。")
if blend_file is not None:
safe_relative_path(blend_file)
if not blend_file.casefold().endswith(".blend"):
raise UploadError("blend_file 必须指定 .blend 文件。")
if project_dir.is_symlink():
raise UploadError("项目目录不能是符号链接。")
if project_dir.exists() and (
not project_dir.is_dir() or any(project_dir.iterdir())
):
raise UploadError("项目目录必须为空,不能覆盖已有文件。")
project_dir.parent.mkdir(parents=True, exist_ok=True)
temporary = Path(tempfile.mkdtemp(prefix=f".{project_dir.name}.upload-", dir=project_dir.parent))
staging = _Staging(temporary, extract_limit, file_limit)
uploaded_bytes = 0
upload_names: set[str] = set()
for file_path in files:
source = Path(file_path)
name = source.name
safe_relative_path(name)
if name.casefold() in upload_names:
raise UploadError(f"上传文件名称重复:{name}")
upload_names.add(name.casefold())
if source.suffix.casefold() in _SCRIPT_SUFFIXES:
raise UploadError("不允许上传 Python 脚本文件。")
if name.casefold().endswith(_TAR_SUFFIXES):
raise UploadError("不支持 TAR 压缩包,请使用 ZIP。")
with _open_regular_file(source) as handle:
size = os.fstat(handle.fileno()).st_size
uploaded_bytes += size
if uploaded_bytes > upload_limit:
raise UploadError("上传总大小超过服务端 MAX_UPLOAD_MB 限制。")
if source.suffix.casefold() == ".zip":
staging.unpack(handle)
else:
staging.copy_stream(handle, staging.register(name), size)
if not staging.blends:
raise UploadError("上传内容中找不到 .blend 文件。")
if blend_file is None:
if len(staging.blends) != 1:
raise UploadError("存在多个 .blend 文件,请通过 blend_file 指定相对路径。")
selected = staging.blends[0]
else:
if blend_file not in staging.blends:
raise UploadError(f"找不到 blend_file 指定的文件:{blend_file}")
selected = blend_file
if project_dir.exists():
project_dir.rmdir() # 只移除空目录;发生竞争写入时安全失败。
os.replace(temporary, project_dir)
temporary = None
return project_dir.joinpath(*selected.split("/"))
except UploadError:
raise
except (OSError, ValueError, RuntimeError, zipfile.BadZipFile, NotImplementedError) as exc:
raise UploadError(f"无法暂存上传项目:{exc}") from exc
finally:
if temporary is not None:
shutil.rmtree(temporary, ignore_errors=True)