File size: 10,242 Bytes
94a0de3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
"""安全复制 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)