Files
update-server/main.py
T

222 lines
8.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import os
import shutil
import sqlite3
import subprocess
import tempfile
import time
import urllib.request
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from fastapi.staticfiles import StaticFiles
import db
import minio_tool
from app.api.router import include_api_routes
from app.core.audit import admin_audit_middleware
from app.core.config import configured_path, env_bool, settings
from app.core.security import ensure_default_admin_user
from app.services.crash_api_service import ensure_storage_dirs
from app.services.device_credential_service import verify_device_credential
from app.services.publish_service import UPLOAD_SPOOL_DIR
SERVER_HOST = settings.server_host
SERVER_PORT = settings.server_port
VALID_CLIENT_TOKEN = os.getenv("CLIENT_API_TOKEN")
MINIO_DATA_DIR = configured_path("MINIO_DATA_DIR", "minio_data")
tempfile.tempdir = str(UPLOAD_SPOOL_DIR)
@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'")
license_columns = {row[1] for row in cur.execute("PRAGMA table_info(licenses)").fetchall()}
if "license_key_cipher" not in license_columns:
cur.execute("ALTER TABLE licenses ADD COLUMN license_key_cipher TEXT NOT NULL DEFAULT ''")
policy_columns = {row[1] for row in cur.execute("PRAGMA table_info(version_policies)").fetchall()}
if "git_tags_enabled" not in policy_columns:
cur.execute("ALTER TABLE version_policies ADD COLUMN git_tags_enabled INTEGER NOT NULL DEFAULT 0")
audit_columns = {row[1] for row in cur.execute("PRAGMA table_info(admin_audit_logs)").fetchall()}
if "actor_username" not in audit_columns:
cur.execute("ALTER TABLE admin_audit_logs ADD COLUMN actor_username TEXT NOT NULL DEFAULT ''")
if "actor_roles" not in audit_columns:
cur.execute("ALTER TABLE admin_audit_logs ADD COLUMN actor_roles TEXT NOT NULL DEFAULT ''")
if "auth_type" not in audit_columns:
cur.execute("ALTER TABLE admin_audit_logs ADD COLUMN auth_type TEXT NOT NULL DEFAULT ''")
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("✅ 成功执行tables.sql,创建数据表与测试数据")
else:
print("❌ 错误:当前目录找不到 tables.sql 文件!")
conn.close()
print("===== 数据库初始化完成 =====")
ensure_storage_dirs()
ensure_default_admin_user()
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}")
app = FastAPI(title=settings.service_title, lifespan=lifespan)
include_api_routes(app)
app.mount("/static", StaticFiles(directory=str(minio_tool.LOCAL_UPLOAD_ROOT)), name="static")
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")(admin_audit_middleware)
@app.middleware("http")
async def auth_middleware(request: Request, call_next):
path = request.url.path
skip_paths = (
"/admin",
"/assets",
"/docs",
"/openapi.json",
"/redoc",
"/static",
"/favicon.ico",
"/platform-config.json",
"/logo.svg",
"/health",
"/login",
"/refresh-token",
"/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
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host=SERVER_HOST, port=SERVER_PORT, workers=1)