feat: 完善服务端工程化、发布流程和自动化测试
This commit is contained in:
@@ -10,6 +10,8 @@ from app.services.signing_service import MANIFEST_PRIVATE_KEY_PATH
|
||||
|
||||
|
||||
def license_key_encryption_material() -> bytes:
|
||||
# 数据库只存 License Key 的密文和 hash。
|
||||
# hash 用于客户端提交 License 时快速匹配;密文用于后台列表需要再次展示原始 Key 的场景。
|
||||
configured_secret = os.getenv("LICENSE_KEY_ENCRYPTION_SECRET", "").strip()
|
||||
if configured_secret:
|
||||
return configured_secret.encode("utf-8")
|
||||
|
||||
@@ -108,6 +108,8 @@ def should_skip_release_directory(part: str) -> bool:
|
||||
|
||||
def should_skip_release_file(relative_path: str) -> bool:
|
||||
folded = relative_path.casefold()
|
||||
# 发布包里不能带客户端本机运行态文件。
|
||||
# 这些文件包含 License、设备身份、策略缓存等机器相关数据,应由客户端运行时生成或服务端重新签发。
|
||||
runtime_protected_files = {
|
||||
"client.ini",
|
||||
"bootstrap.exe",
|
||||
@@ -163,6 +165,8 @@ def supported_archive_kind(filename: str) -> str:
|
||||
|
||||
|
||||
def ensure_inside_directory(root: Path, relative_path: str) -> Path:
|
||||
# 解压压缩包时必须防止 Zip Slip / 路径穿越。
|
||||
# 任何 ../ 或绝对路径都不能写出临时解压目录。
|
||||
target = (root / relative_path).resolve()
|
||||
root_resolved = root.resolve()
|
||||
if target != root_resolved and root_resolved not in target.parents:
|
||||
@@ -304,6 +308,8 @@ def release_items_from_extracted_dir(extract_dir: Path, required_main_executable
|
||||
|
||||
|
||||
def normalize_release_item_roots(items: list[dict], required_main_executable: str):
|
||||
# 有些压缩包会多包一层顶级目录,例如 SimCAE-1.0.0/bin/SimCAE.exe。
|
||||
# 如果所有文件都在同一个顶级目录下,并且能找到主程序,就自动剥掉这一层,降低发布者操作成本。
|
||||
if not items:
|
||||
return items
|
||||
required_main_key = required_main_executable.casefold()
|
||||
@@ -417,6 +423,7 @@ async def close_upload_safely(upload):
|
||||
|
||||
def ensure_publish_storage_space(next_file_size: int):
|
||||
required_bytes = max(next_file_size, 0) + UPLOAD_SPACE_RESERVE
|
||||
MINIO_DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
available_bytes = shutil.disk_usage(MINIO_DATA_DIR).free
|
||||
if available_bytes < required_bytes:
|
||||
raise HTTPException(
|
||||
@@ -431,6 +438,8 @@ def ensure_publish_storage_space(next_file_size: int):
|
||||
|
||||
|
||||
async def collect_publish_items(request: Request):
|
||||
# 发布入口支持两种方式:浏览器选择 Release 目录,或上传一个压缩发布包。
|
||||
# 这里先做请求大小、临时目录空间和 MinIO 存储空间检查,避免大包把服务器拖死。
|
||||
content_length_header = request.headers.get("content-length")
|
||||
try:
|
||||
content_length = int(content_length_header or 0)
|
||||
@@ -595,6 +604,7 @@ async def collect_publish_items(request: Request):
|
||||
"upload_items": upload_items,
|
||||
"archive_upload": archive_upload,
|
||||
"archive_temp_dir": archive_temp_dir,
|
||||
"job_temp_dir": None,
|
||||
}
|
||||
|
||||
|
||||
@@ -610,11 +620,55 @@ def cleanup_path(path: Path | None):
|
||||
print(f"清理临时文件失败: {path}: {err}")
|
||||
|
||||
|
||||
async def publish_version(request: Request):
|
||||
async def make_background_publish_payload(request: Request):
|
||||
# 后台发布任务不能继续持有浏览器上传流,所以目录模式要先复制到服务端临时目录。
|
||||
# 压缩包模式在 collect_publish_items 中已经解压为本地文件,只需要保留解压目录到任务结束。
|
||||
payload = await collect_publish_items(request)
|
||||
job_temp_dir = Path(tempfile.mkdtemp(prefix="publish_job_", dir=UPLOAD_SPOOL_DIR))
|
||||
converted_items = []
|
||||
|
||||
try:
|
||||
for item in payload["upload_items"]:
|
||||
if "upload" not in item:
|
||||
converted_items.append(item)
|
||||
continue
|
||||
upload = item["upload"]
|
||||
target = ensure_inside_directory(job_temp_dir, item["path"])
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
upload.file.seek(0)
|
||||
with target.open("wb") as output:
|
||||
shutil.copyfileobj(upload.file, output, length=1024 * 1024)
|
||||
except OSError as err:
|
||||
raise HTTPException(status_code=500, detail={"error": "publish_job_spool_failed", "msg": str(err)})
|
||||
finally:
|
||||
await close_upload_safely(upload)
|
||||
converted_items.append({"path": item["path"], "local_path": target})
|
||||
payload["upload_items"] = converted_items
|
||||
payload["job_temp_dir"] = job_temp_dir
|
||||
payload["archive_upload"] = None
|
||||
return payload
|
||||
except Exception:
|
||||
for item in payload.get("upload_items", []):
|
||||
upload = item.get("upload") if isinstance(item, dict) else None
|
||||
if upload is not None:
|
||||
await close_upload_safely(upload)
|
||||
archive_upload = payload.get("archive_upload")
|
||||
if archive_upload is not None:
|
||||
await close_upload_safely(archive_upload)
|
||||
cleanup_path(payload.get("archive_temp_dir"))
|
||||
cleanup_path(job_temp_dir)
|
||||
raise
|
||||
|
||||
|
||||
async def publish_payload(payload: dict):
|
||||
# 完整发布事务:
|
||||
# 1. 使用已收集的发布文件;2. 创建版本记录;3. 上传文件到 MinIO;4. 写入文件 hash/size。
|
||||
# 任何一步失败都会回滚数据库,并删除已经上传的对象前缀,避免出现“半个版本”。
|
||||
upload_items = payload["upload_items"]
|
||||
archive_upload = payload["archive_upload"]
|
||||
archive_temp_dir = payload["archive_temp_dir"]
|
||||
job_temp_dir = payload.get("job_temp_dir")
|
||||
app_id = payload["app_id"]
|
||||
channel = payload["channel"]
|
||||
version = payload["version"]
|
||||
@@ -689,3 +743,9 @@ async def publish_version(request: Request):
|
||||
if archive_upload is not None:
|
||||
await close_upload_safely(archive_upload)
|
||||
cleanup_path(archive_temp_dir)
|
||||
cleanup_path(job_temp_dir)
|
||||
|
||||
|
||||
async def publish_version(request: Request):
|
||||
payload = await collect_publish_items(request)
|
||||
return await publish_payload(payload)
|
||||
|
||||
@@ -21,11 +21,15 @@ def load_manifest_private_key():
|
||||
|
||||
|
||||
def canonical_manifest_bytes(manifest_obj: dict) -> bytes:
|
||||
# 签名之前必须先规范化 JSON:固定字段顺序、去掉空格、排除 signature 字段。
|
||||
# 否则同一份 Manifest 在不同环境下序列化结果不同,会导致客户端验签失败。
|
||||
copy = {k: manifest_obj[k] for k in manifest_obj if k != "signature"}
|
||||
return json.dumps(copy, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
|
||||
|
||||
|
||||
def sign_manifest(manifest_obj: dict) -> str:
|
||||
# 服务端只保存私钥,客户端只分发公钥。
|
||||
# 客户端能验证 Manifest 确实由服务端签发,但无法伪造新的签名。
|
||||
json_text = canonical_manifest_bytes(manifest_obj)
|
||||
private_key = load_manifest_private_key()
|
||||
signature = private_key.sign(json_text, padding.PKCS1v15(), hashes.SHA256())
|
||||
|
||||
Reference in New Issue
Block a user