Spaces:
Running on Zero
Running on Zero
Download uploads.py from cpuai/blender-render-studio: direct link, hf CLI and curl.
- Browser
- Download file 10.2 kB
-
https://huggingface.co/spaces/cpuai/blender-render-studio/resolve/main/uploads.py
- Command line
-
hf download hf://spaces/cpuai/blender-render-studio/uploads.py
-
curl -L -o uploads.py https://huggingface.co/spaces/cpuai/blender-render-studio/resolve/main/uploads.py
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) | |