feat(server): modularize backend and admin console

This commit is contained in:
2026-07-14 01:38:41 +00:00
parent 9a6ac1e4ed
commit 95dd32c75a
261 changed files with 39289 additions and 2156 deletions
+1
View File
@@ -0,0 +1 @@
"""SimCAE update server backend package."""
+1
View File
@@ -0,0 +1 @@
"""API route package."""
+33
View File
@@ -0,0 +1,33 @@
from fastapi import FastAPI
from app.api.routes import (
admin_app_channel,
admin_config,
admin_crash_report,
admin_device,
admin_license,
admin_logs,
admin_policy,
admin_publish,
admin_version,
client_update,
crash_api,
frontend,
)
def include_api_routes(app: FastAPI):
frontend.mount_admin_assets(app)
crash_api.register_exception_handlers(app)
app.include_router(frontend.router)
app.include_router(admin_policy.router)
app.include_router(admin_version.router)
app.include_router(admin_license.router)
app.include_router(admin_config.router)
app.include_router(admin_publish.router)
app.include_router(admin_app_channel.router)
app.include_router(admin_logs.router)
app.include_router(admin_crash_report.router)
app.include_router(admin_device.router)
app.include_router(client_update.router)
app.include_router(crash_api.router)
+1
View File
@@ -0,0 +1 @@
"""API route modules."""
+45
View File
@@ -0,0 +1,45 @@
from fastapi import APIRouter, Depends, HTTPException
from app.core.security import admin_auth
from app.repositories import app_channel_repository
from app.schemas.app_channel import AppCreateRequest, ChannelSaveRequest
from app.services.common_service import validate_channel_code
router = APIRouter(tags=["admin-app-channel"])
@router.get("/admin/app/list")
def admin_get_app_list(auth=Depends(admin_auth)):
return {"list": app_channel_repository.list_apps()}
@router.post("/admin/app/add")
def admin_add_app(body: AppCreateRequest, auth=Depends(admin_auth)):
aid = body.app_id
aname = body.app_name
try:
app_channel_repository.add_app_with_default_channels(aid, aname)
except Exception as e:
return {"msg": f"创建失败:{str(e)}"}
return {"msg": "应用创建成功"}
@router.get("/admin/channel/list")
def admin_channel_list(app_id: str, include_disabled: bool = True, auth=Depends(admin_auth)):
return {"list": app_channel_repository.list_channels(app_id, include_disabled)}
@router.post("/admin/channel/save")
def admin_channel_save(body: ChannelSaveRequest, auth=Depends(admin_auth)):
app_id = body.app_id.strip()
code = body.channel_code.strip()
name = body.display_name.strip()
enabled = bool(body.enabled)
order = int(body.sort_order or 100)
if not app_id or not validate_channel_code(code) or not name or len(name) > 64:
raise HTTPException(status_code=400, detail="渠道参数无效;代码仅允许字母、数字、下划线和连字符")
if not app_channel_repository.app_exists(app_id):
raise HTTPException(status_code=404, detail="应用不存在")
app_channel_repository.save_channel(app_id, code, name, enabled, order)
return {"msg": "渠道已保存"}
+110
View File
@@ -0,0 +1,110 @@
import json
import os
from fastapi import APIRouter, Depends, HTTPException, Request
from app.core.config import ENV_FILE_PATH
from app.core.security import admin_auth, persist_env_value, set_admin_token
from app.repositories import app_channel_repository
from app.schemas.admin_config import ChangeAdminTokenRequest, ClientConfigGenerateRequest
from app.services.admin_config_service import (
PUBLISH_MAX_FIELDS,
PUBLISH_MAX_FILES,
PUBLISH_MAX_REQUEST_BYTES,
RELEASE_MAIN_EXECUTABLE,
TARGET_ARCH,
TARGET_PLATFORM,
default_client_config_values,
normalize_executable_name,
public_api_base_url,
)
from app.services.common_service import validate_channel_code
router = APIRouter(tags=["admin-config"])
@router.get("/admin/auth/check")
def admin_auth_check(auth=Depends(admin_auth)):
return {"code": 0, "msg": "管理员令牌有效"}
@router.post("/admin/token/change")
def admin_change_token(body: ChangeAdminTokenRequest, auth=Depends(admin_auth)):
new_token = body.new_token.strip()
if len(new_token) < 8:
raise HTTPException(status_code=400, detail="新令牌至少需要 8 个字符")
if len(new_token) > 128:
raise HTTPException(status_code=400, detail="新令牌不能超过 128 个字符")
try:
persist_env_value(ENV_FILE_PATH, "ADMIN_TOKEN", new_token)
except Exception as exc:
raise HTTPException(status_code=500, detail=f"令牌已通过校验,但写入 .env 失败: {exc}")
set_admin_token(new_token)
os.environ["ADMIN_TOKEN"] = new_token
return {"code": 0, "msg": f"管理员令牌已更新,并已写入 {ENV_FILE_PATH}"}
@router.get("/admin/runtime-config")
def admin_runtime_config(request: Request, auth=Depends(admin_auth)):
return {
"release_main_executable": RELEASE_MAIN_EXECUTABLE,
"target_platform": TARGET_PLATFORM,
"target_arch": TARGET_ARCH,
"publish_max_request_bytes": PUBLISH_MAX_REQUEST_BYTES,
"publish_max_files": PUBLISH_MAX_FILES,
"publish_max_fields": PUBLISH_MAX_FIELDS,
"api_base_url": public_api_base_url(request),
"default_client_config": default_client_config_values(request),
}
@router.post("/admin/client-config/generate")
def admin_generate_client_config(body: ClientConfigGenerateRequest, request: Request, auth=Depends(admin_auth)):
app_id = body.app_id.strip()
channel = body.channel.strip() or "stable"
current_version = body.current_version.strip() or "1.0.0"
client_protocol = max(1, int(body.client_protocol or 3))
api_base_url = (body.api_base_url.strip() or public_api_base_url(request)).rstrip("/")
if not app_id:
raise HTTPException(status_code=400, detail="请先选择或填写 App ID")
if not validate_channel_code(channel):
raise HTTPException(status_code=400, detail="渠道代码无效")
if not api_base_url.startswith(("http://", "https://")):
raise HTTPException(status_code=400, detail="api_base_url 必须以 http:// 或 https:// 开头")
app_row = app_channel_repository.get_app(app_id)
if not app_row:
raise HTTPException(status_code=404, detail="应用不存在,请先在应用管理中创建")
channel_row = app_channel_repository.get_channel(app_id, channel)
if not channel_row:
raise HTTPException(status_code=400, detail={"error": "channel_not_found", "msg": f"渠道 {channel} 不存在"})
defaults = default_client_config_values(request)
main_executable = normalize_executable_name(body.main_executable) or defaults["main_executable"]
config = {
"app_id": app_id,
"app_name": app_row["app_name"],
"channel": channel,
"current_version": current_version,
"client_protocol": str(client_protocol),
"launch_token": defaults["launch_token"],
"license_key": body.license_key.strip(),
"api_base_url": api_base_url,
"client_token": defaults["client_token"],
"request_timeout_ms": defaults["request_timeout_ms"],
"temp_folder": defaults["temp_folder"],
"device_id": "",
"install_root": defaults["install_root"],
"main_executable": main_executable,
"launcher_executable": defaults["launcher_executable"],
"updater_executable": defaults["updater_executable"],
"bootstrap_executable": defaults["bootstrap_executable"],
"health_check_timeout_ms": defaults["health_check_timeout_ms"],
"platform": defaults["platform"],
"arch": defaults["arch"],
}
return {
"config": config,
"json_text": json.dumps(config, ensure_ascii=False, indent=2),
}
+56
View File
@@ -0,0 +1,56 @@
from pathlib import Path
from fastapi import APIRouter, Depends, Header, HTTPException, Request
from fastapi.responses import FileResponse
from app.core.security import admin_auth, token_digest
from app.repositories import crash_report_repository
router = APIRouter(tags=["admin-crash-report"])
@router.get("/admin/crash-report/list")
def admin_crash_report_list(limit: int = 300, auth=Depends(admin_auth)):
limit = max(1, min(limit, 1000))
return {"list": crash_report_repository.list_crash_reports(limit)}
@router.get("/admin/crash-report/file/{report_id}/{file_name}")
def admin_crash_report_file(
report_id: str,
file_name: str,
request: Request,
X_Admin_Token: str = Header(""),
auth=Depends(admin_auth),
):
allowed = {
"metadata": "metadata.json",
"metadata.json": "metadata.json",
"minidump": "crash.dmp",
"crash.dmp": "crash.dmp",
"attachments": "attachments.zip",
"attachments.zip": "attachments.zip",
"server": "server.json",
"server.json": "server.json",
}
stored_name = allowed.get(file_name)
if not stored_name:
raise HTTPException(status_code=404, detail="崩溃报告文件不存在")
row = crash_report_repository.get_crash_report(report_id)
if not row:
raise HTTPException(status_code=404, detail="崩溃报告不存在")
target = Path(row["storage_path"]) / stored_name
if not target.is_file():
raise HTTPException(status_code=404, detail="崩溃报告文件不存在")
crash_report_repository.log_file_access(
report_id,
stored_name,
token_digest(X_Admin_Token)[:16],
request.client.host if request.client else "",
request.headers.get("user-agent", "")[:300],
)
return FileResponse(target, media_type="application/octet-stream", filename=stored_name)
+24
View File
@@ -0,0 +1,24 @@
from fastapi import APIRouter, Depends, HTTPException
from app.core.security import admin_auth
from app.repositories import device_repository
from app.schemas.device import DeviceSetDisabledRequest
router = APIRouter(tags=["admin-device"])
@router.get("/admin/device/list")
def admin_device_list(app_id: str = "", auth=Depends(admin_auth)):
return {"list": device_repository.list_devices(app_id)}
@router.post("/admin/device/set-disabled")
def admin_device_set_disabled(body: DeviceSetDisabledRequest, auth=Depends(admin_auth)):
device_id = body.device_id.strip()
disabled = bool(body.disabled)
reason = body.reason.strip()
updated = device_repository.set_device_disabled(device_id, disabled, reason)
if updated != 1:
raise HTTPException(status_code=404, detail="设备不存在")
return {"msg": "设备已禁用" if disabled else "设备已恢复", "device_id": device_id}
+81
View File
@@ -0,0 +1,81 @@
import secrets
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException
from app.core.security import admin_auth, token_digest
from app.repositories import license_repository
from app.schemas.license import LicenseCreateRequest, LicenseDeleteRequest, LicenseStatusRequest
from app.services.common_service import validate_channel_code
from app.services.license_service import decrypt_license_key, encrypt_license_key
router = APIRouter(tags=["admin-license"])
@router.post("/admin/license/create")
def admin_license_create(body: LicenseCreateRequest, auth=Depends(admin_auth)):
app_id = body.app_id.strip()
channel = body.channel.strip()
customer = body.customer_name.strip()
max_devices = int(body.max_devices or 1)
valid_until = body.valid_until.strip()
try:
expiry = datetime.fromisoformat(valid_until.replace("Z", "+00:00"))
except ValueError:
raise HTTPException(status_code=400, detail="valid_until 必须是 ISO-8601 时间")
if not app_id or not customer or not validate_channel_code(channel) or max_devices < 1 or expiry <= datetime.now(timezone.utc):
raise HTTPException(status_code=400, detail="授权参数无效")
license_id = "lic_" + secrets.token_hex(12)
license_key = "MARSCO-" + secrets.token_urlsafe(24)
result = license_repository.insert_license(
license_id,
token_digest(license_key),
encrypt_license_key(license_key),
customer,
app_id,
channel,
max_devices,
valid_until,
)
if result == "channel_not_found":
raise HTTPException(status_code=400, detail={"error": "channel_not_found", "msg": f"渠道 {channel} 不存在"})
if result == "channel_disabled":
raise HTTPException(status_code=403, detail={"error": "channel_disabled", "msg": f"渠道 {channel} 已停用"})
return {"license_id": license_id, "license_key": license_key, "msg": "授权已创建;密钥已加密保存,可在授权列表中再次查看"}
@router.get("/admin/license/list")
def admin_license_list(app_id: str = "", include_deleted: bool = False, auth=Depends(admin_auth)):
rows = license_repository.list_licenses(app_id, include_deleted)
result = []
for r in rows:
license_key = decrypt_license_key(r["license_key_cipher"] if "license_key_cipher" in r.keys() else "")
result.append(
{k: r[k] for k in ("license_id", "customer_name", "app_id", "channel_code", "max_devices", "valid_until", "status", "created_at")}
| {"used_devices": r["used_devices"], "license_key": license_key, "license_key_available": bool(license_key)}
)
return {"list": result}
@router.post("/admin/license/set-status")
def admin_license_set_status(body: LicenseStatusRequest, auth=Depends(admin_auth)):
status = body.status
license_id = body.license_id
if status not in ("active", "disabled"):
raise HTTPException(status_code=400, detail="授权状态无效")
updated = license_repository.set_license_status(license_id, status)
if updated != 1:
raise HTTPException(status_code=404, detail="授权不存在")
return {"msg": "授权状态已更新"}
@router.post("/admin/license/delete")
def admin_license_delete(body: LicenseDeleteRequest, auth=Depends(admin_auth)):
license_id = body.license_id.strip()
if not license_id:
raise HTTPException(status_code=400, detail="license_id 不能为空")
updated = license_repository.soft_delete_license(license_id)
if updated != 1:
raise HTTPException(status_code=404, detail="授权不存在")
return {"msg": "授权已删除,历史记录仍会保留"}
+76
View File
@@ -0,0 +1,76 @@
from fastapi import APIRouter, Body, Depends, HTTPException
from app.core.security import admin_auth
from app.repositories import log_repository
from app.schemas.log import DownloadLogClearRequest, LogDeleteRequest
router = APIRouter(tags=["admin-logs"])
@router.get("/admin/report/list")
def admin_get_report_log(auth=Depends(admin_auth)):
return {"list": log_repository.list_upgrade_logs()}
@router.post("/admin/report/delete")
def admin_delete_report_log(body: LogDeleteRequest, auth=Depends(admin_auth)):
log_id = int(body.id or 0)
if log_id < 1:
raise HTTPException(status_code=400, detail="日志 ID 无效")
deleted = log_repository.delete_upgrade_log(log_id)
if deleted != 1:
raise HTTPException(status_code=404, detail="升级日志不存在")
return {"msg": "升级日志已删除", "deleted": deleted}
@router.post("/admin/report/clear")
def admin_clear_report_logs(auth=Depends(admin_auth)):
deleted = log_repository.clear_upgrade_logs()
return {"msg": "升级日志已清空", "deleted": deleted}
@router.get("/admin/download-log/list")
def admin_download_log_list(app_id: str = "", limit: int = 300, auth=Depends(admin_auth)):
limit = max(1, min(limit, 1000))
return {"list": log_repository.list_download_logs(app_id, limit)}
@router.post("/admin/download-log/delete")
def admin_download_log_delete(body: LogDeleteRequest, auth=Depends(admin_auth)):
log_id = int(body.id or 0)
if log_id < 1:
raise HTTPException(status_code=400, detail="日志 ID 无效")
deleted = log_repository.delete_download_log(log_id)
if deleted != 1:
raise HTTPException(status_code=404, detail="下载日志不存在")
return {"msg": "下载日志已删除", "deleted": deleted}
@router.post("/admin/download-log/clear")
def admin_download_log_clear(body: DownloadLogClearRequest | None = Body(default=None), auth=Depends(admin_auth)):
app_id = (body.app_id if body else "").strip()
deleted = log_repository.clear_download_logs(app_id)
return {"msg": "下载日志已清空", "deleted": deleted}
@router.get("/admin/audit-log/list")
def admin_audit_log_list(limit: int = 300, auth=Depends(admin_auth)):
return {"list": log_repository.list_audit_logs(max(1, min(limit, 1000)))}
@router.post("/admin/audit-log/delete")
def admin_audit_log_delete(body: LogDeleteRequest, auth=Depends(admin_auth)):
log_id = int(body.id or 0)
if log_id < 1:
raise HTTPException(status_code=400, detail="日志 ID 无效")
deleted = log_repository.delete_audit_log(log_id)
if deleted != 1:
raise HTTPException(status_code=404, detail="审计日志不存在")
return {"msg": "审计日志已删除", "deleted": deleted}
@router.post("/admin/audit-log/clear")
def admin_audit_log_clear(auth=Depends(admin_auth)):
deleted = log_repository.clear_audit_logs()
return {"msg": "审计日志已清空", "deleted": deleted}
+52
View File
@@ -0,0 +1,52 @@
import json
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException
from app.core.security import admin_auth
from app.repositories import policy_repository
from app.schemas.policy import PolicySaveRequest
from app.services.common_service import policy_row_to_dict, validate_channel_code
router = APIRouter(tags=["admin-policy"])
@router.get("/admin/policy")
def admin_get_policy(app_id: str, channel: str, auth=Depends(admin_auth)):
row = policy_repository.get_policy(app_id, channel)
result = policy_row_to_dict(row)
result.update({"app_id": app_id, "channel": channel, "saved": bool(row)})
return result
@router.post("/admin/policy/save")
def admin_save_policy(body: PolicySaveRequest, auth=Depends(admin_auth)):
app_id = body.app_id.strip()
channel = body.channel.strip()
if not app_id or not validate_channel_code(channel):
raise HTTPException(status_code=400, detail="App ID 或渠道无效")
disabled = body.disabled_versions or []
if any(not isinstance(v, str) for v in disabled):
raise HTTPException(status_code=400, detail="disabled_versions 必须是版本字符串数组")
valid_until = body.valid_until.strip()
try:
datetime.fromisoformat(valid_until.replace("Z", "+00:00"))
except ValueError:
raise HTTPException(status_code=400, detail="valid_until 必须是 ISO-8601 时间")
result, next_seq = policy_repository.save_policy(
app_id,
channel,
bool(body.force_update),
bool(body.allow_rollback),
bool(body.offline_allowed),
valid_until,
body.min_supported_version.strip(),
json.dumps(disabled, ensure_ascii=False),
body.message.strip(),
)
if result == "channel_not_found":
raise HTTPException(status_code=400, detail={"error": "channel_not_found", "msg": f"渠道 {channel} 不存在"})
if result == "channel_disabled":
raise HTTPException(status_code=403, detail={"error": "channel_disabled", "msg": f"渠道 {channel} 已停用"})
return {"msg": "策略已保存并递增 policy_seq", "policy_seq": next_seq}
+18
View File
@@ -0,0 +1,18 @@
import asyncio
from fastapi import APIRouter, Depends, HTTPException, Request
from app.core.security import admin_auth
from app.services import publish_service
router = APIRouter()
PUBLISH_LOCK = asyncio.Lock()
@router.post("/admin/publish")
async def admin_publish_version(request: Request, auth=Depends(admin_auth)):
if PUBLISH_LOCK.locked():
raise HTTPException(status_code=409, detail="已有版本发布任务正在进行,请等待完成后再发布")
async with PUBLISH_LOCK:
return await publish_service.publish_version(request)
+126
View File
@@ -0,0 +1,126 @@
import hashlib
import json
import os
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import StreamingResponse
import minio_tool
from app.core.security import admin_auth
from app.repositories import version_repository
from app.schemas.version import VersionIdRequest, VersionProtocolRequest
from app.services.common_service import is_executable_path
from app.services.signing_service import canonical_manifest_bytes, sign_manifest, sign_policy
TARGET_PLATFORM = os.getenv("TARGET_PLATFORM", "windows")
TARGET_ARCH = os.getenv("TARGET_ARCH", "x64")
SIGNING_KEY_ID = os.getenv("SIGNING_KEY_ID", "manifest-key-v1")
router = APIRouter(tags=["admin-version"])
@router.post("/admin/version/offline-package")
def admin_offline_package(body: VersionIdRequest, auth=Depends(admin_auth)):
version_id = int(body.version_id or 0)
ver, rows = version_repository.get_version_with_files(version_id)
if not ver or not rows:
raise HTTPException(status_code=404, detail="版本或版本文件不存在")
files = [{"path": r["path"], "sha256": r["sha256"], "size": r["size"], "executable": is_executable_path(r["path"])} for r in rows]
manifest = {
"app_id": ver["app_id"],
"version": ver["version"],
"channel": ver["channel"],
"platform": TARGET_PLATFORM,
"arch": TARGET_ARCH,
"manifest_seq": version_id,
"created_at": ver["create_time"],
"files": files,
}
manifest_text = canonical_manifest_bytes(manifest).decode()
manifest_signature = sign_manifest(manifest)
offset = 0
entries = []
for r in rows:
entries.append({"path": r["path"], "offset": offset, "size": r["size"], "sha256": r["sha256"]})
offset += int(r["size"] or 0)
package = {
"format": "MUPD0001",
"app_id": ver["app_id"],
"channel": ver["channel"],
"version": ver["version"],
"version_id": version_id,
"created_at": datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z"),
"manifest_sha256": hashlib.sha256(manifest_text.encode()).hexdigest(),
"payload_size": offset,
"files": entries,
"signature_alg": "RSA-2048-SHA256",
"key_id": SIGNING_KEY_ID,
}
package_text, package_signature = sign_policy(package)
wrapper = json.dumps(
{
"package_text": package_text,
"package_signature": package_signature,
"manifest_text": manifest_text,
"manifest_signature": manifest_signature,
},
ensure_ascii=False,
separators=(",", ":"),
).encode()
prefix = f"{ver['app_id']}/{ver['channel']}/{ver['version']}/files/"
def stream():
yield b"MUPD0001"
yield len(wrapper).to_bytes(8, "little")
yield wrapper
for r in rows:
yield from minio_tool.iter_file(prefix + r["path"])
filename = f"{ver['app_id']}_{ver['channel']}_{ver['version']}.upd"
return StreamingResponse(
stream(),
media_type="application/octet-stream",
headers={"Content-Disposition": f'attachment; filename="{filename}"', "Content-Length": str(16 + len(wrapper) + offset)},
)
@router.get("/admin/version/list")
def admin_get_version_list(app_id: str, auth=Depends(admin_auth)):
return {"list": version_repository.list_versions(app_id)}
@router.post("/admin/version/set-protocol")
def admin_set_version_protocol(body: VersionProtocolRequest, auth=Depends(admin_auth)):
version_id = int(body.version_id or 0)
client_protocol = int(body.client_protocol or 0)
if version_id < 1 or client_protocol < 1:
raise HTTPException(status_code=400, detail="版本 ID 和客户端协议必须是正整数")
updated = version_repository.set_version_protocol(version_id, client_protocol)
if updated != 1:
raise HTTPException(status_code=404, detail="版本不存在")
return {"msg": "客户端协议已更新", "version_id": version_id, "client_protocol": client_protocol}
@router.post("/admin/version/set-latest")
def admin_set_latest(body: VersionIdRequest, auth=Depends(admin_auth)):
vid = body.version_id
result = version_repository.set_latest_version(vid)
if result == "not_found":
raise HTTPException(status_code=404, detail="版本不存在")
return {"msg": "已切换为渠道最新版本"}
@router.post("/admin/version/delete")
def admin_delete_version(body: VersionIdRequest, auth=Depends(admin_auth)):
vid = body.version_id
v_info = version_repository.get_version_for_delete(vid)
if not v_info:
raise HTTPException(status_code=404, detail="版本不存在")
aid, ch, ver = v_info["app_id"], v_info["channel"], v_info["version"]
was_latest = bool(v_info["latest"])
prefix = f"{aid}/{ch}/{ver}/files/"
minio_tool.remove_prefix(prefix)
promoted_version = version_repository.delete_version_and_promote(vid, aid, ch, was_latest)
return {"msg": "版本、云端文件、数据库记录全部删除完成", "promoted_latest": promoted_version}
+244
View File
@@ -0,0 +1,244 @@
import hashlib
from datetime import datetime, timezone
from fastapi import APIRouter, Body, HTTPException, Request
import minio_tool
from app.repositories import client_update_repository
from app.schemas.client_update import (
CheckUpdateRequest,
DeviceIssueRequest,
DownloadReportRequest,
DownloadUrlRequest,
ManifestRequest,
UpgradeReportRequest,
)
from app.services.common_service import is_executable_path, policy_row_to_dict, validate_channel_code, version_key
from app.services.signing_service import canonical_manifest_bytes, sign_device_identity, sign_manifest, sign_policy
TARGET_PLATFORM = __import__("os").getenv("TARGET_PLATFORM", "windows")
TARGET_ARCH = __import__("os").getenv("TARGET_ARCH", "x64")
SIGNING_KEY_ID = __import__("os").getenv("SIGNING_KEY_ID", "manifest-key-v1")
router = APIRouter(tags=["client-update"])
def normalize_relative_path(raw_path: str) -> str:
from pathlib import PurePosixPath
if not isinstance(raw_path, str):
raise HTTPException(status_code=400, detail="文件相对路径无效")
normalized = raw_path.replace(chr(92), "/")
if normalized.startswith("/"):
raise HTTPException(status_code=400, detail=f"文件路径必须是相对路径: {raw_path}")
normalized = normalized.strip("/")
if not normalized or chr(0) in normalized:
raise HTTPException(status_code=400, detail="文件相对路径为空或包含非法字符")
path = PurePosixPath(normalized)
if path.is_absolute() or any(part in ("", ".", "..") for part in path.parts):
raise HTTPException(status_code=400, detail=f"不安全的文件路径: {raw_path}")
if path.parts and ":" in path.parts[0]:
raise HTTPException(status_code=400, detail=f"文件路径不能包含盘符: {raw_path}")
return path.as_posix()
@router.post("/api/v1/device/issue")
async def issue_device(request: Request, body: DeviceIssueRequest = Body(...)):
app_id, channel, installation_id = body.app_id.strip(), body.channel.strip(), body.installation_id.strip()
if not app_id or not validate_channel_code(channel) or not (16 <= len(installation_id) <= 128):
raise HTTPException(status_code=400, detail="设备登记参数无效")
ip = request.client.host if request.client else ""
result = client_update_repository.issue_device(
app_id,
channel,
hashlib.sha256(body.license_key.strip().encode("utf-8")).hexdigest(),
installation_id,
body.machine_hash.strip().lower(),
ip,
)
if result["status"] == "channel_not_found":
raise HTTPException(status_code=400, detail={"error": "channel_not_found", "msg": f"渠道 {channel} 不存在"})
if result["status"] == "channel_disabled":
raise HTTPException(status_code=403, detail={"error": "channel_disabled", "msg": f"渠道 {channel} 已停用"})
if result["status"] == "license_invalid":
raise HTTPException(status_code=403, detail={"error": "license_invalid", "msg": "授权密钥无效、已过期或不适用于当前应用/渠道"})
if result["status"] == "device_disabled":
raise HTTPException(status_code=403, detail={"error": "device_disabled", "msg": result["reason"]})
if result["status"] == "license_conflict":
raise HTTPException(status_code=409, detail="该安装实例已绑定其他授权")
if result["status"] == "license_device_limit":
raise HTTPException(status_code=403, detail={"error": "license_device_limit", "msg": "授权设备数量已达到上限"})
issued_at = result["issued_at"]
license_row = result["license"]
identity = {
"device_id": result["device_id"],
"license_id": license_row["license_id"],
"app_id": app_id,
"channel": channel,
"installation_id": installation_id,
"credential_seq": result["credential_seq"],
"issued_at": issued_at.replace(microsecond=0).isoformat().replace("+00:00", "Z"),
"valid_until": license_row["valid_until"],
"signature_alg": "RSA-2048-SHA256",
"key_id": SIGNING_KEY_ID,
}
identity_text, signature = sign_device_identity(identity)
return {"identity": identity, "identity_text": identity_text, "signature": signature}
@router.post("/api/v1/update/check")
async def check_update(request: Request, body: CheckUpdateRequest = Body(...)):
if request.state.device_identity["app_id"] != body.app_id or request.state.device_identity["channel"] != body.channel:
raise HTTPException(status_code=403, detail="设备凭证与应用/渠道不匹配")
app_id, cur_ver, channel = body.app_id, body.current_version, body.channel
context = client_update_repository.get_update_context(app_id, channel)
if context["status"] == "channel_not_found":
raise HTTPException(status_code=400, detail={"error": "channel_not_found", "msg": f"渠道 {channel} 不存在"})
if context["status"] == "channel_disabled":
raise HTTPException(status_code=403, detail={"error": "channel_disabled", "msg": f"渠道 {channel} 已停用"})
ver = context["version"]
settings = policy_row_to_dict(context["policy"])
latest_ver = ver["version"] if ver else ""
version_id = int(ver["id"]) if ver else 0
target_protocol = int(ver["client_protocol"] or 1) if ver else 0
protocol_compatible = not ver or target_protocol >= body.client_protocol
upgrade_available = bool(latest_ver) and version_key(latest_ver) > version_key(cur_ver)
rollback_candidate = bool(latest_ver) and version_key(latest_ver) < version_key(cur_ver)
rollback_allowed = rollback_candidate and bool(settings["allow_rollback"]) and protocol_compatible
need_update = upgrade_available or rollback_allowed
disabled = cur_ver in settings["disabled_versions"]
below_min = bool(settings["min_supported_version"]) and version_key(cur_ver) < version_key(settings["min_supported_version"])
allow_run = not disabled and not below_min
force_update = bool(settings["force_update"] and upgrade_available) or disabled or below_min
if disabled:
action, message = "blocked", settings["message"] or f"当前版本 {cur_ver} 已被管理员禁用"
elif below_min:
action, message = "force_update", settings["message"] or f"当前版本低于最低支持版本 {settings['min_supported_version']}"
elif force_update:
action, message = "force_update", settings["message"] or "必须升级到最新版本后才能继续使用"
elif rollback_allowed:
action, message = "rollback_allowed", settings["message"] or f"管理员要求回退到版本 {latest_ver}"
elif rollback_candidate and not protocol_compatible:
action, message = "rollback_denied", f"目标版本 {latest_ver} 的客户端协议为 {target_protocol},低于当前协议 {body.client_protocol},禁止降级"
elif rollback_candidate:
action, message = "rollback_denied", settings["message"] or f"渠道目标版本 {latest_ver} 低于当前版本,策略禁止降级"
elif upgrade_available:
action, message = "optional_update", settings["message"] or "发现可用新版本"
else:
action, message = "allow", settings["message"] or "当前版本允许使用"
issued_at = datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
policy = {
"app_id": app_id,
"channel": channel,
"current_version": cur_ver,
"policy_seq": settings["policy_seq"],
"allow_run": allow_run,
"force_update": force_update,
"allow_rollback": settings["allow_rollback"],
"rollback_targets": [],
"offline_allowed": settings["offline_allowed"],
"issued_at": issued_at,
"valid_until": settings["valid_until"],
"latest_version": latest_ver,
"min_supported_version": settings["min_supported_version"],
"disabled_versions": settings["disabled_versions"],
"message": message,
"signature_alg": "RSA-2048-SHA256",
"key_id": SIGNING_KEY_ID,
}
policy_text, policy_signature = sign_policy(policy)
policy["signature"] = policy_signature
return {
"allow_run": allow_run,
"action": action,
"message": message,
"need_update": need_update,
"release_available": bool(ver),
"current_version": cur_ver,
"latest_version": latest_ver,
"version_id": version_id,
"force_update": force_update,
"allow_rollback": settings["allow_rollback"],
"rollback": rollback_allowed,
"client_protocol": body.client_protocol,
"target_client_protocol": target_protocol,
"protocol_compatible": protocol_compatible,
"policy_seq": settings["policy_seq"],
"policy_valid_until": settings["valid_until"],
"policy": policy,
"policy_text": policy_text,
"policy_signature": policy_signature,
}
@router.post("/api/v1/update/download-url")
async def get_download_url(request: Request, body: DownloadUrlRequest = Body(...)):
identity = request.state.device_identity
if identity["app_id"] != body.app_id or identity["channel"] != body.channel:
raise HTTPException(status_code=403, detail="设备凭证与应用/渠道不匹配")
ip = request.client.host if request.client else ""
ua = request.headers.get("user-agent", "")[:300]
result = client_update_repository.get_download_files(body.version_id, body.app_id, body.channel, body.version, identity, ip, ua)
if result["status"] != "ok":
raise HTTPException(status_code=404, detail="版本与应用/渠道不匹配")
base_path = f"{body.app_id}/{body.channel}/{body.version}/files/"
return {
"files": [
{"path": row["path"], "url": minio_tool.get_url(base_path + row["path"]), "sha256": row["sha256"], "size": row["size"]}
for row in result["files"]
]
}
@router.post("/api/v1/update/download-report")
async def report_download(request: Request, body: DownloadReportRequest = Body(...)):
identity = request.state.device_identity
if identity["app_id"] != body.app_id or identity["channel"] != body.channel:
raise HTTPException(status_code=403, detail="设备凭证与应用/渠道不匹配")
if body.result not in ("success", "fail"):
raise HTTPException(status_code=400, detail="下载结果无效")
ip = request.client.host if request.client else ""
ua = request.headers.get("user-agent", "")[:300]
values = []
for item in body.files[:5000]:
path = normalize_relative_path(str(item.get("path") or ""))
size = max(0, int(item.get("size") or 0))
values.append((identity["device_id"], identity["license_id"], body.app_id, body.version, body.channel, path, size, body.result, ip, ua))
logged = client_update_repository.insert_download_report_logs(values)
return {"code": 0, "logged": logged}
@router.post("/api/v1/update/manifest")
async def get_manifest(request: Request, body: ManifestRequest = Body(...)):
if request.state.device_identity["app_id"] != body.app_id or request.state.device_identity["channel"] != body.channel:
raise HTTPException(status_code=403, detail="设备凭证与应用/渠道不匹配")
ver, rows = client_update_repository.get_manifest_version_files(body.version_id)
if not ver:
raise HTTPException(status_code=404, detail="Version not found")
files = [{"path": row["path"], "sha256": row["sha256"], "size": row["size"], "executable": is_executable_path(row["path"])} for row in rows]
manifest = {
"app_id": ver["app_id"],
"version": ver["version"],
"channel": ver["channel"],
"platform": TARGET_PLATFORM,
"arch": TARGET_ARCH,
"manifest_seq": int(body.version_id),
"created_at": ver["create_time"],
"files": files,
}
manifest_text = canonical_manifest_bytes(manifest).decode("utf-8")
signature = sign_manifest(manifest)
manifest["signature"] = signature
return {"manifest": manifest, "manifest_text": manifest_text, "signature": signature}
@router.post("/api/v1/update/report")
async def report(request: Request, body: UpgradeReportRequest = Body(...)):
identity = request.state.device_identity
if identity["app_id"] != body.app_id or identity["device_id"] != body.device_id:
raise HTTPException(status_code=403, detail="上报身份与设备凭证不匹配")
client_update_repository.insert_upgrade_log(body.device_id, body.from_version, body.to_version, body.result)
return {"code": 0, "msg": "上报成功"}
+35
View File
@@ -0,0 +1,35 @@
from fastapi import APIRouter, FastAPI, Request
from app.services import crash_api_service
router = APIRouter()
def register_exception_handlers(app: FastAPI):
app.add_exception_handler(crash_api_service.CrashApiException, crash_api_service.crash_api_exception_handler)
@router.get("/api/v1/health")
def crash_health():
return crash_api_service.health_payload()
@router.post("/api/v1/crash-reports")
async def create_crash_report(request: Request):
return await crash_api_service.create_crash_report(request)
@router.get("/api/v1/crash-reports/{report_id}")
def get_crash_report(report_id: str, request: Request):
return crash_api_service.get_crash_report_detail(report_id, request)
@router.get("/api/v1/crash-reports/{report_id}/files/{file_name}")
def download_crash_report_file(report_id: str, file_name: str, request: Request):
return crash_api_service.download_crash_report_file(report_id, file_name, request)
@router.post("/api/v1/symbols")
async def upload_symbols(request: Request):
return await crash_api_service.upload_symbols(request)
+68
View File
@@ -0,0 +1,68 @@
from fastapi import APIRouter, FastAPI, HTTPException
from fastapi.responses import FileResponse, Response
from fastapi.staticfiles import StaticFiles
from app.core.config import configured_path
router = APIRouter(include_in_schema=False)
ADMIN_HTML_PATH = configured_path("ADMIN_HTML_PATH", "legacy/admin.html")
ADMIN_UI_DIST_PATH = configured_path("ADMIN_UI_DIST_PATH", "admin-ui/dist")
ADMIN_UI_INDEX_PATH = ADMIN_UI_DIST_PATH / "index.html"
ADMIN_UI_ASSETS_PATH = ADMIN_UI_DIST_PATH / "assets"
def mount_admin_assets(app: FastAPI):
if ADMIN_UI_ASSETS_PATH.is_dir():
app.mount(
"/assets",
StaticFiles(directory=str(ADMIN_UI_ASSETS_PATH)),
name="admin_ui_assets",
)
@router.get("/")
@router.get("/admin.html")
def admin_page():
if ADMIN_UI_INDEX_PATH.is_file():
return FileResponse(ADMIN_UI_INDEX_PATH, headers={"Cache-Control": "no-store"})
if not ADMIN_HTML_PATH.is_file():
raise HTTPException(status_code=404, detail="管理页面文件不存在")
return FileResponse(ADMIN_HTML_PATH, headers={"Cache-Control": "no-store"})
@router.get("/legacy-admin.html")
def legacy_admin_page():
if not ADMIN_HTML_PATH.is_file():
raise HTTPException(status_code=404, detail="旧管理页面文件不存在")
return FileResponse(ADMIN_HTML_PATH, headers={"Cache-Control": "no-store"})
@router.get("/favicon.ico")
def favicon():
icon_path = ADMIN_UI_DIST_PATH / "favicon.ico"
if icon_path.is_file():
return FileResponse(icon_path, headers={"Cache-Control": "public, max-age=3600"})
return Response(status_code=204)
@router.get("/platform-config.json")
def admin_platform_config():
config_path = ADMIN_UI_DIST_PATH / "platform-config.json"
if not config_path.is_file():
raise HTTPException(status_code=404, detail="前端平台配置不存在")
return FileResponse(config_path, headers={"Cache-Control": "no-store"})
@router.get("/logo.svg")
def admin_logo():
logo_path = ADMIN_UI_DIST_PATH / "logo.svg"
if not logo_path.is_file():
raise HTTPException(status_code=404, detail="前端 Logo 不存在")
return FileResponse(logo_path, headers={"Cache-Control": "public, max-age=3600"})
@router.get("/health")
def health_check():
return {"status": "ok"}
+1
View File
@@ -0,0 +1 @@
"""Core configuration, security and middleware helpers."""
+43
View File
@@ -0,0 +1,43 @@
from fastapi import Request
import db
from app.core.security import token_digest
# 管理后台写操作统一审计;不保存令牌明文和请求正文。
async def admin_audit_middleware(request: Request, call_next):
should_audit = (
request.url.path.startswith("/admin/")
and request.method not in ("GET", "HEAD", "OPTIONS")
and not request.url.path.startswith("/admin/audit-log/")
)
status_code = 500
try:
response = await call_next(request)
status_code = response.status_code
return response
finally:
if should_audit:
try:
token = request.headers.get("X-Admin-Token", "")
conn = db.get_conn()
conn.execute(
"""INSERT INTO admin_audit_logs
(actor_hash,action,method,path,target,result,status_code,ip,user_agent)
VALUES(?,?,?,?,?,?,?,?,?)""",
(
token_digest(token)[:16] if token else "anonymous",
request.url.path.removeprefix("/admin/"),
request.method,
request.url.path,
request.url.query[:500],
"success" if 200 <= status_code < 400 else "fail",
status_code,
request.client.host if request.client else "",
request.headers.get("user-agent", "")[:300],
),
)
conn.commit()
conn.close()
except Exception as audit_error:
print("管理员审计日志写入失败:", audit_error)
+34
View File
@@ -0,0 +1,34 @@
from pathlib import Path
import os
from dotenv import load_dotenv
BASE_DIR = Path(__file__).resolve().parents[2]
ENV_FILE_PATH = Path(os.getenv("ENV_FILE_PATH", str(BASE_DIR / ".env"))).expanduser()
if not ENV_FILE_PATH.is_absolute():
ENV_FILE_PATH = BASE_DIR / ENV_FILE_PATH
load_dotenv(ENV_FILE_PATH)
def env_bool(name: str, default: bool = False) -> bool:
value = os.getenv(name)
return default if value is None else value.strip().lower() in {"1", "true", "yes", "on"}
def configured_path(name: str, default: str) -> Path:
path = Path(os.getenv(name, default)).expanduser()
return path if path.is_absolute() else BASE_DIR / path
class Settings:
"""Centralized settings facade used during the backend template migration."""
server_host = os.getenv("SERVER_HOST", "0.0.0.0")
server_port = int(os.getenv("SERVER_PORT", "8000"))
service_title = os.getenv("SERVICE_TITLE", "软件自动升级服务")
admin_token = os.getenv("ADMIN_TOKEN", "").strip()
settings = Settings()
+61
View File
@@ -0,0 +1,61 @@
from pathlib import Path
import hashlib
import secrets
from fastapi import Header, HTTPException
from app.core.config import ENV_FILE_PATH, settings
if not settings.admin_token:
raise RuntimeError("请在环境变量或 .env 中配置 ADMIN_TOKEN")
def token_digest(token: str) -> str:
return hashlib.sha256(token.encode("utf-8")).hexdigest()
def quote_env_value(value: str) -> str:
unsafe_chars = set(" \t\n\r#\"'")
if value and not any(ch in unsafe_chars for ch in value):
return value
return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"'
def persist_env_value(path: Path, key: str, value: str) -> bool:
line = f"{key}={quote_env_value(value)}\n"
if path.exists():
lines = path.read_text(encoding="utf-8").splitlines(keepends=True)
replaced = False
for index, item in enumerate(lines):
stripped = item.lstrip()
if stripped.startswith("#") or "=" not in stripped:
continue
name = stripped.split("=", 1)[0].strip()
if name == key:
lines[index] = line
replaced = True
break
if not replaced:
if lines and not lines[-1].endswith(("\n", "\r")):
lines[-1] += "\n"
lines.append(line)
path.write_text("".join(lines), encoding="utf-8")
return True
path.write_text(line, encoding="utf-8")
return True
def admin_auth(X_Admin_Token: str = Header("")):
if not secrets.compare_digest(X_Admin_Token, settings.admin_token):
raise HTTPException(status_code=403, detail="后台密钥错误,禁止访问")
return True
ADMIN_TOKEN = settings.admin_token
def set_admin_token(new_token: str):
global ADMIN_TOKEN
settings.admin_token = new_token
ADMIN_TOKEN = new_token
+1
View File
@@ -0,0 +1 @@
"""Database package placeholder for the template migration."""
+1
View File
@@ -0,0 +1 @@
"""Repository package placeholder for the template migration."""
@@ -0,0 +1,93 @@
import db
DEFAULT_CHANNELS = [
("stable", "正式版", 1, 10),
("preview", "预览版", 1, 20),
("dev", "开发版", 1, 30),
]
def list_apps() -> list[dict]:
conn = db.get_conn()
rows = conn.execute("SELECT app_id, app_name FROM apps").fetchall()
conn.close()
return [{"app_id": row["app_id"], "app_name": row["app_name"]} for row in rows]
def add_app_with_default_channels(app_id: str, app_name: str):
conn = db.get_conn()
try:
conn.execute("INSERT INTO apps(app_id, app_name) VALUES (?, ?)", (app_id, app_name))
conn.executemany(
"INSERT INTO channels(app_id,channel_code,display_name,enabled,sort_order) VALUES(?,?,?,?,?)",
[(app_id, code, name, enabled, order) for code, name, enabled, order in DEFAULT_CHANNELS],
)
conn.commit()
except Exception:
conn.rollback()
raise
finally:
conn.close()
def app_exists(app_id: str) -> bool:
conn = db.get_conn()
row = conn.execute("SELECT 1 FROM apps WHERE app_id=?", (app_id,)).fetchone()
conn.close()
return bool(row)
def get_app(app_id: str):
conn = db.get_conn()
row = conn.execute("SELECT app_id, app_name FROM apps WHERE app_id=?", (app_id,)).fetchone()
conn.close()
return row
def list_channels(app_id: str, include_disabled: bool = True) -> list[dict]:
conn = db.get_conn()
sql = (
"SELECT channel_code,display_name,enabled,sort_order FROM channels WHERE app_id=?"
+ ("" if include_disabled else " AND enabled=1")
+ " ORDER BY sort_order,channel_code"
)
rows = conn.execute(sql, (app_id,)).fetchall()
conn.close()
return [
{
"channel_code": row["channel_code"],
"display_name": row["display_name"],
"enabled": bool(row["enabled"]),
"sort_order": row["sort_order"],
}
for row in rows
]
def get_channel(app_id: str, channel: str):
conn = db.get_conn()
row = conn.execute("SELECT * FROM channels WHERE app_id=? AND channel_code=?", (app_id, channel)).fetchone()
conn.close()
return row
def save_channel(app_id: str, code: str, name: str, enabled: bool, sort_order: int):
conn = db.get_conn()
try:
conn.execute(
"""INSERT INTO channels(app_id,channel_code,display_name,enabled,sort_order)
VALUES(?,?,?,?,?)
ON CONFLICT(app_id,channel_code) DO UPDATE SET
display_name=excluded.display_name,
enabled=excluded.enabled,
sort_order=excluded.sort_order,
updated_at=datetime('now')""",
(app_id, code, name, int(enabled), sort_order),
)
conn.commit()
except Exception:
conn.rollback()
raise
finally:
conn.close()
@@ -0,0 +1,168 @@
from datetime import datetime, timezone, timedelta
import secrets
import db
def get_device(device_id: str):
conn = db.get_conn()
row = conn.execute("SELECT * FROM devices WHERE device_id=?", (device_id,)).fetchone()
conn.close()
return row
def get_license(license_id: str):
conn = db.get_conn()
row = conn.execute("SELECT * FROM licenses WHERE license_id=?", (license_id,)).fetchone()
conn.close()
return row
def touch_device(device_id: str):
conn = db.get_conn()
conn.execute("UPDATE devices SET last_seen_at=datetime('now') WHERE device_id=?", (device_id,))
conn.commit()
conn.close()
def issue_device(app_id: str, channel: str, license_key_hash: str, installation_id: str, machine_hash: str, ip: str):
now = datetime.now(timezone.utc)
conn = db.get_conn()
conn.execute("BEGIN IMMEDIATE")
try:
channel_row = conn.execute("SELECT * FROM channels WHERE app_id=? AND channel_code=?", (app_id, channel)).fetchone()
if not channel_row:
conn.rollback()
return {"status": "channel_not_found"}
if not channel_row["enabled"]:
conn.rollback()
return {"status": "channel_disabled"}
license_row = conn.execute("SELECT * FROM licenses WHERE license_key_hash=?", (license_key_hash,)).fetchone()
try:
expiry = datetime.fromisoformat((license_row["valid_until"] if license_row else "").replace("Z", "+00:00"))
except ValueError:
expiry = now - timedelta(seconds=1)
if (
not license_row
or license_row["status"] != "active"
or expiry <= now
or license_row["app_id"] != app_id
or license_row["channel_code"] != channel
):
conn.rollback()
return {"status": "license_invalid"}
device_row = conn.execute("SELECT * FROM devices WHERE app_id=? AND installation_id=?", (app_id, installation_id)).fetchone()
if device_row and device_row["disabled"]:
conn.rollback()
return {"status": "device_disabled", "reason": device_row["disabled_reason"] or "设备已被禁用"}
if device_row and device_row["license_id"] not in ("", license_row["license_id"]):
conn.rollback()
return {"status": "license_conflict"}
device_id = device_row["device_id"] if device_row else "dev_" + secrets.token_hex(16)
seq = int(device_row["credential_seq"]) if device_row else 1
bound = conn.execute("SELECT 1 FROM license_devices WHERE license_id=? AND device_id=?", (license_row["license_id"], device_id)).fetchone()
used = conn.execute("SELECT COUNT(*) FROM license_devices WHERE license_id=?", (license_row["license_id"],)).fetchone()[0]
if not bound and used >= int(license_row["max_devices"]):
conn.rollback()
return {"status": "license_device_limit"}
if device_row:
conn.execute(
"UPDATE devices SET machine_hash=?,license_id=?,channel=?,last_seen_at=datetime('now'),last_ip=? WHERE device_id=?",
(machine_hash, license_row["license_id"], channel, ip, device_id),
)
else:
conn.execute(
"INSERT INTO devices(device_id,app_id,installation_id,machine_hash,credential_seq,last_ip,license_id,channel) VALUES(?,?,?,?,?,?,?,?)",
(device_id, app_id, installation_id, machine_hash, seq, ip, license_row["license_id"], channel),
)
conn.execute("INSERT OR IGNORE INTO license_devices(license_id,device_id) VALUES(?,?)", (license_row["license_id"], device_id))
conn.commit()
return {"status": "ok", "device_id": device_id, "license": license_row, "credential_seq": seq, "issued_at": now}
except Exception:
conn.rollback()
raise
finally:
conn.close()
def get_update_context(app_id: str, channel: str):
conn = db.get_conn()
try:
channel_row = conn.execute("SELECT * FROM channels WHERE app_id=? AND channel_code=?", (app_id, channel)).fetchone()
if not channel_row:
return {"status": "channel_not_found"}
if not channel_row["enabled"]:
return {"status": "channel_disabled"}
version = conn.execute(
"SELECT * FROM versions WHERE app_id=? AND channel=? AND latest=1",
(app_id, channel),
).fetchone()
policy = conn.execute(
"SELECT * FROM version_policies WHERE app_id=? AND channel=?",
(app_id, channel),
).fetchone()
return {"status": "ok", "version": version, "policy": policy}
finally:
conn.close()
def get_download_files(version_id: int, app_id: str, channel: str, version: str, identity: dict, ip: str, user_agent: str):
conn = db.get_conn()
version_row = conn.execute("SELECT app_id,channel,version FROM versions WHERE id=?", (version_id,)).fetchone()
if not version_row or version_row["app_id"] != app_id or version_row["channel"] != channel or version_row["version"] != version:
conn.close()
return {"status": "not_found"}
rows = conn.execute("SELECT path, sha256, size FROM version_files WHERE version_id=?", (version_id,)).fetchall()
conn.executemany(
"""INSERT INTO download_logs(device_id,license_id,app_id,version,channel_code,file_path,file_size,result,ip,user_agent)
VALUES(?,?,?,?,?,?,?,?,?,?)""",
[
(identity["device_id"], identity["license_id"], app_id, version, channel, row["path"], row["size"], "authorized", ip, user_agent)
for row in rows
],
)
conn.commit()
conn.close()
return {"status": "ok", "files": rows}
def insert_download_report_logs(values: list[tuple]) -> int:
conn = db.get_conn()
if values:
conn.executemany(
"INSERT INTO download_logs(device_id,license_id,app_id,version,channel_code,file_path,file_size,result,ip,user_agent) VALUES(?,?,?,?,?,?,?,?,?,?)",
values,
)
conn.commit()
conn.close()
return len(values)
def get_manifest_version_files(version_id: int):
conn = db.get_conn()
version = conn.execute(
"SELECT app_id, channel, version, create_time FROM versions WHERE id=?",
(version_id,),
).fetchone()
if not version:
conn.close()
return None, []
rows = conn.execute("SELECT path, sha256, size FROM version_files WHERE version_id=?", (version_id,)).fetchall()
conn.close()
return version, rows
def insert_upgrade_log(device_id: str, from_version: str, to_version: str, result: str):
conn = db.get_conn()
conn.execute(
"""
INSERT INTO upgrade_logs(device_id,from_ver,to_ver,result,create_time)
VALUES (?,?,?,?,datetime('now'))
""",
(device_id, from_version, to_version, result),
)
conn.commit()
conn.close()
+133
View File
@@ -0,0 +1,133 @@
from datetime import datetime, timezone
import secrets
import db
def list_crash_reports(limit: int = 300) -> list[dict]:
conn = db.get_conn()
rows = conn.execute(
"""
SELECT
id,report_id,client_report_id,status,symbolication_status,product,app_version,
git_commit,build_type,channel,exception_code,crash_time_utc,received_at_utc,
remote_address,content_length,metadata_size,minidump_size,attachments_size
FROM crash_reports
ORDER BY id DESC
LIMIT ?
""",
(limit,),
).fetchall()
conn.close()
return [dict(row) for row in rows]
def get_crash_report(report_id: str):
conn = db.get_conn()
row = conn.execute("SELECT * FROM crash_reports WHERE report_id=?", (report_id,)).fetchone()
conn.close()
return row
def log_file_access(report_id: str, file_name: str, actor_hash: str, ip: str, user_agent: str):
conn = db.get_conn()
conn.execute(
"INSERT INTO crash_file_access_logs(report_id,file_name,actor_hash,ip,user_agent) VALUES(?,?,?,?,?)",
(report_id, file_name, actor_hash, ip, user_agent),
)
conn.commit()
conn.close()
def generate_prefixed_id(conn, table: str, column: str, prefix: str) -> str | None:
day = datetime.now(timezone.utc).strftime("%Y%m%d")
for _ in range(20):
value = f"{prefix}_{day}_{secrets.token_hex(4)}"
if not conn.execute(f"SELECT 1 FROM {table} WHERE {column}=?", (value,)).fetchone():
return value
return None
def get_report_by_client_report_id(conn, client_report_id: str):
return conn.execute("SELECT * FROM crash_reports WHERE client_report_id=?", (client_report_id,)).fetchone()
def get_report_by_id(conn, report_id: str):
return conn.execute("SELECT * FROM crash_reports WHERE report_id=?", (report_id,)).fetchone()
def insert_crash_report(conn, values: dict):
conn.execute(
"""
INSERT INTO crash_reports(
report_id,client_report_id,content_hash,status,symbolication_status,product,app_version,
git_commit,build_type,channel,exception_code,crash_time_utc,received_at_utc,remote_address,
content_length,metadata_sha256,metadata_size,minidump_sha256,minidump_size,attachments_sha256,
attachments_size,storage_path,metadata_json,server_json
) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
""",
(
values["report_id"],
values["client_report_id"],
values["content_hash"],
values["status"],
values["symbolication_status"],
values["product"],
values["app_version"],
values["git_commit"],
values["build_type"],
values["channel"],
values["exception_code"],
values["crash_time_utc"],
values["received_at_utc"],
values["remote_address"],
values["content_length"],
values["metadata_sha256"],
values["metadata_size"],
values["minidump_sha256"],
values["minidump_size"],
values["attachments_sha256"],
values["attachments_size"],
values["storage_path"],
values["metadata_json"],
values["server_json"],
),
)
def get_symbol_upload_by_identity_hash(conn, identity_hash: str):
return conn.execute("SELECT * FROM crash_symbol_uploads WHERE identity_hash=?", (identity_hash,)).fetchone()
def get_symbol_upload_by_id(conn, symbol_upload_id: str):
return conn.execute("SELECT * FROM crash_symbol_uploads WHERE symbol_upload_id=?", (symbol_upload_id,)).fetchone()
def insert_symbol_upload(conn, values: dict):
conn.execute(
"""
INSERT INTO crash_symbol_uploads(
symbol_upload_id,identity_hash,status,product,app_version,git_commit,build_type,platform,toolchain,
created_at_utc,received_at_utc,metadata_sha256,metadata_size,symbols_sha256,symbols_size,storage_path,metadata_json
) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
""",
(
values["symbol_upload_id"],
values["identity_hash"],
values["status"],
values["product"],
values["app_version"],
values["git_commit"],
values["build_type"],
values["platform"],
values["toolchain"],
values["created_at_utc"],
values["received_at_utc"],
values["metadata_sha256"],
values["metadata_size"],
values["symbols_sha256"],
values["symbols_size"],
values["storage_path"],
values["metadata_json"],
),
)
+36
View File
@@ -0,0 +1,36 @@
import db
def list_devices(app_id: str = "") -> list[dict]:
conn = db.get_conn()
if app_id:
rows = conn.execute("SELECT * FROM devices WHERE app_id=? ORDER BY last_seen_at DESC", (app_id,)).fetchall()
else:
rows = conn.execute("SELECT * FROM devices ORDER BY last_seen_at DESC LIMIT 500").fetchall()
conn.close()
return [
{
"device_id": row["device_id"],
"app_id": row["app_id"],
"installation_id": row["installation_id"],
"disabled": bool(row["disabled"]),
"disabled_reason": row["disabled_reason"],
"credential_seq": row["credential_seq"],
"first_seen_at": row["first_seen_at"],
"last_seen_at": row["last_seen_at"],
"last_ip": row["last_ip"],
}
for row in rows
]
def set_device_disabled(device_id: str, disabled: bool, reason: str) -> int:
conn = db.get_conn()
cur = conn.execute(
"UPDATE devices SET disabled=?,disabled_reason=? WHERE device_id=?",
(int(disabled), reason if disabled else "", device_id),
)
conn.commit()
updated = cur.rowcount
conn.close()
return updated
+75
View File
@@ -0,0 +1,75 @@
import db
def insert_license(
license_id: str,
license_key_hash: str,
license_key_cipher: str,
customer_name: str,
app_id: str,
channel: str,
max_devices: int,
valid_until: str,
):
conn = db.get_conn()
try:
channel_row = conn.execute("SELECT * FROM channels WHERE app_id=? AND channel_code=?", (app_id, channel)).fetchone()
if not channel_row:
return "channel_not_found"
if not channel_row["enabled"]:
return "channel_disabled"
conn.execute(
"""INSERT INTO licenses
(license_id,license_key_hash,license_key_cipher,customer_name,app_id,channel_code,max_devices,valid_until,status)
VALUES(?,?,?,?,?,?,?,?,'active')""",
(license_id, license_key_hash, license_key_cipher, customer_name, app_id, channel, max_devices, valid_until),
)
conn.commit()
return "ok"
except Exception:
conn.rollback()
raise
finally:
conn.close()
def list_licenses(app_id: str = "", include_deleted: bool = False) -> list:
deleted_filter = "" if include_deleted else " AND status<>'deleted'"
conn = db.get_conn()
if app_id:
rows = conn.execute(
f"""SELECT l.*,(SELECT COUNT(*) FROM license_devices d WHERE d.license_id=l.license_id) used_devices
FROM licenses l WHERE app_id=?{deleted_filter} ORDER BY created_at DESC""",
(app_id,),
).fetchall()
else:
rows = conn.execute(
f"""SELECT l.*,(SELECT COUNT(*) FROM license_devices d WHERE d.license_id=l.license_id) used_devices
FROM licenses l WHERE 1=1{deleted_filter} ORDER BY created_at DESC"""
).fetchall()
conn.close()
return rows
def set_license_status(license_id: str, status: str) -> int:
conn = db.get_conn()
cur = conn.execute(
"UPDATE licenses SET status=?,updated_at=datetime('now') WHERE license_id=? AND status<>'deleted'",
(status, license_id),
)
conn.commit()
updated = cur.rowcount
conn.close()
return updated
def soft_delete_license(license_id: str) -> int:
conn = db.get_conn()
cur = conn.execute(
"UPDATE licenses SET status='deleted',updated_at=datetime('now') WHERE license_id=? AND status<>'deleted'",
(license_id,),
)
conn.commit()
updated = cur.rowcount
conn.close()
return updated
+99
View File
@@ -0,0 +1,99 @@
import db
def list_upgrade_logs(limit: int = 200) -> list[dict]:
conn = db.get_conn()
rows = conn.execute(
"""
SELECT id,device_id,from_ver,to_ver,result,create_time
FROM upgrade_logs ORDER BY create_time DESC LIMIT ?
""",
(limit,),
).fetchall()
conn.close()
return [
{
"id": row["id"],
"device_id": row["device_id"],
"app_id": "",
"from_version": row["from_ver"],
"to_version": row["to_ver"],
"result": row["result"],
"create_time": row["create_time"],
}
for row in rows
]
def delete_upgrade_log(log_id: int) -> int:
conn = db.get_conn()
cur = conn.execute("DELETE FROM upgrade_logs WHERE id=?", (log_id,))
conn.commit()
deleted = cur.rowcount
conn.close()
return deleted
def clear_upgrade_logs() -> int:
conn = db.get_conn()
cur = conn.execute("DELETE FROM upgrade_logs")
conn.commit()
deleted = cur.rowcount
conn.close()
return deleted
def list_download_logs(app_id: str = "", limit: int = 300) -> list[dict]:
conn = db.get_conn()
if app_id:
rows = conn.execute("SELECT * FROM download_logs WHERE app_id=? ORDER BY id DESC LIMIT ?", (app_id, limit)).fetchall()
else:
rows = conn.execute("SELECT * FROM download_logs ORDER BY id DESC LIMIT ?", (limit,)).fetchall()
conn.close()
return [dict(row) for row in rows]
def delete_download_log(log_id: int) -> int:
conn = db.get_conn()
cur = conn.execute("DELETE FROM download_logs WHERE id=?", (log_id,))
conn.commit()
deleted = cur.rowcount
conn.close()
return deleted
def clear_download_logs(app_id: str = "") -> int:
conn = db.get_conn()
if app_id:
cur = conn.execute("DELETE FROM download_logs WHERE app_id=?", (app_id,))
else:
cur = conn.execute("DELETE FROM download_logs")
conn.commit()
deleted = cur.rowcount
conn.close()
return deleted
def list_audit_logs(limit: int = 300) -> list[dict]:
conn = db.get_conn()
rows = conn.execute("SELECT * FROM admin_audit_logs ORDER BY id DESC LIMIT ?", (limit,)).fetchall()
conn.close()
return [dict(row) for row in rows]
def delete_audit_log(log_id: int) -> int:
conn = db.get_conn()
cur = conn.execute("DELETE FROM admin_audit_logs WHERE id=?", (log_id,))
conn.commit()
deleted = cur.rowcount
conn.close()
return deleted
def clear_audit_logs() -> int:
conn = db.get_conn()
cur = conn.execute("DELETE FROM admin_audit_logs")
conn.commit()
deleted = cur.rowcount
conn.close()
return deleted
+62
View File
@@ -0,0 +1,62 @@
import db
def get_policy(app_id: str, channel: str):
conn = db.get_conn()
row = conn.execute("SELECT * FROM version_policies WHERE app_id=? AND channel=?", (app_id, channel)).fetchone()
conn.close()
return row
def save_policy(
app_id: str,
channel: str,
force_update: bool,
allow_rollback: bool,
offline_allowed: bool,
valid_until: str,
min_supported_version: str,
disabled_versions_json: str,
message: str,
) -> tuple[str, int | None]:
conn = db.get_conn()
try:
channel_row = conn.execute("SELECT * FROM channels WHERE app_id=? AND channel_code=?", (app_id, channel)).fetchone()
if not channel_row:
return "channel_not_found", None
if not channel_row["enabled"]:
return "channel_disabled", None
row = conn.execute("SELECT policy_seq FROM version_policies WHERE app_id=? AND channel=?", (app_id, channel)).fetchone()
next_seq = (int(row["policy_seq"]) + 1) if row else 1
values = (
next_seq,
int(bool(force_update)),
int(bool(allow_rollback)),
int(bool(offline_allowed)),
valid_until,
min_supported_version,
disabled_versions_json,
message,
app_id,
channel,
)
conn.execute(
"""
INSERT INTO version_policies(policy_seq,force_update,allow_rollback,offline_allowed,valid_until,
min_supported_version,disabled_versions,message,app_id,channel)
VALUES(?,?,?,?,?,?,?,?,?,?)
ON CONFLICT(app_id,channel) DO UPDATE SET policy_seq=excluded.policy_seq,
force_update=excluded.force_update,allow_rollback=excluded.allow_rollback,
offline_allowed=excluded.offline_allowed,valid_until=excluded.valid_until,
min_supported_version=excluded.min_supported_version,disabled_versions=excluded.disabled_versions,
message=excluded.message,updated_at=datetime('now')
""",
values,
)
conn.commit()
return "ok", next_seq
except Exception:
conn.rollback()
raise
finally:
conn.close()
+24
View File
@@ -0,0 +1,24 @@
import sqlite3
def version_exists(conn: sqlite3.Connection, app_id: str, channel: str, version: str) -> bool:
return conn.execute(
"SELECT 1 FROM versions WHERE app_id=? AND channel=? AND version=?",
(app_id, channel, version),
).fetchone() is not None
def create_version(conn: sqlite3.Connection, app_id: str, channel: str, version: str, client_protocol: int) -> int:
conn.execute("UPDATE versions SET latest=0 WHERE app_id=? AND channel=?", (app_id, channel))
cur = conn.execute(
"INSERT INTO versions(app_id, channel, version, latest, client_protocol) VALUES (?,?,?,1,?)",
(app_id, channel, version, client_protocol),
)
return int(cur.lastrowid)
def insert_version_file(conn: sqlite3.Connection, version_id: int, path: str, sha256: str, size: int):
conn.execute(
"INSERT INTO version_files(version_id, path, sha256, size) VALUES (?,?,?,?)",
(version_id, path, sha256, size),
)
+91
View File
@@ -0,0 +1,91 @@
import db
def get_version_with_files(version_id: int):
conn = db.get_conn()
version = conn.execute("SELECT app_id,channel,version,create_time FROM versions WHERE id=?", (version_id,)).fetchone()
files = conn.execute("SELECT path,sha256,size FROM version_files WHERE version_id=? ORDER BY id", (version_id,)).fetchall()
conn.close()
return version, files
def list_versions(app_id: str) -> list[dict]:
conn = db.get_conn()
rows = conn.execute(
"""
SELECT id, version, channel, latest, client_protocol, create_time
FROM versions WHERE app_id=? ORDER BY create_time DESC
""",
(app_id,),
).fetchall()
conn.close()
return [
{
"id": row["id"],
"version": row["version"],
"channel": row["channel"],
"latest": bool(row["latest"]),
"client_protocol": int(row["client_protocol"] or 1),
"create_time": row["create_time"],
}
for row in rows
]
def set_version_protocol(version_id: int, client_protocol: int) -> int:
conn = db.get_conn()
cur = conn.execute("UPDATE versions SET client_protocol=? WHERE id=?", (client_protocol, version_id))
conn.commit()
updated = cur.rowcount
conn.close()
return updated
def set_latest_version(version_id: int) -> str:
conn = db.get_conn()
try:
cur = conn.cursor()
version_info = cur.execute("SELECT app_id, channel FROM versions WHERE id=?", (version_id,)).fetchone()
if not version_info:
return "not_found"
app_id, channel = version_info["app_id"], version_info["channel"]
cur.execute("UPDATE versions SET latest=0 WHERE app_id=? AND channel=?", (app_id, channel))
cur.execute("UPDATE versions SET latest=1 WHERE id=?", (version_id,))
conn.commit()
return "ok"
except Exception:
conn.rollback()
raise
finally:
conn.close()
def get_version_for_delete(version_id: int):
conn = db.get_conn()
row = conn.execute("SELECT app_id, channel, version, latest FROM versions WHERE id=?", (version_id,)).fetchone()
conn.close()
return row
def delete_version_and_promote(version_id: int, app_id: str, channel: str, was_latest: bool) -> str | None:
conn = db.get_conn()
try:
cur = conn.cursor()
cur.execute("DELETE FROM version_files WHERE version_id=?", (version_id,))
cur.execute("DELETE FROM versions WHERE id=?", (version_id,))
promoted_version = None
if was_latest:
replacement = cur.execute(
"SELECT id, version FROM versions WHERE app_id=? AND channel=? ORDER BY create_time DESC, id DESC LIMIT 1",
(app_id, channel),
).fetchone()
if replacement:
cur.execute("UPDATE versions SET latest=1 WHERE id=?", (replacement["id"],))
promoted_version = replacement["version"]
conn.commit()
return promoted_version
except Exception:
conn.rollback()
raise
finally:
conn.close()
+1
View File
@@ -0,0 +1 @@
"""Pydantic schema package placeholder for the template migration."""
+15
View File
@@ -0,0 +1,15 @@
from pydantic import BaseModel
class ChangeAdminTokenRequest(BaseModel):
new_token: str
class ClientConfigGenerateRequest(BaseModel):
app_id: str
channel: str = "stable"
current_version: str = "1.0.0"
client_protocol: int = 3
license_key: str = ""
api_base_url: str = ""
main_executable: str = ""
+14
View File
@@ -0,0 +1,14 @@
from pydantic import BaseModel
class AppCreateRequest(BaseModel):
app_id: str = ""
app_name: str = ""
class ChannelSaveRequest(BaseModel):
app_id: str = ""
channel_code: str = ""
display_name: str = ""
enabled: bool = True
sort_order: int = 100
+47
View File
@@ -0,0 +1,47 @@
from pydantic import BaseModel
class CheckUpdateRequest(BaseModel):
app_id: str
current_version: str
channel: str
client_protocol: int = 1
class DownloadUrlRequest(BaseModel):
app_id: str
channel: str
version: str
version_id: int
files: list[str]
class ManifestRequest(BaseModel):
app_id: str
channel: str
version: str
version_id: int
class UpgradeReportRequest(BaseModel):
app_id: str
device_id: str
from_version: str
to_version: str
result: str
class DownloadReportRequest(BaseModel):
app_id: str
channel: str
version: str
result: str
files: list[dict]
class DeviceIssueRequest(BaseModel):
app_id: str
channel: str
license_key: str
installation_id: str
machine_hash: str = ""
+7
View File
@@ -0,0 +1,7 @@
from pydantic import BaseModel
class DeviceSetDisabledRequest(BaseModel):
device_id: str = ""
disabled: bool = False
reason: str = ""
+18
View File
@@ -0,0 +1,18 @@
from pydantic import BaseModel
class LicenseCreateRequest(BaseModel):
app_id: str = ""
channel: str = ""
customer_name: str = ""
max_devices: int = 1
valid_until: str = ""
class LicenseStatusRequest(BaseModel):
license_id: str = ""
status: str = ""
class LicenseDeleteRequest(BaseModel):
license_id: str = ""
+9
View File
@@ -0,0 +1,9 @@
from pydantic import BaseModel
class LogDeleteRequest(BaseModel):
id: int = 0
class DownloadLogClearRequest(BaseModel):
app_id: str = ""
+13
View File
@@ -0,0 +1,13 @@
from pydantic import BaseModel, Field
class PolicySaveRequest(BaseModel):
app_id: str = ""
channel: str = ""
force_update: bool = False
allow_rollback: bool = False
offline_allowed: bool = False
valid_until: str = ""
min_supported_version: str = ""
disabled_versions: list[str] = Field(default_factory=list)
message: str = ""
+10
View File
@@ -0,0 +1,10 @@
from pydantic import BaseModel
class VersionIdRequest(BaseModel):
version_id: int = 0
class VersionProtocolRequest(BaseModel):
version_id: int = 0
client_protocol: int = 0
+1
View File
@@ -0,0 +1 @@
"""Service package placeholder for the template migration."""
+63
View File
@@ -0,0 +1,63 @@
import os
from pathlib import Path
from fastapi import Request
from fastapi import HTTPException
PUBLIC_API_BASE_URL = os.getenv("PUBLIC_API_BASE_URL", "").strip().rstrip("/")
VALID_CLIENT_TOKEN = os.getenv("CLIENT_API_TOKEN")
RELEASE_MAIN_EXECUTABLE = os.getenv("RELEASE_MAIN_EXECUTABLE", "MainApp.exe").strip().replace(chr(92), "/").strip("/")
TARGET_PLATFORM = os.getenv("TARGET_PLATFORM", "windows")
TARGET_ARCH = os.getenv("TARGET_ARCH", "x64")
PUBLISH_MAX_REQUEST_BYTES = int(os.getenv("PUBLISH_MAX_REQUEST_MB", "4096")) * 1024 * 1024
PUBLISH_MAX_FILES = int(os.getenv("PUBLISH_MAX_FILES", "20000"))
PUBLISH_MAX_FIELDS = int(os.getenv("PUBLISH_MAX_FIELDS", str(PUBLISH_MAX_FILES + 100)))
def public_api_base_url(request: Request) -> str:
if PUBLIC_API_BASE_URL:
return PUBLIC_API_BASE_URL
forwarded_proto = request.headers.get("x-forwarded-proto", "").split(",")[0].strip()
forwarded_host = request.headers.get("x-forwarded-host", "").split(",")[0].strip()
scheme = forwarded_proto or request.url.scheme
host = forwarded_host or request.headers.get("host", "")
if host:
return f"{scheme}://{host}".rstrip("/")
return str(request.base_url).rstrip("/")
def default_client_main_executable() -> str:
path = RELEASE_MAIN_EXECUTABLE.strip().replace(chr(92), "/").strip("/")
if path.casefold().startswith("bin/"):
path = path[4:]
return path or "SimCAE.exe"
def default_client_config_values(request: Request) -> dict:
return {
"api_base_url": public_api_base_url(request),
"client_token": VALID_CLIENT_TOKEN or "",
"launch_token": os.getenv("CLIENT_LAUNCH_TOKEN", "SimCAE_Launch_Token_2026_ChangeMe_32Bytes"),
"request_timeout_ms": os.getenv("CLIENT_REQUEST_TIMEOUT_MS", "5000"),
"temp_folder": os.getenv("CLIENT_TEMP_FOLDER", "update_temp"),
"install_root": os.getenv("CLIENT_INSTALL_ROOT", ".."),
"main_executable": os.getenv("CLIENT_MAIN_EXECUTABLE", default_client_main_executable()),
"launcher_executable": os.getenv("CLIENT_LAUNCHER_EXECUTABLE", "Launcher.exe"),
"updater_executable": os.getenv("CLIENT_UPDATER_EXECUTABLE", "Updater.exe"),
"bootstrap_executable": os.getenv("CLIENT_BOOTSTRAP_EXECUTABLE", "Bootstrap.exe"),
"health_check_timeout_ms": os.getenv("CLIENT_HEALTH_CHECK_TIMEOUT_MS", "15000"),
"platform": TARGET_PLATFORM,
"arch": TARGET_ARCH,
}
def normalize_executable_name(value: str) -> str:
normalized = str(value or "").strip().replace(chr(92), "/").strip("/")
if not normalized:
return ""
if normalized.startswith("/") or ":" in normalized or any(part in ("", ".", "..") for part in Path(normalized).parts):
raise HTTPException(status_code=400, detail="主程序文件名必须是安全的相对路径")
if not normalized.casefold().endswith(".exe"):
normalized += ".exe"
return normalized
+56
View File
@@ -0,0 +1,56 @@
import json
from fastapi import HTTPException
def policy_row_to_dict(row) -> dict:
if not row:
return {
"policy_seq": 1,
"force_update": False,
"allow_rollback": False,
"offline_allowed": True,
"valid_until": "2099-12-31T23:59:59Z",
"min_supported_version": "",
"disabled_versions": [],
"message": "",
}
try:
disabled = json.loads(row["disabled_versions"] or "[]")
except (TypeError, json.JSONDecodeError):
disabled = []
return {
"policy_seq": int(row["policy_seq"]),
"force_update": bool(row["force_update"]),
"allow_rollback": bool(row["allow_rollback"]),
"offline_allowed": bool(row["offline_allowed"]),
"valid_until": row["valid_until"],
"min_supported_version": row["min_supported_version"] or "",
"disabled_versions": disabled if isinstance(disabled, list) else [],
"message": row["message"] or "",
}
def validate_channel_code(code: str) -> bool:
return bool(code) and len(code) <= 32 and code[0].isalnum() and all(c.isalnum() or c in "_-" for c in code)
def require_channel(conn, app_id: str, channel: str, require_enabled: bool = True):
row = conn.execute("SELECT * FROM channels WHERE app_id=? AND channel_code=?", (app_id, channel)).fetchone()
if not row:
raise HTTPException(status_code=400, detail={"error": "channel_not_found", "msg": f"渠道 {channel} 不存在"})
if require_enabled and not row["enabled"]:
raise HTTPException(status_code=403, detail={"error": "channel_disabled", "msg": f"渠道 {channel} 已停用"})
return row
def is_executable_path(path: str) -> bool:
return path.lower().endswith((".exe", ".dll"))
def version_key(version: str):
parts = []
for part in version.split("."):
digits = "".join(ch for ch in part if ch.isdigit())
parts.append(int(digits) if digits else 0)
return tuple(parts)
+598
View File
@@ -0,0 +1,598 @@
import hashlib
import json
import os
import secrets
import shutil
import sqlite3
import uuid
from datetime import datetime, timezone
from pathlib import Path
from fastapi import Request
from fastapi.responses import FileResponse, JSONResponse
import db
from app.core import security
from app.core.config import configured_path
from app.repositories import crash_report_repository
CRASH_STORAGE_ROOT = configured_path("CRASH_STORAGE_ROOT", "crash_storage")
CRASH_METADATA_MAX_BYTES = int(os.getenv("CRASH_METADATA_MAX_KB", "256")) * 1024
CRASH_MINIDUMP_MAX_BYTES = int(os.getenv("CRASH_MINIDUMP_MAX_MB", "128")) * 1024 * 1024
CRASH_ATTACHMENTS_MAX_BYTES = int(os.getenv("CRASH_ATTACHMENTS_MAX_MB", "64")) * 1024 * 1024
CRASH_REQUEST_MAX_BYTES = int(os.getenv("CRASH_REQUEST_MAX_MB", "200")) * 1024 * 1024
CRASH_SYMBOLS_MAX_BYTES = int(os.getenv("CRASH_SYMBOLS_MAX_MB", "512")) * 1024 * 1024
CRASH_SERVICE_VERSION = os.getenv("CRASH_SERVICE_VERSION", "1.0.0")
CRASH_REPORT_TOKEN = os.getenv("CRASH_REPORT_TOKEN") or os.getenv("SIMCAE_CRASH_CLIENT_TOKEN") or os.getenv("CLIENT_API_TOKEN")
CRASH_SYMBOL_TOKEN = os.getenv("CRASH_SYMBOL_TOKEN") or os.getenv("SIMCAE_SYMBOL_TOKEN")
CRASH_ADMIN_TOKEN = os.getenv("CRASH_ADMIN_TOKEN")
class CrashApiException(Exception):
def __init__(self, status_code: int, code: str, message: str, retryable: bool = False):
self.status_code = status_code
self.code = code
self.message = message
self.retryable = retryable
async def crash_api_exception_handler(request: Request, exc: CrashApiException):
return JSONResponse(
status_code=exc.status_code,
content={"error": {"code": exc.code, "message": exc.message, "retryable": exc.retryable}},
)
def ensure_storage_dirs():
CRASH_STORAGE_ROOT.mkdir(parents=True, exist_ok=True)
(CRASH_STORAGE_ROOT / "reports").mkdir(parents=True, exist_ok=True)
(CRASH_STORAGE_ROOT / "symbols").mkdir(parents=True, exist_ok=True)
def utc_now_text() -> str:
return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
def crash_api_error(status_code: int, code: str, message: str, retryable: bool = False):
raise CrashApiException(status_code, code, message, retryable)
def bearer_token_from_request(request: Request) -> str:
value = request.headers.get("authorization", "")
if not value.lower().startswith("bearer "):
crash_api_error(401, "unauthorized", "missing bearer token", False)
token = value[7:].strip()
if not token:
crash_api_error(401, "unauthorized", "missing bearer token", False)
return token
def require_crash_bearer(request: Request, allowed_roles: set[str]) -> str:
token = bearer_token_from_request(request)
roles: set[str] = set()
if CRASH_REPORT_TOKEN and secrets.compare_digest(token, CRASH_REPORT_TOKEN):
roles.add("crash_report")
if CRASH_SYMBOL_TOKEN:
if secrets.compare_digest(token, CRASH_SYMBOL_TOKEN):
roles.add("symbols")
elif secrets.compare_digest(token, security.ADMIN_TOKEN):
roles.add("symbols")
if CRASH_ADMIN_TOKEN:
if secrets.compare_digest(token, CRASH_ADMIN_TOKEN):
roles.add("admin")
elif secrets.compare_digest(token, security.ADMIN_TOKEN):
roles.add("admin")
if not roles:
crash_api_error(401, "unauthorized", "invalid bearer token", False)
if roles.isdisjoint(allowed_roles):
crash_api_error(403, "forbidden", "token is not allowed to access this endpoint", False)
return token
def ensure_multipart_request(request: Request, max_bytes: int = CRASH_REQUEST_MAX_BYTES):
content_type = request.headers.get("content-type", "")
if "multipart/form-data" not in content_type.lower():
crash_api_error(415, "unsupported_media_type", "Content-Type must be multipart/form-data", False)
raw_length = request.headers.get("content-length")
if raw_length:
try:
content_length = int(raw_length)
except ValueError:
crash_api_error(400, "metadata_invalid", "Content-Length is invalid", False)
if content_length > max_bytes:
crash_api_error(413, "payload_too_large", "request body exceeds limit", False)
def is_upload_file(value) -> bool:
return hasattr(value, "filename") and hasattr(value, "read")
async def read_metadata_field(value, max_bytes: int) -> bytes:
if value is None:
crash_api_error(400, "metadata_invalid", "metadata is required", False)
if is_upload_file(value):
data = bytearray()
while True:
chunk = await value.read(64 * 1024)
if not chunk:
break
data.extend(chunk)
if len(data) > max_bytes:
crash_api_error(413, "payload_too_large", "metadata exceeds size limit", False)
return bytes(data)
data = str(value).encode("utf-8")
if len(data) > max_bytes:
crash_api_error(413, "payload_too_large", "metadata exceeds size limit", False)
return data
async def save_upload_limited(upload, target: Path, max_bytes: int, field_name: str) -> dict:
if not is_upload_file(upload):
crash_api_error(400, "metadata_invalid", f"{field_name} file is required", False)
target.parent.mkdir(parents=True, exist_ok=True)
total = 0
digest = hashlib.sha256()
try:
with open(target, "wb") as out_file:
while True:
chunk = await upload.read(1024 * 1024)
if not chunk:
break
total += len(chunk)
if total > max_bytes:
crash_api_error(413, "payload_too_large", f"{field_name} exceeds size limit", False)
digest.update(chunk)
out_file.write(chunk)
except Exception:
if target.exists():
target.unlink()
raise
if total <= 0:
crash_api_error(400, "metadata_invalid", f"{field_name} file is empty", False)
return {"sha256": digest.hexdigest(), "size": total, "filename": upload.filename or field_name}
def load_json_metadata(raw: bytes, error_code: str = "metadata_invalid") -> dict:
try:
obj = json.loads(raw.decode("utf-8"))
except Exception:
crash_api_error(400, error_code, "metadata must be valid UTF-8 JSON", False)
if not isinstance(obj, dict):
crash_api_error(400, error_code, "metadata must be a JSON object", False)
return obj
def require_metadata_fields(metadata: dict, fields: list[str], error_code: str = "metadata_invalid"):
for field in fields:
if metadata.get(field) in (None, ""):
crash_api_error(400, error_code, f"metadata.{field} is required", False)
if metadata.get("schemaVersion") != 1:
crash_api_error(400, error_code, "metadata.schemaVersion must be 1", False)
def normalize_declared_sha256(value, field_path: str) -> str:
if value in (None, ""):
return ""
text = str(value).strip().lower()
if len(text) != 64 or any(ch not in "0123456789abcdef" for ch in text):
crash_api_error(400, "metadata_invalid", f"{field_path} must be a SHA-256 hex string", False)
return text
def declared_file_sha256(metadata: dict, kind: str, filename: str) -> str:
files = metadata.get("files")
if not isinstance(files, list):
crash_api_error(400, "metadata_invalid", "metadata.files must be an array", False)
for index, item in enumerate(files):
if not isinstance(item, dict):
crash_api_error(400, "metadata_invalid", f"metadata.files[{index}] must be an object", False)
if item.get("kind") == kind or item.get("name") == filename:
return normalize_declared_sha256(item.get("sha256"), f"metadata.files[{index}].sha256")
return ""
def validate_client_report_id(value: str):
try:
uuid.UUID(value)
except Exception:
crash_api_error(400, "metadata_invalid", "metadata.clientReportId must be a UUID", False)
def safe_storage_component(value, fallback: str = "unknown") -> str:
text = str(value or fallback).strip()
cleaned = "".join(ch if ch.isalnum() or ch in "._-" else "_" for ch in text)
return cleaned[:96] or fallback
def crash_content_hash(metadata_sha256: str, minidump_sha256: str, attachments_sha256: str) -> str:
payload = json.dumps(
{"metadata": metadata_sha256, "minidump": minidump_sha256, "attachments": attachments_sha256 or ""},
sort_keys=True,
separators=(",", ":"),
).encode("utf-8")
return hashlib.sha256(payload).hexdigest()
def generate_prefixed_id(conn, table: str, column: str, prefix: str) -> str:
value = crash_report_repository.generate_prefixed_id(conn, table, column, prefix)
if not value:
crash_api_error(503, "service_unavailable", "could not allocate report id", True)
return value
def cleanup_path(path: Path | None):
if not path:
return
try:
if path.is_dir():
shutil.rmtree(path)
elif path.exists():
path.unlink()
except Exception as err:
print(f"清理临时文件失败: {path}: {err}")
def crash_report_response(row, duplicate: bool, status_code: int):
return JSONResponse(
status_code=status_code,
content={
"reportId": row["report_id"],
"duplicate": duplicate,
"status": row["status"],
"acceptedAtUtc": row["received_at_utc"],
},
)
def symbol_upload_response(row, duplicate: bool, status_code: int):
return JSONResponse(
status_code=status_code,
content={
"symbolUploadId": row["symbol_upload_id"],
"status": row["status"],
"duplicate": duplicate,
"acceptedAtUtc": row["received_at_utc"],
},
)
def health_payload():
return {
"status": "ok",
"service": "simcae-crash-server",
"version": CRASH_SERVICE_VERSION,
"timeUtc": utc_now_text(),
}
async def create_crash_report(request: Request):
tmp_dir: Path | None = None
final_dir: Path | None = None
stored = False
conn = None
metadata_raw = b""
package_hash = ""
try:
require_crash_bearer(request, {"crash_report"})
ensure_multipart_request(request)
try:
form = await request.form()
except Exception:
crash_api_error(400, "metadata_invalid", "multipart form is invalid", False)
metadata_raw = await read_metadata_field(form.get("metadata"), CRASH_METADATA_MAX_BYTES)
metadata_sha = hashlib.sha256(metadata_raw).hexdigest()
metadata = load_json_metadata(metadata_raw)
require_metadata_fields(
metadata,
[
"schemaVersion",
"clientReportId",
"crashTimeUtc",
"product",
"appVersion",
"gitCommit",
"buildType",
"channel",
"platform",
"crash",
"userConsent",
"files",
],
)
if metadata.get("product") != "SIMCAE":
crash_api_error(400, "metadata_invalid", "metadata.product must be SIMCAE", False)
if not isinstance(metadata.get("platform"), dict):
crash_api_error(400, "metadata_invalid", "metadata.platform must be an object", False)
if not isinstance(metadata.get("crash"), dict):
crash_api_error(400, "metadata_invalid", "metadata.crash must be an object", False)
if not isinstance(metadata.get("userConsent"), dict):
crash_api_error(400, "metadata_invalid", "metadata.userConsent must be an object", False)
client_report_id = str(metadata.get("clientReportId") or "").strip()
validate_client_report_id(client_report_id)
idempotency_key = request.headers.get("idempotency-key", "").strip()
if not idempotency_key:
crash_api_error(400, "metadata_invalid", "Idempotency-Key header is required", False)
if idempotency_key != client_report_id:
crash_api_error(400, "metadata_invalid", "Idempotency-Key must equal metadata.clientReportId", False)
tmp_dir = CRASH_STORAGE_ROOT / "_incoming" / f"crash_{secrets.token_hex(12)}"
tmp_dir.mkdir(parents=True, exist_ok=False)
(tmp_dir / "metadata.json").write_bytes(metadata_raw)
minidump_info = await save_upload_limited(form.get("minidump"), tmp_dir / "crash.dmp", CRASH_MINIDUMP_MAX_BYTES, "minidump")
declared_dump_sha = declared_file_sha256(metadata, "minidump", "crash.dmp")
if declared_dump_sha and declared_dump_sha != minidump_info["sha256"]:
crash_api_error(400, "file_hash_mismatch", "minidump SHA-256 does not match metadata.files", False)
attachments_info = None
if form.get("attachments") is not None:
attachments_info = await save_upload_limited(form.get("attachments"), tmp_dir / "attachments.zip", CRASH_ATTACHMENTS_MAX_BYTES, "attachments")
package_hash = crash_content_hash(metadata_sha, minidump_info["sha256"], attachments_info["sha256"] if attachments_info else "")
received_at = utc_now_text()
content_length = int(request.headers.get("content-length") or 0)
remote_addr = request.client.host if request.client else ""
server_info = {
"storageVersion": 1,
"clientReportId": client_report_id,
"receivedAtUtc": received_at,
"remoteAddress": remote_addr,
"contentLength": content_length,
}
conn = db.get_conn()
existing = crash_report_repository.get_report_by_client_report_id(conn, client_report_id)
if existing:
if existing["content_hash"] != package_hash:
crash_api_error(409, "idempotency_conflict", "same clientReportId was uploaded with different content", False)
return crash_report_response(existing, True, 200)
report_id = generate_prefixed_id(conn, "crash_reports", "report_id", "srv")
server_info["reportId"] = report_id
(tmp_dir / "server.json").write_text(json.dumps(server_info, ensure_ascii=False, indent=2), encoding="utf-8")
final_dir = CRASH_STORAGE_ROOT / "reports" / report_id
final_dir.parent.mkdir(parents=True, exist_ok=True)
tmp_dir.rename(final_dir)
tmp_dir = None
crash_obj = metadata.get("crash") or {}
crash_report_repository.insert_crash_report(
conn,
{
"report_id": report_id,
"client_report_id": client_report_id,
"content_hash": package_hash,
"status": "stored",
"symbolication_status": "not_started",
"product": metadata.get("product"),
"app_version": metadata.get("appVersion"),
"git_commit": metadata.get("gitCommit"),
"build_type": metadata.get("buildType"),
"channel": metadata.get("channel"),
"exception_code": str(crash_obj.get("exceptionCode") or ""),
"crash_time_utc": metadata.get("crashTimeUtc"),
"received_at_utc": received_at,
"remote_address": remote_addr,
"content_length": content_length,
"metadata_sha256": metadata_sha,
"metadata_size": len(metadata_raw),
"minidump_sha256": minidump_info["sha256"],
"minidump_size": minidump_info["size"],
"attachments_sha256": attachments_info["sha256"] if attachments_info else "",
"attachments_size": attachments_info["size"] if attachments_info else 0,
"storage_path": str(final_dir),
"metadata_json": metadata_raw.decode("utf-8", errors="replace"),
"server_json": json.dumps(server_info, ensure_ascii=False, separators=(",", ":")),
},
)
conn.commit()
stored = True
row = crash_report_repository.get_report_by_id(conn, report_id)
return crash_report_response(row, False, 201)
except sqlite3.IntegrityError:
if conn:
conn.rollback()
client_report_id = ""
try:
metadata = load_json_metadata(metadata_raw)
client_report_id = str(metadata.get("clientReportId") or "").strip()
except Exception:
pass
if client_report_id:
existing = crash_report_repository.get_report_by_client_report_id(conn, client_report_id)
if existing and existing["content_hash"] == package_hash:
return crash_report_response(existing, True, 200)
crash_api_error(409, "idempotency_conflict", "same clientReportId was uploaded with different content", False)
except CrashApiException:
if conn:
conn.rollback()
raise
except Exception as err:
if conn:
conn.rollback()
print("崩溃报告上传失败:", err)
crash_api_error(500, "server_error", "server failed to store crash report", True)
finally:
if conn:
conn.close()
cleanup_path(tmp_dir)
if final_dir and not stored:
cleanup_path(final_dir)
def get_crash_report_detail(report_id: str, request: Request):
require_crash_bearer(request, {"admin"})
row = crash_report_repository.get_crash_report(report_id)
if not row:
crash_api_error(404, "metadata_invalid", "crash report not found", False)
return {
"reportId": row["report_id"],
"clientReportId": row["client_report_id"],
"status": row["status"],
"receivedAtUtc": row["received_at_utc"],
"symbolicationStatus": row["symbolication_status"],
"appVersion": row["app_version"],
"gitCommit": row["git_commit"],
"product": row["product"],
"buildType": row["build_type"],
"channel": row["channel"],
"exceptionCode": row["exception_code"],
"files": {
"metadata": {"name": "metadata.json", "sha256": row["metadata_sha256"], "size": row["metadata_size"]},
"minidump": {"name": "crash.dmp", "sha256": row["minidump_sha256"], "size": row["minidump_size"]},
"attachments": {"name": "attachments.zip", "sha256": row["attachments_sha256"], "size": row["attachments_size"]} if row["attachments_size"] else None,
"server": {"name": "server.json"},
},
}
def download_crash_report_file(report_id: str, file_name: str, request: Request):
token = require_crash_bearer(request, {"admin"})
allowed = {
"metadata": "metadata.json",
"metadata.json": "metadata.json",
"minidump": "crash.dmp",
"crash.dmp": "crash.dmp",
"attachments": "attachments.zip",
"attachments.zip": "attachments.zip",
"server": "server.json",
"server.json": "server.json",
}
stored_name = allowed.get(file_name)
if not stored_name:
crash_api_error(404, "metadata_invalid", "file not found", False)
row = crash_report_repository.get_crash_report(report_id)
if not row:
crash_api_error(404, "metadata_invalid", "crash report not found", False)
target = Path(row["storage_path"]) / stored_name
crash_report_repository.log_file_access(
report_id,
stored_name,
security.token_digest(token)[:16],
request.client.host if request.client else "",
request.headers.get("user-agent", "")[:300],
)
if not target.is_file():
crash_api_error(404, "metadata_invalid", "file not found", False)
return FileResponse(target, media_type="application/octet-stream", filename=stored_name)
async def upload_symbols(request: Request):
tmp_dir: Path | None = None
final_dir: Path | None = None
stored = False
conn = None
try:
require_crash_bearer(request, {"symbols"})
ensure_multipart_request(request, CRASH_SYMBOLS_MAX_BYTES + CRASH_METADATA_MAX_BYTES)
try:
form = await request.form()
except Exception:
crash_api_error(400, "metadata_invalid", "multipart form is invalid", False)
metadata_raw = await read_metadata_field(form.get("metadata"), CRASH_METADATA_MAX_BYTES)
metadata_sha = hashlib.sha256(metadata_raw).hexdigest()
metadata = load_json_metadata(metadata_raw)
require_metadata_fields(
metadata,
["schemaVersion", "product", "appVersion", "gitCommit", "buildType", "platform", "toolchain", "createdAtUtc", "files"],
)
if metadata.get("product") != "SIMCAE":
crash_api_error(400, "metadata_invalid", "metadata.product must be SIMCAE", False)
if not isinstance(metadata.get("files"), list):
crash_api_error(400, "metadata_invalid", "metadata.files must be an array", False)
tmp_dir = CRASH_STORAGE_ROOT / "_incoming" / f"symbols_{secrets.token_hex(12)}"
tmp_dir.mkdir(parents=True, exist_ok=False)
(tmp_dir / "metadata.json").write_bytes(metadata_raw)
symbols_info = await save_upload_limited(form.get("symbols"), tmp_dir / "symbols.zip", CRASH_SYMBOLS_MAX_BYTES, "symbols")
identity_payload = json.dumps(
{
"product": metadata.get("product"),
"appVersion": metadata.get("appVersion"),
"gitCommit": metadata.get("gitCommit"),
"buildType": metadata.get("buildType"),
"platform": metadata.get("platform"),
},
sort_keys=True,
separators=(",", ":"),
)
identity_hash = hashlib.sha256(identity_payload.encode("utf-8")).hexdigest()
received_at = utc_now_text()
conn = db.get_conn()
existing = crash_report_repository.get_symbol_upload_by_identity_hash(conn, identity_hash)
if existing:
if existing["symbols_sha256"] != symbols_info["sha256"]:
crash_api_error(409, "idempotency_conflict", "same build identity already has different symbols", False)
return symbol_upload_response(existing, True, 200)
upload_id = generate_prefixed_id(conn, "crash_symbol_uploads", "symbol_upload_id", "sym")
final_dir = (
CRASH_STORAGE_ROOT
/ "symbols"
/ safe_storage_component(metadata.get("product"))
/ safe_storage_component(metadata.get("appVersion"))
/ safe_storage_component(metadata.get("gitCommit"))
/ safe_storage_component(metadata.get("buildType"))
/ safe_storage_component(metadata.get("platform"))
)
final_dir.parent.mkdir(parents=True, exist_ok=True)
if final_dir.exists():
shutil.rmtree(final_dir)
server_info = {
"storageVersion": 1,
"symbolUploadId": upload_id,
"receivedAtUtc": received_at,
"remoteAddress": request.client.host if request.client else "",
"contentLength": int(request.headers.get("content-length") or 0),
}
(tmp_dir / "server.json").write_text(json.dumps(server_info, ensure_ascii=False, indent=2), encoding="utf-8")
tmp_dir.rename(final_dir)
tmp_dir = None
crash_report_repository.insert_symbol_upload(
conn,
{
"symbol_upload_id": upload_id,
"identity_hash": identity_hash,
"status": "stored",
"product": metadata.get("product"),
"app_version": metadata.get("appVersion"),
"git_commit": metadata.get("gitCommit"),
"build_type": metadata.get("buildType"),
"platform": metadata.get("platform"),
"toolchain": metadata.get("toolchain"),
"created_at_utc": metadata.get("createdAtUtc"),
"received_at_utc": received_at,
"metadata_sha256": metadata_sha,
"metadata_size": len(metadata_raw),
"symbols_sha256": symbols_info["sha256"],
"symbols_size": symbols_info["size"],
"storage_path": str(final_dir),
"metadata_json": metadata_raw.decode("utf-8", errors="replace"),
},
)
conn.commit()
stored = True
row = crash_report_repository.get_symbol_upload_by_id(conn, upload_id)
return symbol_upload_response(row, False, 201)
except sqlite3.IntegrityError:
if conn:
conn.rollback()
crash_api_error(409, "idempotency_conflict", "same build identity already exists", False)
except CrashApiException:
if conn:
conn.rollback()
raise
except Exception as err:
if conn:
conn.rollback()
print("符号包上传失败:", err)
crash_api_error(500, "server_error", "server failed to store symbols", True)
finally:
if conn:
conn.close()
cleanup_path(tmp_dir)
if final_dir and not stored:
cleanup_path(final_dir)
+45
View File
@@ -0,0 +1,45 @@
import base64
import json
from datetime import datetime, timezone, timedelta
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import padding
from fastapi import HTTPException
from app.repositories import client_update_repository
from app.services.signing_service import load_manifest_private_key
def verify_device_credential(encoded: str) -> dict:
try:
wrapper = json.loads(base64.b64decode(encoded, validate=True).decode("utf-8"))
identity_text = wrapper["identity_text"]
signature = base64.b64decode(wrapper["signature"], validate=True)
load_manifest_private_key().public_key().verify(signature, identity_text.encode(), padding.PKCS1v15(), hashes.SHA256())
identity = json.loads(identity_text)
if any(not identity.get(k) for k in ("device_id", "license_id", "app_id", "channel", "installation_id", "credential_seq", "valid_until")):
raise ValueError("missing field")
except Exception:
raise HTTPException(status_code=401, detail={"error": "invalid_device_credential", "msg": "设备凭证格式或签名无效"})
row = client_update_repository.get_device(identity["device_id"])
if not row:
raise HTTPException(status_code=401, detail={"error": "device_not_registered", "msg": "设备未登记"})
if row["disabled"]:
reason = row["disabled_reason"] or "设备已被管理员禁用"
raise HTTPException(status_code=403, detail={"error": "device_disabled", "msg": reason})
if row["app_id"] != identity["app_id"] or row["installation_id"] != identity["installation_id"] or int(row["credential_seq"]) != int(identity["credential_seq"]):
raise HTTPException(status_code=401, detail={"error": "device_credential_revoked", "msg": "设备凭证已失效"})
license_row = client_update_repository.get_license(identity.get("license_id", ""))
now = datetime.now(timezone.utc)
try:
license_expiry = datetime.fromisoformat((license_row["valid_until"] if license_row else "").replace("Z", "+00:00"))
except ValueError:
license_expiry = now - timedelta(seconds=1)
if not license_row or license_row["status"] != "active" or license_expiry <= now:
raise HTTPException(status_code=403, detail={"error": "license_invalid", "msg": "授权不存在、已禁用或已过期"})
if license_row["app_id"] != identity["app_id"] or license_row["channel_code"] != identity.get("channel"):
raise HTTPException(status_code=403, detail={"error": "license_scope_mismatch", "msg": "授权应用或渠道不匹配"})
client_update_repository.touch_device(identity["device_id"])
return identity
+37
View File
@@ -0,0 +1,37 @@
import base64
import hashlib
import os
from pathlib import Path
from cryptography.fernet import Fernet, InvalidToken
from app.core.security import ADMIN_TOKEN
from app.services.signing_service import MANIFEST_PRIVATE_KEY_PATH
def license_key_encryption_material() -> bytes:
configured_secret = os.getenv("LICENSE_KEY_ENCRYPTION_SECRET", "").strip()
if configured_secret:
return configured_secret.encode("utf-8")
key_path = Path(MANIFEST_PRIVATE_KEY_PATH)
if key_path.exists():
return key_path.read_bytes()
return ADMIN_TOKEN.encode("utf-8")
def license_key_cipher() -> Fernet:
key = base64.urlsafe_b64encode(hashlib.sha256(license_key_encryption_material()).digest())
return Fernet(key)
def encrypt_license_key(license_key: str) -> str:
return license_key_cipher().encrypt(license_key.encode("utf-8")).decode("ascii")
def decrypt_license_key(cipher_text: str) -> str:
if not cipher_text:
return ""
try:
return license_key_cipher().decrypt(cipher_text.encode("ascii")).decode("utf-8")
except (InvalidToken, UnicodeDecodeError, ValueError):
return ""
+643
View File
@@ -0,0 +1,643 @@
import hashlib
import os
import secrets
import shutil
import sqlite3
import subprocess
import tarfile
import tempfile
import zipfile
from pathlib import Path, PurePosixPath
from fastapi import HTTPException, Request
import db
import minio_tool
from app.core.config import configured_path
from app.repositories import publish_repository
from app.services.common_service import require_channel
RELEASE_MAIN_EXECUTABLE = os.getenv("RELEASE_MAIN_EXECUTABLE", "MainApp.exe").strip().replace(chr(92), "/").strip("/")
UPLOAD_SPOOL_DIR = configured_path("UPLOAD_SPOOL_DIR", "upload_spool")
UPLOAD_SPOOL_DIR.mkdir(parents=True, exist_ok=True)
tempfile.tempdir = str(UPLOAD_SPOOL_DIR)
MINIO_DATA_DIR = configured_path("MINIO_DATA_DIR", "minio_data")
UPLOAD_SPACE_RESERVE = int(os.getenv("UPLOAD_SPACE_RESERVE_MB", "256")) * 1024 * 1024
PUBLISH_MAX_REQUEST_BYTES = int(os.getenv("PUBLISH_MAX_REQUEST_MB", "4096")) * 1024 * 1024
PUBLISH_MAX_FILES = int(os.getenv("PUBLISH_MAX_FILES", "20000"))
PUBLISH_MAX_FIELDS = int(os.getenv("PUBLISH_MAX_FIELDS", str(PUBLISH_MAX_FILES + 100)))
def normalize_relative_path(raw_path: str) -> str:
if not isinstance(raw_path, str):
raise HTTPException(status_code=400, detail="文件相对路径无效")
normalized = raw_path.replace(chr(92), "/")
if normalized.startswith("/"):
raise HTTPException(status_code=400, detail=f"文件路径必须是相对路径: {raw_path}")
normalized = normalized.strip("/")
if not normalized or chr(0) in normalized:
raise HTTPException(status_code=400, detail="文件相对路径为空或包含非法字符")
path = PurePosixPath(normalized)
if path.is_absolute() or any(part in ("", ".", "..") for part in path.parts):
raise HTTPException(status_code=400, detail=f"不安全的文件路径: {raw_path}")
if path.parts and ":" in path.parts[0]:
raise HTTPException(status_code=400, detail=f"文件路径不能包含盘符: {raw_path}")
return path.as_posix()
def validate_release_path(relative_path: str):
folded = relative_path.casefold()
parts = PurePosixPath(folded).parts
for part in parts[:-1]:
if should_skip_release_directory(part):
raise HTTPException(
status_code=400,
detail=f"发布目录包含构建或运行时目录,必须选择干净的 Release 输出目录: {relative_path}",
)
def should_skip_release_directory(part: str) -> bool:
folded = part.casefold()
blocked_directories = {".git", ".vs", "cmakefiles", "debug", "update", "update_temp"}
return folded in blocked_directories or folded.startswith("build") or folded.endswith("_autogen")
def should_skip_release_file(relative_path: str) -> bool:
folded = relative_path.casefold()
runtime_protected_files = {
"client.ini",
"bootstrap.exe",
"config/app_config.json",
"config/local_state.json",
"config/client_identity.dat",
"config/version_policy.dat",
}
if folded in runtime_protected_files:
return True
if folded.startswith("bin/") and folded[4:] in runtime_protected_files:
return True
if any(folded.endswith("/" + protected) for protected in runtime_protected_files):
return True
if any(folded.endswith("/bin/" + protected) for protected in runtime_protected_files):
return True
return folded.endswith((".pdb", ".ilk", ".obj"))
def should_skip_release_path(relative_path: str) -> bool:
folded = relative_path.casefold()
parts = PurePosixPath(folded).parts
if any(should_skip_release_directory(part) for part in parts[:-1]):
return True
return should_skip_release_file(relative_path)
def calc_local_file_sha256(path: Path, chunk_size: int = 1024 * 1024):
sha = hashlib.sha256()
total = 0
try:
with path.open("rb") as stream:
while True:
chunk = stream.read(chunk_size)
if not chunk:
break
sha.update(chunk)
total += len(chunk)
except OSError as err:
raise HTTPException(status_code=500, detail={"error": "local_file_read_failed", "msg": str(err), "path": str(path)})
return sha.hexdigest(), total
def supported_archive_kind(filename: str) -> str:
name = (filename or "").casefold()
if name.endswith(".zip"):
return "zip"
if name.endswith((".tar.gz", ".tgz", ".tar.bz2", ".tbz2")):
return "tar"
if name.endswith(".rar"):
return "rar"
return ""
def ensure_inside_directory(root: Path, relative_path: str) -> Path:
target = (root / relative_path).resolve()
root_resolved = root.resolve()
if target != root_resolved and root_resolved not in target.parents:
raise HTTPException(status_code=400, detail=f"压缩包包含不安全路径: {relative_path}")
return target
def publish_space_detail(error: str, msg: str, required_bytes: int, available_bytes: int):
return {
"error": error,
"msg": msg,
"required_bytes": required_bytes,
"available_bytes": available_bytes,
"required_mb": round(required_bytes / 1024 / 1024, 2),
"available_mb": round(available_bytes / 1024 / 1024, 2),
}
def check_extracted_publish_limits(total_size: int, file_count: int):
if PUBLISH_MAX_FILES > 0 and file_count > PUBLISH_MAX_FILES:
raise HTTPException(status_code=413, detail=f"压缩包解压后文件数量过多:{file_count},当前上限为 {PUBLISH_MAX_FILES}")
if PUBLISH_MAX_REQUEST_BYTES > 0 and total_size > PUBLISH_MAX_REQUEST_BYTES:
raise HTTPException(
status_code=413,
detail=publish_space_detail(
"publish_extracted_too_large",
f"压缩包解压后总大小过大,当前上限为 {PUBLISH_MAX_REQUEST_BYTES // 1024 // 1024} MB",
total_size,
PUBLISH_MAX_REQUEST_BYTES,
),
)
def copy_member_stream_to_path(source, target: Path, size: int | None, limits: dict):
target.parent.mkdir(parents=True, exist_ok=True)
total = 0
with target.open("wb") as output:
while True:
chunk = source.read(1024 * 1024)
if not chunk:
break
output.write(chunk)
total += len(chunk)
limits["total_size"] += len(chunk)
check_extracted_publish_limits(limits["total_size"], limits["file_count"])
if size is not None and size >= 0 and total != size:
raise HTTPException(status_code=400, detail=f"压缩包文件大小异常: {target.name}")
def extract_zip_archive(archive_path: Path, extract_dir: Path):
limits = {"total_size": 0, "file_count": 0}
try:
with zipfile.ZipFile(archive_path) as archive:
for info in archive.infolist():
if info.is_dir():
continue
rel_path = normalize_relative_path(info.filename)
if should_skip_release_path(rel_path):
continue
target = ensure_inside_directory(extract_dir, rel_path)
limits["file_count"] += 1
check_extracted_publish_limits(limits["total_size"] + max(info.file_size, 0), limits["file_count"])
with archive.open(info, "r") as source:
copy_member_stream_to_path(source, target, info.file_size, limits)
except zipfile.BadZipFile:
raise HTTPException(status_code=400, detail="zip 发布包无效或已损坏")
def extract_tar_archive(archive_path: Path, extract_dir: Path):
limits = {"total_size": 0, "file_count": 0}
try:
with tarfile.open(archive_path, mode="r:*") as archive:
for member in archive.getmembers():
if not member.isfile():
continue
rel_path = normalize_relative_path(member.name)
if should_skip_release_path(rel_path):
continue
target = ensure_inside_directory(extract_dir, rel_path)
limits["file_count"] += 1
check_extracted_publish_limits(limits["total_size"] + max(member.size, 0), limits["file_count"])
source = archive.extractfile(member)
if source is None:
raise HTTPException(status_code=400, detail=f"无法读取压缩包文件: {member.name}")
with source:
copy_member_stream_to_path(source, target, member.size, limits)
except tarfile.TarError:
raise HTTPException(status_code=400, detail="tar 发布包无效或已损坏")
def extract_rar_archive(archive_path: Path, extract_dir: Path):
commands = []
if shutil.which("bsdtar"):
commands.append(["bsdtar", "-xf", str(archive_path), "-C", str(extract_dir)])
if shutil.which("unrar"):
commands.append(["unrar", "x", "-o+", "-idq", str(archive_path), str(extract_dir) + os.sep])
if shutil.which("7z"):
commands.append(["7z", "x", "-y", f"-o{extract_dir}", str(archive_path)])
if not commands:
raise HTTPException(
status_code=400,
detail="服务器未安装 rar 解压工具,无法解压 .rar。请安装 bsdtar/unrar/7z,或上传 zip/tar.gz/tar.bz2 发布包",
)
last_error = ""
for command in commands:
try:
result = subprocess.run(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=3600)
except Exception as err:
last_error = str(err)
continue
if result.returncode == 0:
return
last_error = (result.stderr or result.stdout or "").strip()
raise HTTPException(status_code=400, detail=f"rar 发布包解压失败: {last_error or 'unknown error'}")
def extract_release_archive(archive_path: Path, filename: str, extract_dir: Path):
kind = supported_archive_kind(filename)
if kind == "zip":
extract_zip_archive(archive_path, extract_dir)
elif kind == "tar":
extract_tar_archive(archive_path, extract_dir)
elif kind == "rar":
extract_rar_archive(archive_path, extract_dir)
else:
raise HTTPException(status_code=400, detail="压缩发布包仅支持 zip、tar.gz、tgz、tar.bz2、tbz2、rar")
def release_items_from_extracted_dir(extract_dir: Path):
items = []
for path in extract_dir.rglob("*"):
if path.is_symlink() or not path.is_file():
continue
rel_path = normalize_relative_path(path.relative_to(extract_dir).as_posix())
if should_skip_release_path(rel_path):
continue
items.append({"path": rel_path, "local_path": path})
return normalize_release_item_roots(items)
def normalize_release_item_roots(items: list[dict]):
if not items:
return items
required_main_key = RELEASE_MAIN_EXECUTABLE.casefold()
lower_paths = [item["path"].casefold() for item in items]
if required_main_key in lower_paths:
return items
nested_main = next((path for path in lower_paths if path.endswith("/" + required_main_key)), None)
if not nested_main:
return items
top_levels = set()
for item in items:
parts = PurePosixPath(item["path"]).parts
if parts:
top_levels.add(parts[0])
if len(top_levels) != 1:
return items
prefix = next(iter(top_levels)) + "/"
normalized = []
for item in items:
if not item["path"].startswith(prefix):
return items
stripped = item["path"][len(prefix):]
if stripped:
clone = dict(item)
clone["path"] = stripped
normalized.append(clone)
return normalized
def validate_release_items(items: list[dict]):
if not items:
raise HTTPException(status_code=400, detail="未选择任何文件,请选择程序文件夹或压缩发布包")
if len(items) > PUBLISH_MAX_FILES:
raise HTTPException(status_code=413, detail=f"文件数量过多:{len(items)},当前上限为 {PUBLISH_MAX_FILES}")
seen_paths = set()
filtered = []
for item in items:
rel_path = normalize_relative_path(item["path"])
if should_skip_release_path(rel_path):
continue
validate_release_path(rel_path)
path_key = rel_path.casefold()
if path_key in seen_paths:
raise HTTPException(status_code=400, detail=f"存在重复文件路径: {rel_path}")
seen_paths.add(path_key)
clone = dict(item)
clone["path"] = rel_path
filtered.append(clone)
if not filtered:
raise HTTPException(status_code=400, detail="发布内容为空;请确认发布目录或压缩包中包含程序文件")
required_main_key = RELEASE_MAIN_EXECUTABLE.casefold()
if required_main_key not in seen_paths:
nested_main = next((path for path in seen_paths if path.endswith("/" + required_main_key)), None)
if nested_main:
raise HTTPException(
status_code=400,
detail=f"{RELEASE_MAIN_EXECUTABLE} 位于更深层目录,请选择包含该相对路径的正确发布根目录。当前发现: {nested_main}",
)
raise HTTPException(status_code=400, detail=f"发布目录中缺少 {RELEASE_MAIN_EXECUTABLE}")
nested_main = next((path for path in seen_paths if path.endswith("/" + required_main_key)), None)
if nested_main:
raise HTTPException(
status_code=400,
detail=f"发布目录中存在嵌套的重复主程序 {nested_main},请使用干净的 Release 输出目录",
)
return filtered
def calc_upload_file_sha256(upload, chunk_size: int = 1024 * 1024):
stream = getattr(upload, "file", None)
if stream is None:
raise HTTPException(status_code=400, detail="上传文件对象无效")
sha = hashlib.sha256()
total = 0
try:
stream.seek(0)
while True:
chunk = stream.read(chunk_size)
if not chunk:
break
if isinstance(chunk, str):
chunk = chunk.encode("utf-8")
sha.update(chunk)
total += len(chunk)
stream.seek(0)
except OSError as err:
raise HTTPException(status_code=500, detail={"error": "upload_file_read_failed", "msg": str(err)})
return sha.hexdigest(), total
async def close_upload_safely(upload):
try:
close = getattr(upload, "close", None)
if close:
result = close()
if hasattr(result, "__await__"):
await result
return
except Exception as err:
print(f"close upload temp file failed: {err}")
stream = getattr(upload, "file", None)
if stream is not None:
try:
stream.close()
except Exception as err:
print(f"close upload stream failed: {err}")
def ensure_publish_storage_space(next_file_size: int):
required_bytes = max(next_file_size, 0) + UPLOAD_SPACE_RESERVE
available_bytes = shutil.disk_usage(MINIO_DATA_DIR).free
if available_bytes < required_bytes:
raise HTTPException(
status_code=507,
detail=publish_space_detail(
"storage_space_insufficient",
"版本存储空间不足,已停止发布。请删除旧版本、清理磁盘或扩容后重试",
required_bytes,
available_bytes,
),
)
async def collect_publish_items(request: Request):
content_length_header = request.headers.get("content-length")
try:
content_length = int(content_length_header or 0)
except ValueError:
raise HTTPException(status_code=400, detail="无效的 Content-Length")
if content_length <= 0:
raise HTTPException(status_code=411, detail="发布请求必须提供 Content-Length")
if PUBLISH_MAX_REQUEST_BYTES > 0 and content_length > PUBLISH_MAX_REQUEST_BYTES:
raise HTTPException(
status_code=413,
detail=publish_space_detail(
"publish_request_too_large",
f"发布请求过大,当前上限为 {PUBLISH_MAX_REQUEST_BYTES // 1024 // 1024} MB。请减小发布目录、清理无关文件,或调整 PUBLISH_MAX_REQUEST_MB",
content_length,
PUBLISH_MAX_REQUEST_BYTES,
),
)
spool_free = shutil.disk_usage(UPLOAD_SPOOL_DIR).free
storage_free = shutil.disk_usage(MINIO_DATA_DIR).free
same_storage_device = os.stat(UPLOAD_SPOOL_DIR).st_dev == os.stat(MINIO_DATA_DIR).st_dev
if same_storage_device:
required_bytes = content_length * 2 + UPLOAD_SPACE_RESERVE
if spool_free < required_bytes:
raise HTTPException(
status_code=507,
detail=publish_space_detail(
"publish_space_insufficient",
"发布目录上传和版本存储位于同一磁盘,空间不足。请删除旧版本、扩容磁盘,或把 UPLOAD_SPOOL_DIR 放到其他磁盘",
required_bytes,
spool_free,
),
)
else:
if spool_free < content_length + 64 * 1024 * 1024:
raise HTTPException(
status_code=507,
detail=publish_space_detail(
"upload_temp_space_insufficient",
"上传临时空间不足,请减小发布包或扩容上传临时目录",
content_length + 64 * 1024 * 1024,
spool_free,
),
)
if storage_free < content_length + UPLOAD_SPACE_RESERVE:
raise HTTPException(
status_code=507,
detail=publish_space_detail(
"storage_space_insufficient",
"版本存储空间不足,请扩容服务器根卷或删除不再需要的历史版本",
content_length + UPLOAD_SPACE_RESERVE,
storage_free,
),
)
try:
form = await request.form(max_files=PUBLISH_MAX_FILES, max_fields=PUBLISH_MAX_FIELDS)
except OSError as err:
if getattr(err, "errno", None) == 28:
raise HTTPException(status_code=507, detail="上传临时空间已满,请扩容后重试")
raise
app_id = form.get("app_id")
channel = form.get("channel") or ""
version = form.get("version")
try:
client_protocol = int(form.get("client_protocol") or 2)
except (TypeError, ValueError):
raise HTTPException(status_code=400, detail="client_protocol 必须是正整数")
if client_protocol < 1:
raise HTTPException(status_code=400, detail="client_protocol 必须是正整数")
files = []
archives = []
if hasattr(form, "getlist"):
files = form.getlist("files")
archives = form.getlist("archive")
else:
for key, value in form.multi_items():
if key == "files":
files.append(value)
elif key == "archive":
archives.append(value)
files = [item for item in files if hasattr(item, "filename") and item.filename]
archives = [item for item in archives if hasattr(item, "filename") and item.filename]
if not app_id or not version:
headers = {key: value for key, value in request.headers.items()}
raise HTTPException(
status_code=422,
detail={
"error": "missing form fields",
"have_app_id": bool(app_id),
"have_version": bool(version),
"content_type": request.headers.get("content-type"),
"headers": headers,
"form_keys": list(form.keys()),
"app_id_value": app_id,
"version_value": version,
"app_id_len": len(app_id or ""),
"version_len": len(version or ""),
},
)
if files and archives:
raise HTTPException(status_code=400, detail="请只选择一种发布方式:软件根目录或压缩发布包")
if len(archives) > 1:
raise HTTPException(status_code=400, detail="一次只能上传一个压缩发布包")
if not files and not archives:
raise HTTPException(status_code=400, detail="未选择任何文件,请选择程序文件夹或压缩发布包")
archive_upload = None
archive_temp_dir = None
try:
if archives:
archive_upload = archives[0]
archive_name = str(archive_upload.filename or "")
if not supported_archive_kind(archive_name):
raise HTTPException(status_code=400, detail="压缩发布包仅支持 zip、tar.gz、tgz、tar.bz2、tbz2、rar")
archive_temp_dir = Path(tempfile.mkdtemp(prefix="publish_archive_", dir=UPLOAD_SPOOL_DIR))
archive_path = archive_temp_dir / ("upload_" + secrets.token_hex(8))
try:
archive_upload.file.seek(0)
with archive_path.open("wb") as target:
shutil.copyfileobj(archive_upload.file, target, length=1024 * 1024)
except OSError as err:
raise HTTPException(status_code=500, detail={"error": "archive_spool_failed", "msg": str(err)})
extract_dir = archive_temp_dir / "extracted"
extract_dir.mkdir(parents=True, exist_ok=True)
extract_release_archive(archive_path, archive_name, extract_dir)
raw_items = release_items_from_extracted_dir(extract_dir)
upload_items = validate_release_items(raw_items)
extracted_total = sum(item["local_path"].stat().st_size for item in upload_items)
check_extracted_publish_limits(extracted_total, len(upload_items))
else:
if len(files) > PUBLISH_MAX_FILES:
raise HTTPException(status_code=413, detail=f"文件数量过多:{len(files)},当前上限为 {PUBLISH_MAX_FILES}")
relative_paths = form.getlist("relative_paths") if hasattr(form, "getlist") else []
if relative_paths and len(relative_paths) != len(files):
raise HTTPException(status_code=400, detail="文件数量与相对路径数量不一致")
raw_items = []
for index, file in enumerate(files):
raw_path = relative_paths[index] if relative_paths else file.filename
raw_items.append({"upload": file, "path": str(raw_path)})
upload_items = validate_release_items(raw_items)
except Exception:
if archive_upload is not None:
await close_upload_safely(archive_upload)
cleanup_path(archive_temp_dir)
raise
return {
"app_id": str(app_id),
"channel": str(channel),
"version": str(version),
"client_protocol": client_protocol,
"upload_items": upload_items,
"archive_upload": archive_upload,
"archive_temp_dir": archive_temp_dir,
}
def cleanup_path(path: Path | None):
if not path:
return
try:
if path.is_dir():
shutil.rmtree(path)
elif path.exists():
path.unlink()
except Exception as err:
print(f"清理临时文件失败: {path}: {err}")
async def publish_version(request: Request):
payload = await collect_publish_items(request)
upload_items = payload["upload_items"]
archive_upload = payload["archive_upload"]
archive_temp_dir = payload["archive_temp_dir"]
app_id = payload["app_id"]
channel = payload["channel"]
version = payload["version"]
client_protocol = payload["client_protocol"]
try:
conn = db.get_conn()
require_channel(conn, app_id, channel)
publish_started = False
publish_prefix = f"{app_id}/{channel}/{version}/"
try:
if publish_repository.version_exists(conn, app_id, channel, version):
raise HTTPException(status_code=400, detail="该版本已存在,请更换版本号或先删除旧版本")
new_vid = publish_repository.create_version(conn, app_id, channel, version, client_protocol)
publish_started = True
for item in upload_items:
rel_path = item["path"]
if "upload" in item:
file = item["upload"]
f_sha, f_size = calc_upload_file_sha256(file)
stream = file.file
else:
local_path = item["local_path"]
f_sha, f_size = calc_local_file_sha256(local_path)
stream = local_path.open("rb")
try:
ensure_publish_storage_space(f_size)
obj_path = f"{app_id}/{channel}/{version}/files/{rel_path}"
put_result = minio_tool.put_file_stream(obj_path, stream, f_size)
if put_result.get("storage") == "local":
print(f"MinIO 不可用,已保存到本地回退:{put_result.get('path')}")
publish_repository.insert_version_file(conn, new_vid, rel_path, f_sha, f_size)
finally:
if "local_path" in item:
stream.close()
conn.commit()
except sqlite3.IntegrityError as err:
conn.rollback()
if publish_started:
minio_tool.remove_prefix(publish_prefix)
if "UNIQUE constraint failed: versions.app_id, versions.channel, versions.version" in str(err):
raise HTTPException(status_code=400, detail="该版本已存在,请更换版本号或先删除旧版本")
raise HTTPException(status_code=500, detail=str(err))
except sqlite3.OperationalError as err:
conn.rollback()
if publish_started:
minio_tool.remove_prefix(publish_prefix)
raise HTTPException(status_code=500, detail={"error": "database locked", "msg": str(err)})
except HTTPException:
conn.rollback()
if publish_started:
minio_tool.remove_prefix(publish_prefix)
raise
except Exception as err:
conn.rollback()
if publish_started:
minio_tool.remove_prefix(publish_prefix)
if isinstance(err, OSError) and getattr(err, "errno", None) == 28:
raise HTTPException(status_code=507, detail="发布过程中存储空间耗尽,已清理未完成版本")
raise HTTPException(status_code=500, detail={"error": "publish_failed", "msg": str(err)})
finally:
conn.close()
return {"msg": f"版本 {version} 发布完成,共上传 {len(upload_items)} 个文件", "file_count": len(upload_items)}
finally:
for item in upload_items:
upload = item.get("upload") if isinstance(item, dict) else None
if upload is not None:
await close_upload_safely(upload)
if archive_upload is not None:
await close_upload_safely(archive_upload)
cleanup_path(archive_temp_dir)
+49
View File
@@ -0,0 +1,49 @@
import base64
import json
from pathlib import Path
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.hazmat.primitives.serialization import load_pem_private_key
from app.core.config import configured_path
MANIFEST_PRIVATE_KEY_PATH = str(configured_path("MANIFEST_PRIVATE_KEY_PATH", "keys/manifest_private_key.pem"))
def load_manifest_private_key():
key_path = Path(MANIFEST_PRIVATE_KEY_PATH)
if not key_path.exists():
raise FileNotFoundError(f"Manifest private key not found: {key_path}")
key_data = key_path.read_bytes()
return load_pem_private_key(key_data, password=None)
def canonical_manifest_bytes(manifest_obj: dict) -> bytes:
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:
json_text = canonical_manifest_bytes(manifest_obj)
private_key = load_manifest_private_key()
signature = private_key.sign(json_text, padding.PKCS1v15(), hashes.SHA256())
return base64.b64encode(signature).decode("ascii")
def canonical_signed_bytes(obj: dict) -> bytes:
copy = {k: obj[k] for k in obj if k != "signature"}
return json.dumps(copy, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
def sign_policy(policy_obj: dict) -> tuple[str, str]:
payload = canonical_signed_bytes(policy_obj)
signature = load_manifest_private_key().sign(payload, padding.PKCS1v15(), hashes.SHA256())
return payload.decode("utf-8"), base64.b64encode(signature).decode("ascii")
def sign_device_identity(identity: dict) -> tuple[str, str]:
payload = canonical_signed_bytes(identity)
signature = load_manifest_private_key().sign(payload, padding.PKCS1v15(), hashes.SHA256())
return payload.decode("utf-8"), base64.b64encode(signature).decode("ascii")