feat(server): modularize backend and admin console
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""SimCAE update server backend package."""
|
||||
@@ -0,0 +1 @@
|
||||
"""API route package."""
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
"""API route modules."""
|
||||
@@ -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": "渠道已保存"}
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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}
|
||||
@@ -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": "授权已删除,历史记录仍会保留"}
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
@@ -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)
|
||||
@@ -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}
|
||||
@@ -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": "上报成功"}
|
||||
@@ -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)
|
||||
@@ -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"}
|
||||
@@ -0,0 +1 @@
|
||||
"""Core configuration, security and middleware helpers."""
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
"""Database package placeholder for the template migration."""
|
||||
@@ -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()
|
||||
@@ -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"],
|
||||
),
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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),
|
||||
)
|
||||
@@ -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()
|
||||
@@ -0,0 +1 @@
|
||||
"""Pydantic schema package placeholder for the template migration."""
|
||||
@@ -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 = ""
|
||||
@@ -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
|
||||
@@ -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 = ""
|
||||
@@ -0,0 +1,7 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class DeviceSetDisabledRequest(BaseModel):
|
||||
device_id: str = ""
|
||||
disabled: bool = False
|
||||
reason: str = ""
|
||||
@@ -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 = ""
|
||||
@@ -0,0 +1,9 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class LogDeleteRequest(BaseModel):
|
||||
id: int = 0
|
||||
|
||||
|
||||
class DownloadLogClearRequest(BaseModel):
|
||||
app_id: str = ""
|
||||
@@ -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 = ""
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
"""Service package placeholder for the template migration."""
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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 ""
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user