1676 lines
82 KiB
Python
1676 lines
82 KiB
Python
from fastapi import FastAPI, Body, Request, HTTPException, UploadFile, File, Form, Header, Depends
|
||
from fastapi.staticfiles import StaticFiles
|
||
from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
|
||
from pydantic import BaseModel
|
||
import os
|
||
import sqlite3
|
||
from pathlib import Path, PurePosixPath
|
||
from dotenv import load_dotenv
|
||
from contextlib import asynccontextmanager
|
||
import db
|
||
import minio_tool
|
||
import hashlib
|
||
import secrets
|
||
import base64
|
||
import json
|
||
import uuid
|
||
from datetime import datetime, timezone, timedelta
|
||
from fastapi.middleware.cors import CORSMiddleware
|
||
import socket
|
||
import subprocess
|
||
import time
|
||
import shutil
|
||
import tempfile
|
||
import urllib.request
|
||
from cryptography.hazmat.primitives.serialization import load_pem_private_key
|
||
from cryptography.hazmat.primitives.asymmetric import padding
|
||
from cryptography.hazmat.primitives import hashes
|
||
|
||
# 加载环境变量
|
||
load_dotenv()
|
||
BASE_DIR = Path(__file__).resolve().parent
|
||
|
||
|
||
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
|
||
|
||
|
||
SERVER_HOST = os.getenv("SERVER_HOST", "0.0.0.0")
|
||
SERVER_PORT = int(os.getenv("SERVER_PORT", "8000"))
|
||
VALID_CLIENT_TOKEN = os.getenv("CLIENT_API_TOKEN")
|
||
TARGET_PLATFORM = os.getenv("TARGET_PLATFORM", "windows")
|
||
TARGET_ARCH = os.getenv("TARGET_ARCH", "x64")
|
||
RELEASE_MAIN_EXECUTABLE = os.getenv("RELEASE_MAIN_EXECUTABLE", "MainApp.exe").strip()
|
||
SIGNING_KEY_ID = os.getenv("SIGNING_KEY_ID", "manifest-key-v1")
|
||
|
||
# 大型 multipart 上传使用内存文件系统暂存,避免与 MinIO 对象双重占用根分区。
|
||
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
|
||
|
||
# SimCAE 崩溃报告私有存储与上传限制
|
||
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")
|
||
|
||
# 后台管理令牌:当前版本直接使用 .env 中的 ADMIN_TOKEN 明文配置。
|
||
ADMIN_TOKEN = os.getenv("ADMIN_TOKEN", "").strip()
|
||
if not ADMIN_TOKEN:
|
||
raise RuntimeError("请在环境变量或 .env 中配置 ADMIN_TOKEN")
|
||
|
||
def token_digest(token: str) -> str:
|
||
return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
||
|
||
CRASH_REPORT_TOKEN = os.getenv("CRASH_REPORT_TOKEN") or os.getenv("SIMCAE_CRASH_CLIENT_TOKEN") or VALID_CLIENT_TOKEN
|
||
CRASH_SYMBOL_TOKEN = os.getenv("CRASH_SYMBOL_TOKEN") or os.getenv("SIMCAE_SYMBOL_TOKEN") or ADMIN_TOKEN
|
||
CRASH_ADMIN_TOKEN = os.getenv("CRASH_ADMIN_TOKEN")
|
||
|
||
# ========== 生命周期初始化 ==========
|
||
@asynccontextmanager
|
||
async def lifespan(app: FastAPI):
|
||
print("===== 进程启动,开始初始化数据库 =====")
|
||
db_file = db.DB_FILE
|
||
sql_file_path = db.SQL_FILE
|
||
conn = sqlite3.connect(db_file, check_same_thread=False)
|
||
conn.row_factory = sqlite3.Row
|
||
cur = conn.cursor()
|
||
|
||
if sql_file_path.exists():
|
||
with open(sql_file_path, "r", encoding="utf-8") as f:
|
||
full_sql = f.read()
|
||
cur.executescript(full_sql)
|
||
version_columns = {row[1] for row in cur.execute("PRAGMA table_info(versions)").fetchall()}
|
||
if "client_protocol" not in version_columns:
|
||
cur.execute("ALTER TABLE versions ADD COLUMN client_protocol INTEGER NOT NULL DEFAULT 1")
|
||
device_columns = {row[1] for row in cur.execute("PRAGMA table_info(devices)").fetchall()}
|
||
if "license_id" not in device_columns: cur.execute("ALTER TABLE devices ADD COLUMN license_id TEXT NOT NULL DEFAULT ''")
|
||
if "channel" not in device_columns: cur.execute("ALTER TABLE devices ADD COLUMN channel TEXT NOT NULL DEFAULT 'stable'")
|
||
for app_row in cur.execute("SELECT app_id FROM apps").fetchall():
|
||
if not cur.execute("SELECT 1 FROM channels WHERE app_id=? LIMIT 1", (app_row[0],)).fetchone():
|
||
cur.executemany("INSERT INTO channels(app_id,channel_code,display_name,enabled,sort_order) VALUES(?,?,?,?,?)",
|
||
[(app_row[0], "stable", "正式版", 1, 10), (app_row[0], "preview", "预览版", 1, 20), (app_row[0], "dev", "开发版", 1, 30)])
|
||
conn.commit()
|
||
print(f"✅ 成功执行tables.sql,创建数据表与测试数据")
|
||
else:
|
||
print(f"❌ 错误:当前目录找不到 tables.sql 文件!")
|
||
conn.close()
|
||
print("===== 数据库初始化完成 =====")
|
||
|
||
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)
|
||
|
||
start_minio_if_needed()
|
||
|
||
yield
|
||
print("服务进程正常退出")
|
||
|
||
def is_minio_running(endpoint: str) -> bool:
|
||
try:
|
||
if endpoint.startswith("http://") or endpoint.startswith("https://"):
|
||
base_url = endpoint.rstrip("/")
|
||
else:
|
||
scheme = "https" if env_bool("MINIO_SECURE") else "http"
|
||
base_url = f"{scheme}://{endpoint.rstrip('/')}"
|
||
health_url = base_url + "/minio/health/live"
|
||
with urllib.request.urlopen(health_url, timeout=float(os.getenv("MINIO_HEALTH_TIMEOUT_SEC", "2"))) as resp:
|
||
return resp.status == 200
|
||
except Exception:
|
||
return False
|
||
|
||
|
||
def download_minio_binary(target_path: Path) -> str | None:
|
||
url = os.getenv("MINIO_DOWNLOAD_URL", "https://dl.min.io/server/minio/release/linux-amd64/minio")
|
||
try:
|
||
print(f"MinIO 二进制未找到,尝试下载到 {target_path}")
|
||
target_path.parent.mkdir(parents=True, exist_ok=True)
|
||
with urllib.request.urlopen(url, timeout=30) as response, open(target_path, 'wb') as out_file:
|
||
out_file.write(response.read())
|
||
target_path.chmod(0o755)
|
||
abs_path = str(target_path.resolve())
|
||
print(f"已下载 MinIO 二进制到 {abs_path}")
|
||
return abs_path
|
||
except Exception as err:
|
||
print(f"自动下载 MinIO 失败:{err}")
|
||
return None
|
||
|
||
|
||
def start_minio_if_needed():
|
||
if not env_bool("MINIO_AUTO_START", True):
|
||
return
|
||
endpoint = os.getenv('MINIO_ENDPOINT')
|
||
if not endpoint:
|
||
return
|
||
|
||
if is_minio_running(endpoint):
|
||
print(f"MinIO 已在 {endpoint} 运行,跳过启动")
|
||
return
|
||
|
||
minio_cmd = shutil.which('minio')
|
||
if not minio_cmd:
|
||
minio_bin = Path(os.getenv('MINIO_BIN_PATH', 'minio'))
|
||
minio_cmd = str(minio_bin.resolve()) if minio_bin.exists() else None
|
||
if not minio_cmd and env_bool("MINIO_AUTO_DOWNLOAD", False):
|
||
minio_cmd = download_minio_binary(minio_bin)
|
||
if not minio_cmd:
|
||
print("MinIO 二进制未找到,且未启用或未完成自动下载")
|
||
return
|
||
|
||
data_dir = MINIO_DATA_DIR
|
||
data_dir.mkdir(parents=True, exist_ok=True)
|
||
minio_address = os.getenv("MINIO_LISTEN_ADDRESS", ":9000")
|
||
print(f"尝试自动启动 MinIO:{minio_cmd} server {data_dir} --address {minio_address}")
|
||
try:
|
||
subprocess.Popen([minio_cmd, 'server', str(data_dir), '--address', minio_address], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||
time.sleep(float(os.getenv("MINIO_START_WAIT_SEC", "5")))
|
||
if is_minio_running(endpoint):
|
||
print("MinIO 已启动成功")
|
||
else:
|
||
print("MinIO 启动失败,仍然无法连接")
|
||
except Exception as err:
|
||
print(f"启动 MinIO 失败:{err}")
|
||
|
||
|
||
# Manifest signing key path
|
||
MANIFEST_PRIVATE_KEY_PATH = str(configured_path("MANIFEST_PRIVATE_KEY_PATH", "keys/manifest_private_key.pem"))
|
||
|
||
# 创建APP绑定生命周期
|
||
app = FastAPI(title=os.getenv("SERVICE_TITLE", "软件自动升级服务"), lifespan=lifespan)
|
||
|
||
ADMIN_HTML_PATH = configured_path("ADMIN_HTML_PATH", "../client/admin.html")
|
||
|
||
|
||
@app.get("/", include_in_schema=False)
|
||
@app.get("/admin.html", include_in_schema=False)
|
||
def 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"})
|
||
|
||
|
||
@app.get("/health", include_in_schema=False)
|
||
def health_check():
|
||
return {"status": "ok"}
|
||
|
||
# 本地上传备份文件目录(MinIO 不通时会保存到这里)
|
||
app.mount("/static", StaticFiles(directory=str(minio_tool.LOCAL_UPLOAD_ROOT)), name="static")
|
||
|
||
# 跨域配置(给admin前端页面用)
|
||
cors_origins = [item.strip() for item in os.getenv("CORS_ALLOW_ORIGINS", "*").split(",") if item.strip()]
|
||
app.add_middleware(
|
||
CORSMiddleware,
|
||
allow_origins=cors_origins,
|
||
allow_credentials=True,
|
||
allow_methods=["*"],
|
||
allow_headers=["*"],
|
||
)
|
||
|
||
# 管理后台写操作统一审计;不保存令牌明文和请求正文。
|
||
@app.middleware("http")
|
||
async def admin_audit_middleware(request: Request, call_next):
|
||
should_audit = request.url.path.startswith("/admin/") and request.method not in ("GET", "HEAD", "OPTIONS")
|
||
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)
|
||
|
||
# ===================== 全局客户端鉴权中间件 =====================
|
||
@app.middleware("http")
|
||
async def auth_middleware(request: Request, call_next):
|
||
path = request.url.path
|
||
# 后台接口、文档接口、静态回退链接全部跳过客户端token校验
|
||
skip_paths = ("/admin", "/docs", "/openapi.json", "/redoc", "/static", "/health", "/api/v1/health", "/api/v1/crash-reports", "/api/v1/symbols")
|
||
if path == "/" or path.startswith(skip_paths):
|
||
response = await call_next(request)
|
||
return response
|
||
|
||
client_token = request.headers.get("X-Client-Token")
|
||
if client_token != VALID_CLIENT_TOKEN:
|
||
return JSONResponse(status_code=401, content={"detail": "非法客户端,令牌校验失败"})
|
||
if path != "/api/v1/device/issue":
|
||
credential = request.headers.get("X-Device-Credential", "")
|
||
if not credential:
|
||
return JSONResponse(status_code=401, content={"detail": {
|
||
"error": "device_credential_required", "msg": "缺少设备身份凭证"
|
||
}})
|
||
try:
|
||
request.state.device_identity = verify_device_credential(credential)
|
||
except HTTPException as exc:
|
||
return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail})
|
||
response = await call_next(request)
|
||
return response
|
||
|
||
# ========== 请求体模型 ==========
|
||
class CheckUpdateReq(BaseModel):
|
||
app_id: str
|
||
current_version: str
|
||
channel: str
|
||
client_protocol: int = 1
|
||
|
||
class DownloadUrlReq(BaseModel):
|
||
app_id: str
|
||
channel: str
|
||
version: str
|
||
version_id: int
|
||
files: list[str]
|
||
|
||
class ManifestReq(BaseModel):
|
||
app_id: str
|
||
channel: str
|
||
version: str
|
||
version_id: int
|
||
|
||
class ReportReq(BaseModel):
|
||
app_id: str
|
||
device_id: str
|
||
from_version: str
|
||
to_version: str
|
||
result: str
|
||
|
||
class ChangeAdminTokenReq(BaseModel):
|
||
new_token: str
|
||
|
||
class DownloadReportReq(BaseModel):
|
||
app_id: str
|
||
channel: str
|
||
version: str
|
||
result: str
|
||
files: list[dict]
|
||
|
||
class DeviceIssueReq(BaseModel):
|
||
app_id: str
|
||
channel: str
|
||
license_key: str
|
||
installation_id: str
|
||
machine_hash: str = ""
|
||
|
||
# 统一后台鉴权:全部接口从Header读取token,不再区分表单/头
|
||
def admin_auth(X_Admin_Token: str = Header("")):
|
||
if not secrets.compare_digest(X_Admin_Token, ADMIN_TOKEN):
|
||
raise HTTPException(status_code=403, detail="后台密钥错误,禁止访问")
|
||
return True
|
||
|
||
@app.get("/admin/auth/check")
|
||
def admin_auth_check(auth=Depends(admin_auth)):
|
||
return {"code": 0, "msg": "管理员令牌有效"}
|
||
|
||
# Manifest signing helpers
|
||
|
||
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")
|
||
|
||
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":"设备凭证格式或签名无效"})
|
||
conn = db.get_conn(); row = conn.execute("SELECT * FROM devices WHERE device_id=?", (identity["device_id"],)).fetchone()
|
||
if not row:
|
||
conn.close(); raise HTTPException(status_code=401, detail={"error":"device_not_registered","msg":"设备未登记"})
|
||
if row["disabled"]:
|
||
reason=row["disabled_reason"] or "设备已被管理员禁用"; conn.close()
|
||
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"]):
|
||
conn.close(); raise HTTPException(status_code=401, detail={"error":"device_credential_revoked","msg":"设备凭证已失效"})
|
||
license_row = conn.execute("SELECT * FROM licenses WHERE license_id=?", (identity.get("license_id", ""),)).fetchone()
|
||
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:
|
||
conn.close(); 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"):
|
||
conn.close(); raise HTTPException(status_code=403, detail={"error":"license_scope_mismatch","msg":"授权应用或渠道不匹配"})
|
||
conn.execute("UPDATE devices SET last_seen_at=datetime('now') WHERE device_id=?", (identity["device_id"],)); conn.commit(); conn.close()
|
||
return identity
|
||
|
||
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)
|
||
|
||
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
|
||
blocked_directories = {".git", ".vs", "cmakefiles", "debug", "update", "update_temp"}
|
||
blocked_suffixes = ("_autogen",)
|
||
for part in parts[:-1]:
|
||
if (part in blocked_directories or part.startswith("build")
|
||
or part.endswith(blocked_suffixes)):
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail=f"发布目录包含构建或运行时目录,必须选择干净的 Release 输出目录: {relative_path}"
|
||
)
|
||
|
||
# 文件sha256工具
|
||
def calc_sha256(data: bytes):
|
||
sha = hashlib.sha256()
|
||
sha.update(data)
|
||
return sha.hexdigest()
|
||
|
||
|
||
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
|
||
|
||
|
||
@app.exception_handler(CrashApiException)
|
||
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 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 and secrets.compare_digest(token, CRASH_SYMBOL_TOKEN):
|
||
roles.add("symbols")
|
||
if CRASH_ADMIN_TOKEN:
|
||
if secrets.compare_digest(token, CRASH_ADMIN_TOKEN):
|
||
roles.add("admin")
|
||
elif secrets.compare_digest(token, 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:
|
||
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
|
||
crash_api_error(503, "service_unavailable", "could not allocate report id", True)
|
||
|
||
|
||
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"],
|
||
},
|
||
)
|
||
|
||
|
||
@app.get("/api/v1/health")
|
||
def crash_health():
|
||
return {
|
||
"status": "ok",
|
||
"service": "simcae-crash-server",
|
||
"version": CRASH_SERVICE_VERSION,
|
||
"timeUtc": utc_now_text(),
|
||
}
|
||
|
||
|
||
@app.post("/api/v1/crash-reports")
|
||
async def create_crash_report(request: Request):
|
||
tmp_dir: Path | None = None
|
||
final_dir: Path | None = None
|
||
stored = False
|
||
conn = None
|
||
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 = conn.execute("SELECT * FROM crash_reports WHERE client_report_id=?", (client_report_id,)).fetchone()
|
||
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 {}
|
||
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(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||
""",
|
||
(
|
||
report_id,
|
||
client_report_id,
|
||
package_hash,
|
||
"stored",
|
||
"not_started",
|
||
metadata.get("product"),
|
||
metadata.get("appVersion"),
|
||
metadata.get("gitCommit"),
|
||
metadata.get("buildType"),
|
||
metadata.get("channel"),
|
||
str(crash_obj.get("exceptionCode") or ""),
|
||
metadata.get("crashTimeUtc"),
|
||
received_at,
|
||
remote_addr,
|
||
content_length,
|
||
metadata_sha,
|
||
len(metadata_raw),
|
||
minidump_info["sha256"],
|
||
minidump_info["size"],
|
||
attachments_info["sha256"] if attachments_info else "",
|
||
attachments_info["size"] if attachments_info else 0,
|
||
str(final_dir),
|
||
metadata_raw.decode("utf-8", errors="replace"),
|
||
json.dumps(server_info, ensure_ascii=False, separators=(",", ":")),
|
||
),
|
||
)
|
||
conn.commit()
|
||
stored = True
|
||
row = conn.execute("SELECT * FROM crash_reports WHERE report_id=?", (report_id,)).fetchone()
|
||
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 = conn.execute("SELECT * FROM crash_reports WHERE client_report_id=?", (client_report_id,)).fetchone()
|
||
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)
|
||
|
||
|
||
@app.get("/api/v1/crash-reports/{report_id}")
|
||
def get_crash_report(report_id: str, request: Request):
|
||
require_crash_bearer(request, {"admin"})
|
||
conn = db.get_conn()
|
||
row = conn.execute("SELECT * FROM crash_reports WHERE report_id=?", (report_id,)).fetchone()
|
||
conn.close()
|
||
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"},
|
||
},
|
||
}
|
||
|
||
|
||
@app.get("/api/v1/crash-reports/{report_id}/files/{file_name}")
|
||
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)
|
||
conn = db.get_conn()
|
||
row = conn.execute("SELECT * FROM crash_reports WHERE report_id=?", (report_id,)).fetchone()
|
||
if not row:
|
||
conn.close()
|
||
crash_api_error(404, "metadata_invalid", "crash report not found", False)
|
||
target = Path(row["storage_path"]) / stored_name
|
||
try:
|
||
conn.execute(
|
||
"INSERT INTO crash_file_access_logs(report_id,file_name,actor_hash,ip,user_agent) VALUES(?,?,?,?,?)",
|
||
(report_id, stored_name, token_digest(token)[:16], request.client.host if request.client else "", request.headers.get("user-agent", "")[:300]),
|
||
)
|
||
conn.commit()
|
||
finally:
|
||
conn.close()
|
||
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)
|
||
|
||
|
||
@app.post("/api/v1/symbols")
|
||
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 = conn.execute("SELECT * FROM crash_symbol_uploads WHERE identity_hash=?", (identity_hash,)).fetchone()
|
||
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
|
||
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(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||
""",
|
||
(
|
||
upload_id,
|
||
identity_hash,
|
||
"stored",
|
||
metadata.get("product"),
|
||
metadata.get("appVersion"),
|
||
metadata.get("gitCommit"),
|
||
metadata.get("buildType"),
|
||
metadata.get("platform"),
|
||
metadata.get("toolchain"),
|
||
metadata.get("createdAtUtc"),
|
||
received_at,
|
||
metadata_sha,
|
||
len(metadata_raw),
|
||
symbols_info["sha256"],
|
||
symbols_info["size"],
|
||
str(final_dir),
|
||
metadata_raw.decode("utf-8", errors="replace"),
|
||
),
|
||
)
|
||
conn.commit()
|
||
stored = True
|
||
row = conn.execute("SELECT * FROM crash_symbol_uploads WHERE symbol_upload_id=?", (upload_id,)).fetchone()
|
||
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. 首次设备登记;仅此客户端接口允许没有设备凭证
|
||
@app.post("/api/v1/device/issue")
|
||
async def issue_device(request: Request, body: DeviceIssueReq = 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="设备登记参数无效")
|
||
key_hash=token_digest(body.license_key.strip()); now=datetime.now(timezone.utc)
|
||
conn=db.get_conn(); conn.execute("BEGIN IMMEDIATE")
|
||
require_channel(conn, app_id, channel)
|
||
lic=conn.execute("SELECT * FROM licenses WHERE license_key_hash=?",(key_hash,)).fetchone()
|
||
try: expiry=datetime.fromisoformat((lic["valid_until"] if lic else "").replace("Z","+00:00"))
|
||
except ValueError: expiry=now-timedelta(seconds=1)
|
||
if not lic or lic["status"]!="active" or expiry<=now or lic["app_id"]!=app_id or lic["channel_code"]!=channel:
|
||
conn.rollback();conn.close();raise HTTPException(status_code=403,detail={"error":"license_invalid","msg":"授权密钥无效、已过期或不适用于当前应用/渠道"})
|
||
row=conn.execute("SELECT * FROM devices WHERE app_id=? AND installation_id=?",(app_id,installation_id)).fetchone()
|
||
if row and row["disabled"]: reason=row["disabled_reason"] or "设备已被禁用";conn.rollback();conn.close();raise HTTPException(status_code=403,detail={"error":"device_disabled","msg":reason})
|
||
if row and row["license_id"] not in ("",lic["license_id"]): conn.rollback();conn.close();raise HTTPException(status_code=409,detail="该安装实例已绑定其他授权")
|
||
device_id=row["device_id"] if row else "dev_"+secrets.token_hex(16); seq=int(row["credential_seq"]) if row else 1
|
||
bound=conn.execute("SELECT 1 FROM license_devices WHERE license_id=? AND device_id=?",(lic["license_id"],device_id)).fetchone()
|
||
used=conn.execute("SELECT COUNT(*) FROM license_devices WHERE license_id=?",(lic["license_id"],)).fetchone()[0]
|
||
if not bound and used>=int(lic["max_devices"]): conn.rollback();conn.close();raise HTTPException(status_code=403,detail={"error":"license_device_limit","msg":"授权设备数量已达到上限"})
|
||
ip=request.client.host if request.client else ""
|
||
if row: conn.execute("UPDATE devices SET machine_hash=?,license_id=?,channel=?,last_seen_at=datetime('now'),last_ip=? WHERE device_id=?",(body.machine_hash.strip().lower(),lic["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,body.machine_hash.strip().lower(),seq,ip,lic["license_id"],channel))
|
||
conn.execute("INSERT OR IGNORE INTO license_devices(license_id,device_id) VALUES(?,?)",(lic["license_id"],device_id));conn.commit();conn.close()
|
||
identity={"device_id":device_id,"license_id":lic["license_id"],"app_id":app_id,"channel":channel,"installation_id":installation_id,"credential_seq":seq,"issued_at":now.replace(microsecond=0).isoformat().replace("+00:00","Z"),"valid_until":lic["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}
|
||
|
||
# 1. 查询更新接口
|
||
@app.post("/api/v1/update/check")
|
||
async def check_update(request: Request, body: CheckUpdateReq = 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="设备凭证与应用/渠道不匹配")
|
||
print("收到客户端版本检测请求,参数:", body.model_dump())
|
||
app_id, cur_ver, channel = body.app_id, body.current_version, body.channel
|
||
conn = db.get_conn()
|
||
require_channel(conn, app_id, channel)
|
||
ver = conn.execute(
|
||
"SELECT * FROM versions WHERE app_id=? AND channel=? AND latest=1", (app_id, channel)
|
||
).fetchone()
|
||
policy_row = conn.execute(
|
||
"SELECT * FROM version_policies WHERE app_id=? AND channel=?", (app_id, channel)
|
||
).fetchone()
|
||
conn.close()
|
||
|
||
settings = policy_row_to_dict(policy_row)
|
||
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
|
||
}
|
||
|
||
# 2. 获取文件下载链接
|
||
@app.post("/api/v1/update/download-url")
|
||
async def get_download_url(request: Request, body: DownloadUrlReq = 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_id = body.version_id
|
||
conn = db.get_conn()
|
||
version_row = conn.execute("SELECT app_id,channel,version FROM versions WHERE id=?", (ver_id,)).fetchone()
|
||
if not version_row or version_row["app_id"] != body.app_id or version_row["channel"] != body.channel or version_row["version"] != body.version:
|
||
conn.close(); raise HTTPException(status_code=404, detail="版本与应用/渠道不匹配")
|
||
rows = conn.execute(
|
||
"SELECT path, sha256, size FROM version_files WHERE version_id=?",
|
||
(ver_id,)
|
||
).fetchall()
|
||
identity=request.state.device_identity; ip=request.client.host if request.client else ""; ua=request.headers.get("user-agent", "")[:300]
|
||
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"],body.app_id,body.version,body.channel,r["path"],r["size"],"authorized",ip,ua) for r in rows])
|
||
conn.commit(); conn.close()
|
||
res = []
|
||
base_path = f"{body.app_id}/{body.channel}/{body.version}/files/"
|
||
for r in rows:
|
||
full_path = base_path + r["path"]
|
||
url = minio_tool.get_url(full_path)
|
||
res.append({
|
||
"path": r["path"],
|
||
"url": url,
|
||
"sha256": r["sha256"],
|
||
"size": r["size"]
|
||
})
|
||
return {"files": res}
|
||
|
||
@app.post("/api/v1/update/download-report")
|
||
async def report_download(request: Request, body: DownloadReportReq = 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="下载结果无效")
|
||
conn=db.get_conn(); 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))
|
||
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 {"code":0,"logged":len(values)}
|
||
|
||
# 2b. 获取版本 manifest
|
||
@app.post("/api/v1/update/manifest")
|
||
async def get_manifest(request: Request, body: ManifestReq = 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_id = body.version_id
|
||
conn = db.get_conn()
|
||
ver = conn.execute(
|
||
"SELECT app_id, channel, version, create_time FROM versions WHERE id=?",
|
||
(ver_id,)
|
||
).fetchone()
|
||
if not ver:
|
||
conn.close()
|
||
raise HTTPException(status_code=404, detail="Version not found")
|
||
|
||
rows = conn.execute(
|
||
"SELECT path, sha256, size FROM version_files WHERE version_id=?",
|
||
(ver_id,)
|
||
).fetchall()
|
||
conn.close()
|
||
|
||
files = []
|
||
for r in rows:
|
||
files.append({
|
||
"path": r["path"],
|
||
"sha256": r["sha256"],
|
||
"size": r["size"],
|
||
"executable": is_executable_path(r["path"])
|
||
})
|
||
|
||
manifest = {
|
||
"app_id": ver["app_id"],
|
||
"version": ver["version"],
|
||
"channel": ver["channel"],
|
||
"platform": TARGET_PLATFORM,
|
||
"arch": TARGET_ARCH,
|
||
"manifest_seq": int(ver_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
|
||
}
|
||
|
||
# 3. 升级结果上报
|
||
@app.post("/api/v1/update/report")
|
||
async def report(request: Request, body: ReportReq = 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="上报身份与设备凭证不匹配")
|
||
conn = db.get_conn()
|
||
insert_sql = """
|
||
INSERT INTO upgrade_logs(device_id,from_ver,to_ver,result,create_time)
|
||
VALUES (?,?,?,?,datetime('now'))
|
||
"""
|
||
conn.execute(insert_sql, (body.device_id, body.from_version, body.to_version, body.result))
|
||
conn.commit()
|
||
conn.close()
|
||
return {"code": 0, "msg": "上报成功"}
|
||
|
||
# ==================== 管理后台 Admin 全套接口(统一Header鉴权) ====================
|
||
@app.post("/admin/token/change")
|
||
def admin_change_token(body: ChangeAdminTokenReq, auth=Depends(admin_auth)):
|
||
global ADMIN_TOKEN
|
||
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 个字符")
|
||
ADMIN_TOKEN = new_token
|
||
return {"code": 0, "msg": "管理员令牌已在当前服务进程生效;如需重启后继续使用,请同步修改 .env 的 ADMIN_TOKEN"}
|
||
|
||
# 应用列表 GET
|
||
@app.get("/admin/app/list")
|
||
def admin_get_app_list(auth=Depends(admin_auth)):
|
||
conn = db.get_conn()
|
||
rows = conn.execute("SELECT app_id, app_name FROM apps").fetchall()
|
||
conn.close()
|
||
return {"list": [{"app_id": r["app_id"], "app_name": r["app_name"]} for r in rows]}
|
||
|
||
# 新增应用 POST json
|
||
@app.post("/admin/app/add")
|
||
def admin_add_app(body: dict, auth=Depends(admin_auth)):
|
||
aid = body["app_id"]
|
||
aname = body["app_name"]
|
||
conn = db.get_conn()
|
||
try:
|
||
conn.execute("INSERT INTO apps(app_id, app_name) VALUES (?, ?)", (aid, aname))
|
||
conn.executemany("INSERT INTO channels(app_id,channel_code,display_name,enabled,sort_order) VALUES(?,?,?,?,?)",
|
||
[(aid,"stable","正式版",1,10),(aid,"preview","预览版",1,20),(aid,"dev","开发版",1,30)])
|
||
conn.commit()
|
||
except Exception as e:
|
||
return {"msg": f"创建失败:{str(e)}"}
|
||
conn.close()
|
||
return {"msg": "应用创建成功"}
|
||
|
||
@app.get("/admin/channel/list")
|
||
def admin_channel_list(app_id: str, include_disabled: bool = True, auth=Depends(admin_auth)):
|
||
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 {"list":[{"channel_code":r["channel_code"],"display_name":r["display_name"],"enabled":bool(r["enabled"]),"sort_order":r["sort_order"]} for r in rows]}
|
||
|
||
@app.post("/admin/channel/save")
|
||
def admin_channel_save(body: dict, auth=Depends(admin_auth)):
|
||
app_id=str(body.get("app_id") or "").strip();code=str(body.get("channel_code") or "").strip();name=str(body.get("display_name") or "").strip();enabled=bool(body.get("enabled",True));order=int(body.get("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="渠道参数无效;代码仅允许字母、数字、下划线和连字符")
|
||
conn=db.get_conn();
|
||
if not conn.execute("SELECT 1 FROM apps WHERE app_id=?",(app_id,)).fetchone():conn.close();raise HTTPException(status_code=404,detail="应用不存在")
|
||
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),order));conn.commit();conn.close();return {"msg":"渠道已保存"}
|
||
|
||
# 查询和保存应用渠道策略
|
||
@app.get("/admin/policy")
|
||
def admin_get_policy(app_id: str, channel: str, auth=Depends(admin_auth)):
|
||
conn = db.get_conn()
|
||
row = conn.execute("SELECT * FROM version_policies WHERE app_id=? AND channel=?", (app_id, channel)).fetchone()
|
||
conn.close()
|
||
result = policy_row_to_dict(row)
|
||
result.update({"app_id": app_id, "channel": channel})
|
||
return result
|
||
|
||
|
||
@app.post("/admin/policy/save")
|
||
def admin_save_policy(body: dict, auth=Depends(admin_auth)):
|
||
app_id = str(body.get("app_id") or "").strip()
|
||
channel = str(body.get("channel") or "").strip()
|
||
if not app_id or not validate_channel_code(channel):
|
||
raise HTTPException(status_code=400, detail="App ID 或渠道无效")
|
||
disabled = body.get("disabled_versions") or []
|
||
if not isinstance(disabled, list) or any(not isinstance(v, str) for v in disabled):
|
||
raise HTTPException(status_code=400, detail="disabled_versions 必须是版本字符串数组")
|
||
valid_until = str(body.get("valid_until") or "").strip()
|
||
try:
|
||
datetime.fromisoformat(valid_until.replace("Z", "+00:00"))
|
||
except ValueError:
|
||
raise HTTPException(status_code=400, detail="valid_until 必须是 ISO-8601 时间")
|
||
conn = db.get_conn()
|
||
require_channel(conn, app_id, channel)
|
||
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(body.get("force_update"))), int(bool(body.get("allow_rollback"))),
|
||
int(bool(body.get("offline_allowed"))), valid_until,
|
||
str(body.get("min_supported_version") or "").strip(),
|
||
json.dumps(disabled, ensure_ascii=False), str(body.get("message") or "").strip(), 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(); conn.close()
|
||
return {"msg": "策略已保存并递增 policy_seq", "policy_seq": next_seq}
|
||
|
||
|
||
# 发布新版本(文件上传)
|
||
@app.post("/admin/publish")
|
||
async def admin_publish_version(request: Request, auth=Depends(admin_auth)):
|
||
# 在 Starlette 解析 multipart 前检查空间;否则大文件 rollover 会直接抛出 Errno 28。
|
||
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")
|
||
|
||
spool_free = shutil.disk_usage(UPLOAD_SPOOL_DIR).free
|
||
storage_free = shutil.disk_usage(MINIO_DATA_DIR).free
|
||
if spool_free < content_length + 64 * 1024 * 1024:
|
||
raise HTTPException(status_code=507, detail={
|
||
"error": "upload_temp_space_insufficient",
|
||
"msg": "上传临时空间不足,请减小发布包或扩容 /dev/shm",
|
||
"required_bytes": content_length + 64 * 1024 * 1024,
|
||
"available_bytes": spool_free,
|
||
})
|
||
if storage_free < content_length + UPLOAD_SPACE_RESERVE:
|
||
raise HTTPException(status_code=507, detail={
|
||
"error": "storage_space_insufficient",
|
||
"msg": "版本存储空间不足,请扩容服务器根卷或删除不再需要的历史版本",
|
||
"required_bytes": content_length + UPLOAD_SPACE_RESERVE,
|
||
"available_bytes": storage_free,
|
||
})
|
||
|
||
# 手动解析表单,增加兼容性并便于调试前端上传问题
|
||
try:
|
||
form = await request.form()
|
||
except OSError as err:
|
||
if getattr(err, "errno", None) == 28:
|
||
raise HTTPException(status_code=507, detail="上传临时空间已满,请扩容后重试")
|
||
raise
|
||
# 尝试从 form 中获取字段
|
||
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(可能是多个同名字段)
|
||
files = []
|
||
if hasattr(form, "getlist"):
|
||
files = form.getlist("files")
|
||
else:
|
||
# 兼容性降级:遍历所有表单项
|
||
for k, v in form.multi_items():
|
||
if k == "files":
|
||
files.append(v)
|
||
|
||
# 如果关键信息缺失,返回详细调试信息,方便前端定位问题
|
||
if not app_id or not version:
|
||
headers = {k: v for k, v in request.headers.items()}
|
||
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 "")
|
||
}
|
||
raise HTTPException(status_code=422, detail=detail)
|
||
|
||
if not files:
|
||
raise HTTPException(status_code=400, detail="未选择任何文件,请选择程序文件夹")
|
||
|
||
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="文件数量与相对路径数量不一致")
|
||
|
||
upload_items = []
|
||
seen_paths = set()
|
||
for index, file in enumerate(files):
|
||
if not hasattr(file, "filename"):
|
||
raise HTTPException(status_code=400, detail="上传内容中包含无效文件")
|
||
raw_path = relative_paths[index] if relative_paths else file.filename
|
||
rel_path = normalize_relative_path(str(raw_path))
|
||
validate_release_path(rel_path)
|
||
path_key = rel_path.lower()
|
||
if path_key in seen_paths:
|
||
raise HTTPException(status_code=400, detail=f"存在重复文件路径: {rel_path}")
|
||
seen_paths.add(path_key)
|
||
upload_items.append((file, rel_path))
|
||
|
||
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 输出目录"
|
||
)
|
||
|
||
|
||
conn = db.get_conn()
|
||
require_channel(conn, str(app_id), str(channel))
|
||
publish_started = False
|
||
publish_prefix = f"{app_id}/{channel}/{version}/"
|
||
try:
|
||
cur = conn.cursor()
|
||
exists = cur.execute(
|
||
"SELECT 1 FROM versions WHERE app_id=? AND channel=? AND version=?",
|
||
(app_id, channel, version)
|
||
).fetchone()
|
||
if exists:
|
||
raise HTTPException(status_code=400, detail="该版本已存在,请更换版本号或先删除旧版本")
|
||
|
||
cur.execute("UPDATE versions SET latest=0 WHERE app_id=? AND channel=?", (app_id, channel))
|
||
cur.execute(
|
||
"INSERT INTO versions(app_id, channel, version, latest, client_protocol) VALUES (?,?,?,1,?)",
|
||
(app_id, channel, version, client_protocol)
|
||
)
|
||
new_vid = cur.lastrowid
|
||
publish_started = True
|
||
|
||
for file, rel_path in upload_items:
|
||
content = await file.read()
|
||
|
||
f_size = len(content)
|
||
f_sha = calc_sha256(content)
|
||
obj_path = f"{app_id}/{channel}/{version}/files/{rel_path}"
|
||
put_result = minio_tool.put_file(obj_path, content, f_size)
|
||
if put_result.get('storage') == 'local':
|
||
print(f"MinIO 不可用,已保存到本地回退:{put_result.get('path')}")
|
||
cur.execute(
|
||
"INSERT INTO version_files(version_id, path, sha256, size) VALUES (?,?,?,?)",
|
||
(new_vid, rel_path, f_sha, f_size)
|
||
)
|
||
|
||
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)} 个文件"}
|
||
|
||
@app.post("/admin/version/offline-package")
|
||
def admin_offline_package(body: dict, auth=Depends(admin_auth)):
|
||
version_id = int(body.get("version_id") or 0)
|
||
conn=db.get_conn();ver=conn.execute("SELECT app_id,channel,version,create_time FROM versions WHERE id=?",(version_id,)).fetchone();rows=conn.execute("SELECT path,sha256,size FROM version_files WHERE version_id=? ORDER BY id",(version_id,)).fetchall();conn.close()
|
||
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)})
|
||
|
||
# 获取版本列表 GET
|
||
@app.get("/admin/version/list")
|
||
def admin_get_version_list(app_id: str, auth=Depends(admin_auth)):
|
||
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()
|
||
out = []
|
||
for r in rows:
|
||
out.append({
|
||
"id": r["id"],
|
||
"version": r["version"],
|
||
"channel": r["channel"],
|
||
"latest": bool(r["latest"]),
|
||
"client_protocol": int(r["client_protocol"] or 1),
|
||
"create_time": r["create_time"]
|
||
})
|
||
return {"list": out}
|
||
|
||
# 修正历史版本的客户端协议标记
|
||
@app.post("/admin/version/set-protocol")
|
||
def admin_set_version_protocol(body: dict, auth=Depends(admin_auth)):
|
||
version_id = int(body.get("version_id") or 0)
|
||
client_protocol = int(body.get("client_protocol") or 0)
|
||
if version_id < 1 or client_protocol < 1:
|
||
raise HTTPException(status_code=400, detail="版本 ID 和客户端协议必须是正整数")
|
||
conn = db.get_conn()
|
||
cur = conn.execute("UPDATE versions SET client_protocol=? WHERE id=?", (client_protocol, version_id))
|
||
if cur.rowcount != 1:
|
||
conn.close(); raise HTTPException(status_code=404, detail="版本不存在")
|
||
conn.commit(); conn.close()
|
||
return {"msg": "客户端协议已更新", "version_id": version_id, "client_protocol": client_protocol}
|
||
|
||
|
||
# 设置指定版本为渠道最新 POST json
|
||
@app.post("/admin/version/set-latest")
|
||
def admin_set_latest(body: dict, auth=Depends(admin_auth)):
|
||
vid = body["version_id"]
|
||
conn = db.get_conn()
|
||
cur = conn.cursor()
|
||
v_info = cur.execute("SELECT app_id, channel FROM versions WHERE id=?", (vid,)).fetchone()
|
||
aid, ch = v_info["app_id"], v_info["channel"]
|
||
cur.execute("UPDATE versions SET latest=0 WHERE app_id=? AND channel=?", (aid, ch))
|
||
cur.execute("UPDATE versions SET latest=1 WHERE id=?", (vid,))
|
||
conn.commit()
|
||
conn.close()
|
||
return {"msg": "已切换为渠道最新版本"}
|
||
|
||
# 删除版本 POST json
|
||
@app.post("/admin/version/delete")
|
||
def admin_delete_version(body: dict, auth=Depends(admin_auth)):
|
||
vid = body["version_id"]
|
||
conn = db.get_conn()
|
||
cur = conn.cursor()
|
||
v_info = cur.execute("SELECT app_id, channel, version, latest FROM versions WHERE id=?", (vid,)).fetchone()
|
||
if not v_info:
|
||
conn.close()
|
||
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"])
|
||
# 删除minio下该版本全部文件
|
||
prefix = f"{aid}/{ch}/{ver}/files/"
|
||
minio_tool.remove_prefix(prefix)
|
||
# 删除数据库记录
|
||
cur.execute("DELETE FROM version_files WHERE version_id=?", (vid,))
|
||
cur.execute("DELETE FROM versions WHERE id=?", (vid,))
|
||
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",
|
||
(aid, ch)
|
||
).fetchone()
|
||
if replacement:
|
||
cur.execute("UPDATE versions SET latest=1 WHERE id=?", (replacement["id"],))
|
||
promoted_version = replacement["version"]
|
||
conn.commit()
|
||
conn.close()
|
||
return {
|
||
"msg": "版本、云端文件、数据库记录全部删除完成",
|
||
"promoted_latest": promoted_version
|
||
}
|
||
|
||
# 获取升级日志 GET
|
||
@app.get("/admin/report/list")
|
||
def admin_get_report_log(auth=Depends(admin_auth)):
|
||
conn = db.get_conn()
|
||
rows = conn.execute("""
|
||
SELECT device_id,from_ver,to_ver,result,create_time
|
||
FROM upgrade_logs ORDER BY create_time DESC LIMIT 200
|
||
""").fetchall()
|
||
conn.close()
|
||
res = []
|
||
for r in rows:
|
||
res.append({
|
||
"device_id": r["device_id"],
|
||
"app_id": "",
|
||
"from_version": r["from_ver"],
|
||
"to_version": r["to_ver"],
|
||
"result": r["result"],
|
||
"create_time": r["create_time"]
|
||
})
|
||
return {"list": res}
|
||
|
||
|
||
@app.get("/admin/runtime-config")
|
||
def admin_runtime_config(auth=Depends(admin_auth)):
|
||
return {
|
||
"release_main_executable": RELEASE_MAIN_EXECUTABLE,
|
||
"target_platform": TARGET_PLATFORM,
|
||
"target_arch": TARGET_ARCH,
|
||
}
|
||
|
||
|
||
@app.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));conn=db.get_conn();rows=conn.execute("SELECT * FROM download_logs WHERE app_id=? ORDER BY id DESC LIMIT ?",(app_id,limit)).fetchall() if app_id else conn.execute("SELECT * FROM download_logs ORDER BY id DESC LIMIT ?",(limit,)).fetchall();conn.close();return {"list":[dict(r) for r in rows]}
|
||
|
||
@app.get("/admin/audit-log/list")
|
||
def admin_audit_log_list(limit: int=300, auth=Depends(admin_auth)):
|
||
conn=db.get_conn();rows=conn.execute("SELECT * FROM admin_audit_logs ORDER BY id DESC LIMIT ?",(max(1,min(limit,1000)),)).fetchall();conn.close();return {"list":[dict(r) for r in rows]}
|
||
|
||
@app.post("/admin/license/create")
|
||
def admin_license_create(body: dict, auth=Depends(admin_auth)):
|
||
app_id=str(body.get("app_id") or "").strip();channel=str(body.get("channel") or "").strip();customer=str(body.get("customer_name") or "").strip();max_devices=int(body.get("max_devices") or 1);valid_until=str(body.get("valid_until") or "").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);conn=db.get_conn();require_channel(conn,app_id,channel);conn.execute("INSERT INTO licenses(license_id,license_key_hash,customer_name,app_id,channel_code,max_devices,valid_until,status) VALUES(?,?,?,?,?,?,?,'active')",(license_id,token_digest(license_key),customer,app_id,channel,max_devices,valid_until));conn.commit();conn.close();return {"license_id":license_id,"license_key":license_key,"msg":"授权已创建;密钥仅本次返回,请立即保存"}
|
||
|
||
@app.get("/admin/license/list")
|
||
def admin_license_list(app_id: str="", auth=Depends(admin_auth)):
|
||
conn=db.get_conn();rows=conn.execute("SELECT l.*,(SELECT COUNT(*) FROM license_devices d WHERE d.license_id=l.license_id) used_devices FROM licenses l WHERE app_id=? ORDER BY created_at DESC",(app_id,)).fetchall() if app_id else conn.execute("SELECT l.*,(SELECT COUNT(*) FROM license_devices d WHERE d.license_id=l.license_id) used_devices FROM licenses l ORDER BY created_at DESC").fetchall();conn.close();return {"list":[{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"]} for r in rows]}
|
||
|
||
@app.post("/admin/license/set-status")
|
||
def admin_license_set_status(body: dict, auth=Depends(admin_auth)):
|
||
status=str(body.get("status") or "");license_id=str(body.get("license_id") or "");
|
||
if status not in ("active","disabled"): raise HTTPException(status_code=400,detail="授权状态无效")
|
||
conn=db.get_conn();cur=conn.execute("UPDATE licenses SET status=?,updated_at=datetime('now') WHERE license_id=?",(status,license_id));conn.commit();conn.close()
|
||
if cur.rowcount!=1: raise HTTPException(status_code=404,detail="授权不存在")
|
||
return {"msg":"授权状态已更新"}
|
||
|
||
@app.get("/admin/device/list")
|
||
def admin_device_list(app_id: str = "", auth=Depends(admin_auth)):
|
||
conn=db.get_conn(); rows=conn.execute("SELECT * FROM devices WHERE app_id=? ORDER BY last_seen_at DESC",(app_id,)).fetchall() if app_id else conn.execute("SELECT * FROM devices ORDER BY last_seen_at DESC LIMIT 500").fetchall(); conn.close()
|
||
return {"list":[{"device_id":r["device_id"],"app_id":r["app_id"],"installation_id":r["installation_id"],"disabled":bool(r["disabled"]),"disabled_reason":r["disabled_reason"],"credential_seq":r["credential_seq"],"first_seen_at":r["first_seen_at"],"last_seen_at":r["last_seen_at"],"last_ip":r["last_ip"]} for r in rows]}
|
||
|
||
@app.post("/admin/device/set-disabled")
|
||
def admin_device_set_disabled(body: dict, auth=Depends(admin_auth)):
|
||
device_id=str(body.get("device_id") or "").strip(); disabled=bool(body.get("disabled")); reason=str(body.get("reason") or "").strip(); conn=db.get_conn(); cur=conn.execute("UPDATE devices SET disabled=?,disabled_reason=? WHERE device_id=?",(int(disabled),reason if disabled else "",device_id))
|
||
if cur.rowcount != 1: conn.close(); raise HTTPException(status_code=404,detail="设备不存在")
|
||
conn.commit(); conn.close(); return {"msg":"设备已禁用" if disabled else "设备已恢复","device_id":device_id}
|
||
|
||
if __name__ == "__main__":
|
||
import uvicorn
|
||
uvicorn.run(app, host=SERVER_HOST, port=SERVER_PORT, workers=1)
|