Files
server-core/app/api/routers/rbac_desktop_updates.py
T

653 lines
25 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.
# -*- coding: utf-8 -*-
"""桌面端(PineAgents)版本管理与更新源。
两类端点:
- 管理端点(/admin/desktop-versions*):运营方上传平台安装包 + minisign 签名、
设置发布说明 / 灰度比例 / 强制最低版本、发布 / 下架。
- 公开端点(/desktop-updates/*):桌面端 Tauri updater 拉取 latest.json(按
X-App-Version 灰度)、meta.json(强制更新判定)与安装包下载。
签名约定:构建机用 minisign 私钥为安装包生成 .sigTauri build 配置
TAURI_SIGNING_PRIVATE_KEY 自动产出);上传时若配置了 DESKTOP_UPDATER_PUBKEY
则校验签名 key id 与公钥一致,防止混入非本机签名的产物。
"""
from __future__ import annotations
import base64
import binascii
import hashlib
import json
import re
import secrets
from pathlib import Path
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile
from fastapi.responses import FileResponse, RedirectResponse
from pydantic import BaseModel, Field
from ... import config
from ...infrastructure.oss import oss
from ...infrastructure.repositories import Database, utcnow_iso
from ...rbac import require_permission, require_roles, write_audit
from ..dependencies import get_db
router = APIRouter(prefix="/admin", tags=["admin-desktop"])
public_router = APIRouter(tags=["desktop-updates"])
# Tauri updater 平台 target(与桌面端 bundle 产物一一对应)
DESKTOP_TARGETS = (
"darwin-aarch64",
"darwin-x86_64",
"windows-x86_64",
"linux-x86_64",
)
VERSION_RE = re.compile(r"^\d+\.\d+\.\d+([-+][0-9A-Za-z.\-]+)?$")
MAX_ARTIFACT_BYTES = 2 * 1024 * 1024 * 1024 # 2GB(桌面端含 PyInstaller 后端,体积较大)
# ---------------------------------------------------------------------------
# 请求/响应模型
# ---------------------------------------------------------------------------
class DesktopVersionCreate(BaseModel):
version: str = Field(..., description="semver,如 0.3.0")
channel: str = Field("stable", description="stable | beta")
notes: str = Field("", description="发布说明(Markdown)")
min_version: str = Field("", description="强制升级最低版本,空=不强制")
class DesktopVersionUpdate(BaseModel):
channel: str | None = None
notes: str | None = None
min_version: str | None = None
class DesktopPublishRequest(BaseModel):
rollout: int | None = Field(None, ge=0, le=100, description="灰度比例;缺省/100=全量")
class DesktopRolloutRequest(BaseModel):
rollout: int = Field(..., ge=0, le=100)
# ---------------------------------------------------------------------------
# 工具
# ---------------------------------------------------------------------------
def _normalize_version(version: str) -> str:
"""把多种写法统一为标准 semver。
支持:
- 标准 semver: 0.0.1 / 0.0.1-beta.1 / 0.0.1+build.1
- PEP 440: 0.0.1b1 / 0.0.1rc1 / 0.0.1a1 / 0.0.1.post1 / 0.0.1.dev2
- 点号预发布: 0.0.1.beta.1 / 0.0.1.alpha.1 / 0.0.1.rc.1
"""
v = version.strip()
# 已是 semver(含 - 或 + 预发布/元数据,或纯三段)
if VERSION_RE.match(v):
return v
# PEP 440: 0.0.1b1 / 0.0.1rc1 / 0.0.1a1(可叠加 .postN / .devN
m = re.match(
r"^(\d+\.\d+\.\d+)(a|b|rc)(\d+)(?:\.post(\d+))?(?:\.dev(\d+))?$", v
)
if m:
base, pre, n, post, dev = m.groups()
pre_map = {"a": "alpha", "b": "beta", "rc": "rc"}
labels = [f"{pre_map[pre]}.{n}"]
if dev:
labels.append(f"dev.{dev}")
suffix = "-" + ".".join(labels)
meta = f"+post.{post}" if post else ""
return f"{base}{suffix}{meta}"
# PEP 440: 0.0.1.post1 / 0.0.1.dev2post/dev 单独,无预发布标签)
m = re.match(r"^(\d+\.\d+\.\d+)(?:\.post(\d+))?(?:\.dev(\d+))?$", v)
if m and (m.group(2) or m.group(3)):
base, post, dev = m.groups()
labels = []
if dev:
labels.append(f"dev.{dev}")
suffix = "-" + ".".join(labels) if labels else ""
meta = f"+post.{post}" if post else ""
return f"{base}{suffix}{meta}"
# 点号预发布: 0.0.1.beta.1 / 0.0.1.alpha.1 / 0.0.1.rc.1
m = re.match(r"^(\d+\.\d+\.\d+)\.(alpha|beta|rc|dev)\.(\d+)$", v)
if m:
base, label, n = m.groups()
return f"{base}-{label}.{n}"
raise HTTPException(
status_code=400,
detail=f"版本号不合法(支持 semver 0.0.1-beta.1 / PEP 440 0.0.1b1 / 点号 0.0.1.beta.1: {version}",
)
def _validate_version(version: str) -> str:
"""规范化并校验版本号,返回标准 semver。"""
return _normalize_version(version)
def _version_key(version: str) -> tuple:
parts: list[int] = [0, 0, 0]
for i, seg in enumerate(str(version).lstrip("v").split("-", 1)[0].split(".")[:3]):
try:
parts[i] = int(seg)
except ValueError:
break
return tuple(parts)
def _minisign_key_id(text: str) -> str:
"""从 minisign 文本块提取 key id(与 scripts/pack-tauri/generate_update_manifest.py 同算法)。"""
lines = [line.strip() for line in text.strip().splitlines() if line.strip()]
raw = base64.b64decode(lines[1], validate=True)
if len(raw) < 10:
raise ValueError("minisign 数据过短")
return raw[2:10].hex()
def _pubkey_key_id(pubkey_b64: str) -> str:
"""tauri.conf.json 里的 pubkey 是 base64 串:先解码成 minisign 文本,再取 key id。"""
text = base64.b64decode(pubkey_b64, validate=True).decode("utf-8")
return _minisign_key_id(text)
def _verify_signature(sig_text: str) -> str:
"""校验 .sig 文本格式;配置了公钥时校验 key id 匹配,返回规范化签名文本。
支持两种格式:
1. 明文 minisign 格式(含 "untrusted comment:" 行)
2. Tauri v2 格式(单行 base64,解码后为明文 minisign 格式)
"""
normalized = sig_text.strip()
# Tauri v2 生成的 .sig 是单行 base64,需先解码
if "untrusted comment:" not in normalized:
try:
decoded = base64.b64decode(normalized, validate=True).decode("utf-8")
if "untrusted comment:" in decoded:
normalized = decoded.strip()
except (binascii.Error, UnicodeDecodeError):
pass
if "untrusted comment:" not in normalized:
raise HTTPException(status_code=400, detail="签名文件不是合法的 minisign 文本")
if config.DESKTOP_UPDATER_PUBKEY:
try:
sig_key = _minisign_key_id(normalized)
pub_key = _pubkey_key_id(config.DESKTOP_UPDATER_PUBKEY)
except (ValueError, IndexError, binascii.Error, UnicodeDecodeError) as exc:
raise HTTPException(status_code=400, detail=f"签名/公钥解析失败: {exc}") from exc
if sig_key != pub_key:
raise HTTPException(
status_code=400,
detail=f"签名 key id 与配置公钥不匹配(sig={sig_key} pub={pub_key}),请使用本机私钥重新签名",
)
return normalized
def _target_extension(target: str) -> str:
return {
"darwin-aarch64": ".tar.gz",
"darwin-x86_64": ".tar.gz",
"windows-x86_64": ".exe",
"linux-x86_64": ".AppImage",
}[target]
def _installer_extension(target: str) -> str:
"""website 下载页标准安装包扩展名(macOS=.dmg / Windows=.exe / Linux=.AppImage)。"""
return {
"darwin-aarch64": ".dmg",
"darwin-x86_64": ".dmg",
"windows-x86_64": ".exe",
"linux-x86_64": ".AppImage",
}[target]
def _base_url(request: Request) -> str:
"""对外绝对地址前缀:优先 PUBLIC_BASE_URL,否则回退请求 Host。"""
if config.PUBLIC_BASE_URL:
return config.PUBLIC_BASE_URL
return str(request.base_url).rstrip("/")
def _client_version(request: Request) -> str | None:
value = request.headers.get("X-App-Version", "").strip()
return value or None
def _build_manifest(version: dict, base_url: str) -> dict:
"""组装 Tauri updater 标准 manifestversion/notes/pub_date/platforms)。"""
platforms: dict[str, dict] = {}
for target, art in (version.get("artifacts") or {}).items():
url = (art or {}).get("url", "")
signature = (art or {}).get("signature", "")
if not url or not signature:
continue
platforms[target] = {
"url": f"{base_url}{url}",
"signature": signature,
}
return {
"version": version["version"],
"notes": version.get("notes", "") or "",
"pub_date": version.get("pub_date") or utcnow_iso(),
"platforms": platforms,
}
def _select_latest(
candidates: list[dict],
client_version: str | None,
channel: str = "stable",
) -> dict | None:
"""按版本从高到低选择发布候选;灰度未命中时退回上一全量版本。"""
stable = [v for v in candidates if (v.get("channel") or "stable") == channel]
if not stable:
stable = candidates
latest = stable[0] if stable else None
if latest is None:
return None
if (
latest.get("status") == "rolling"
and int(latest.get("rollout") or 0) < 100
and client_version
):
hit = int(hashlib.sha256(client_version.encode()).hexdigest(), 16) % 100
if hit >= int(latest.get("rollout") or 0):
for v in stable[1:]:
if v.get("status") == "published" and v.get("artifacts"):
return v
return None
return latest
async def _get_version(db: Database, vid: str) -> dict:
version = await db.desktop_versions.get(vid)
if version is None:
raise HTTPException(status_code=404, detail="版本不存在")
return version
# ---------------------------------------------------------------------------
# 管理端点(运营方 operator)
# ---------------------------------------------------------------------------
@router.get("/desktop-versions", summary="桌面端版本列表")
async def list_desktop_versions(
db: Database = Depends(get_db),
_role: dict = Depends(require_roles("operator")),
_perm: dict = Depends(require_permission("menu:admin_desktop_updates")),
):
return await db.desktop_versions.list()
@router.get("/desktop-versions/{vid}", summary="桌面端版本详情")
async def get_desktop_version(
vid: str,
db: Database = Depends(get_db),
_role: dict = Depends(require_roles("operator")),
_perm: dict = Depends(require_permission("menu:admin_desktop_updates")),
):
return await _get_version(db, vid)
@router.post("/desktop-versions", summary="新建桌面端版本(草稿)")
async def create_desktop_version(
req: DesktopVersionCreate,
request: Request,
db: Database = Depends(get_db),
actor: dict = Depends(require_permission("menu:admin_desktop_updates")),
):
version = _validate_version(req.version)
min_version = _validate_version(req.min_version) if req.min_version and req.min_version.strip() else ""
if await db.desktop_versions.get_by_version(version):
raise HTTPException(status_code=409, detail=f"版本 {version} 已存在")
created = await db.desktop_versions.create(
version=version,
channel=(req.channel or "stable").strip() or "stable",
notes=req.notes or "",
min_version=min_version,
created_by=(actor.get("username") or actor.get("id") or ""),
)
await write_audit(
db, action="desktop_version.create", resource="desktop_version",
resource_id=created["id"], detail=f"version={version}", user=actor, request=request,
)
return created
@router.patch("/desktop-versions/{vid}", summary="更新版本元信息(说明/频道/最低版本)")
async def update_desktop_version(
vid: str,
req: DesktopVersionUpdate,
request: Request,
db: Database = Depends(get_db),
actor: dict = Depends(require_permission("menu:admin_desktop_updates")),
):
version = await _get_version(db, vid)
fields: dict = {}
if req.channel is not None:
fields["channel"] = req.channel.strip() or "stable"
if req.notes is not None:
fields["notes"] = req.notes
if req.min_version is not None:
mv = req.min_version.strip()
fields["min_version"] = _validate_version(mv) if mv else ""
updated = await db.desktop_versions.update(vid, fields)
await write_audit(
db, action="desktop_version.update", resource="desktop_version",
resource_id=vid, detail=f"version={version['version']}", user=actor, request=request,
)
return updated
@router.post("/desktop-versions/{vid}/artifacts", summary="上传平台安装包 + minisign 签名")
async def upload_desktop_artifact(
vid: str,
request: Request,
target: str = Form(...),
file: UploadFile = File(...),
sig: UploadFile | None = File(None),
db: Database = Depends(get_db),
actor: dict = Depends(require_permission("menu:admin_desktop_updates")),
):
if target not in DESKTOP_TARGETS:
raise HTTPException(status_code=400, detail=f"target 仅支持 {', '.join(DESKTOP_TARGETS)}")
version = await _get_version(db, vid)
if version["status"] == "archived":
raise HTTPException(status_code=400, detail="已下架版本不可再上传产物")
data = await file.read()
if not data:
raise HTTPException(status_code=400, detail="空文件")
if len(data) > MAX_ARTIFACT_BYTES:
raise HTTPException(status_code=413, detail="安装包超过 500MB")
signature = ""
if sig is not None:
sig_data = await sig.read()
if not sig_data:
raise HTTPException(status_code=400, detail="签名文件为空")
try:
signature = _verify_signature(sig_data.decode("utf-8", errors="replace"))
except UnicodeDecodeError as exc:
raise HTTPException(status_code=400, detail="签名文件不是文本格式") from exc
fname = (file.filename or f"artifact-{secrets.token_hex(4)}").rsplit("/", 1)[-1]
fname = Path(fname).name or f"artifact-{secrets.token_hex(4)}"
key = f"desktop-updates/{version['version']}/{target}/{fname}"
path = await oss.upload(key, data, content_type="application/octet-stream")
sha256 = hashlib.sha256(data).hexdigest()
artifacts = dict(version.get("artifacts") or {})
artifacts[target] = {
"filename": fname,
"url": f"/desktop-updates/files/{key}",
"signature": signature,
"sha256": sha256,
"size": len(data),
}
updated = await db.desktop_versions.update(vid, {"artifacts_json": json.dumps(artifacts, ensure_ascii=False)})
await write_audit(
db, action="desktop_version.upload", resource="desktop_version", resource_id=vid,
detail=f"version={version['version']} target={target} {len(data)}B sha256={sha256[:12]}",
user=actor, request=request,
)
return updated
@router.post("/desktop-versions/{vid}/installers", summary="上传标准安装包(website 下载页用:macOS=.dmg / Windows=.exe")
async def upload_desktop_installer(
vid: str,
request: Request,
target: str = Form(...),
file: UploadFile = File(...),
db: Database = Depends(get_db),
actor: dict = Depends(require_permission("menu:admin_desktop_updates")),
):
if target not in DESKTOP_TARGETS:
raise HTTPException(status_code=400, detail=f"target 仅支持 {', '.join(DESKTOP_TARGETS)}")
version = await _get_version(db, vid)
if version["status"] == "archived":
raise HTTPException(status_code=400, detail="已下架版本不可再上传安装包")
data = await file.read()
if not data:
raise HTTPException(status_code=400, detail="空文件")
if len(data) > MAX_ARTIFACT_BYTES:
raise HTTPException(status_code=413, detail="安装包超过大小限制")
fname = (file.filename or f"installer-{secrets.token_hex(4)}").rsplit("/", 1)[-1]
fname = Path(fname).name or f"installer-{secrets.token_hex(4)}"
expect_ext = _installer_extension(target)
if not fname.lower().endswith(expect_ext.lower()):
raise HTTPException(
status_code=400,
detail=f"target={target} 的安装包扩展名应为 {expect_ext}(当前: {Path(fname).suffix or '(无)'}",
)
key = f"desktop-installers/{version['version']}/{target}/{fname}"
path = await oss.upload(key, data, content_type="application/octet-stream")
sha256 = hashlib.sha256(data).hexdigest()
installers = dict(version.get("installers") or {})
installers[target] = {
"filename": fname,
"url": f"/desktop-installers/files/{key}",
"sha256": sha256,
"size": len(data),
}
updated = await db.desktop_versions.update(
vid, {"installers_json": json.dumps(installers, ensure_ascii=False)}
)
await write_audit(
db, action="desktop_version.installer_upload", resource="desktop_version", resource_id=vid,
detail=f"version={version['version']} target={target} {fname} {len(data)}B sha256={sha256[:12]}",
user=actor, request=request,
)
return updated
@router.delete("/desktop-versions/{vid}/installers/{target}", summary="删除标准安装包")
async def delete_desktop_installer(
vid: str,
target: str,
request: Request,
db: Database = Depends(get_db),
actor: dict = Depends(require_permission("menu:admin_desktop_updates")),
):
if target not in DESKTOP_TARGETS:
raise HTTPException(status_code=400, detail=f"target 仅支持 {', '.join(DESKTOP_TARGETS)}")
version = await _get_version(db, vid)
installers = dict(version.get("installers") or {})
if target not in installers:
raise HTTPException(status_code=404, detail="该平台安装包不存在")
del installers[target]
updated = await db.desktop_versions.update(
vid, {"installers_json": json.dumps(installers, ensure_ascii=False)}
)
await write_audit(
db, action="desktop_version.installer_delete", resource="desktop_version", resource_id=vid,
detail=f"version={version['version']} target={target}",
user=actor, request=request,
)
return updated
@router.post("/desktop-versions/{vid}/publish", summary="发布(全量或按灰度比例)")
async def publish_desktop_version(
vid: str,
req: DesktopPublishRequest,
request: Request,
db: Database = Depends(get_db),
actor: dict = Depends(require_permission("menu:admin_desktop_updates")),
):
version = await _get_version(db, vid)
if not version.get("artifacts"):
raise HTTPException(status_code=400, detail="至少上传一个平台安装包后才能发布")
rollout = req.rollout
status = "published"
if rollout is not None and rollout < 100:
status = "rolling"
else:
rollout = 100
updated = await db.desktop_versions.update(
vid,
{"status": status, "rollout": int(rollout), "pub_date": utcnow_iso()},
)
await write_audit(
db, action="desktop_version.publish", resource="desktop_version", resource_id=vid,
detail=f"version={version['version']} status={status} rollout={rollout}",
user=actor, request=request,
)
return updated
@router.post("/desktop-versions/{vid}/rollout", summary="调整灰度比例(100=全量,<100 回到灰度)")
async def set_desktop_rollout(
vid: str,
req: DesktopRolloutRequest,
request: Request,
db: Database = Depends(get_db),
actor: dict = Depends(require_permission("menu:admin_desktop_updates")),
):
version = await _get_version(db, vid)
status = "published" if req.rollout >= 100 else "rolling"
updated = await db.desktop_versions.update(
vid, {"status": status, "rollout": req.rollout},
)
await write_audit(
db, action="desktop_version.rollout", resource="desktop_version", resource_id=vid,
detail=f"version={version['version']} rollout={req.rollout} status={status}",
user=actor, request=request,
)
return updated
@router.post("/desktop-versions/{vid}/archive", summary="下架版本(回滚时发布上一版本)")
async def archive_desktop_version(
vid: str,
request: Request,
db: Database = Depends(get_db),
actor: dict = Depends(require_permission("menu:admin_desktop_updates")),
):
version = await _get_version(db, vid)
if version["status"] == "archived":
raise HTTPException(status_code=400, detail="版本已下架")
updated = await db.desktop_versions.update(vid, {"status": "archived"})
await write_audit(
db, action="desktop_version.archive", resource="desktop_version", resource_id=vid,
detail=f"version={version['version']}", user=actor, request=request,
)
return updated
@router.delete("/desktop-versions/{vid}", summary="删除版本(仅草稿)")
async def delete_desktop_version(
vid: str,
request: Request,
db: Database = Depends(get_db),
actor: dict = Depends(require_permission("menu:admin_desktop_updates")),
):
version = await _get_version(db, vid)
if version["status"] != "draft":
raise HTTPException(status_code=400, detail="仅草稿版本可删除(已发布请先下架)")
await db.desktop_versions.delete(vid)
await write_audit(
db, action="desktop_version.delete", resource="desktop_version", resource_id=vid,
detail=f"version={version['version']}", user=actor, request=request,
)
return {"ok": True, "id": vid}
# ---------------------------------------------------------------------------
# 公开端点(桌面端更新源)
# ---------------------------------------------------------------------------
@public_router.get("/desktop-updates/latest.json", summary="Tauri updater 更新清单(支持灰度)")
async def desktop_updates_latest(request: Request, db: Database = Depends(get_db)):
client_version = _client_version(request)
channel = request.headers.get("X-App-Channel", "stable").strip() or "stable"
candidates = await db.desktop_versions.publishable()
latest = _select_latest(candidates, client_version, channel)
if latest is None:
return {
"version": "0.0.0",
"notes": "",
"pub_date": utcnow_iso(),
"platforms": {},
}
return _build_manifest(latest, _base_url(request))
@public_router.get("/desktop-updates/meta.json", summary="更新元信息(强制更新判定)")
async def desktop_updates_meta(request: Request, db: Database = Depends(get_db)):
client_version = _client_version(request)
channel = request.headers.get("X-App-Channel", "stable").strip() or "stable"
candidates = await db.desktop_versions.publishable()
latest = _select_latest(candidates, client_version, channel)
if latest is None:
return {"version": "", "min_version": "", "notes": "", "channel": ""}
return {
"version": latest["version"],
"min_version": latest.get("min_version") or "",
"notes": latest.get("notes") or "",
"channel": latest.get("channel") or "",
}
@public_router.get("/desktop-installers/latest.json", summary="website 下载页标准安装包清单(macOS=.dmg / Windows=.exe")
async def desktop_installers_latest(request: Request, db: Database = Depends(get_db)):
candidates = await db.desktop_versions.publishable()
if not candidates:
return {"version": "", "pub_date": utcnow_iso(), "platforms": {}}
latest = candidates[0]
base = _base_url(request)
platforms: dict[str, dict] = {}
for target, art in (latest.get("installers") or {}).items():
url = (art or {}).get("url", "")
if not url:
continue
platforms[target] = {
"filename": art.get("filename", ""),
"url": f"{base}{url}",
"sha256": art.get("sha256", ""),
"size": art.get("size", 0),
}
return {
"version": latest["version"],
"notes": latest.get("notes", "") or "",
"pub_date": latest.get("pub_date") or utcnow_iso(),
"platforms": platforms,
}
@public_router.get("/desktop-installers/files/{key:path}", summary="标准安装包下载(OSS 直链/本地文件)")
async def desktop_installers_file(key: str):
try:
key = oss.clean_key(key)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
if not key.startswith("desktop-installers/"):
raise HTTPException(status_code=404, detail="not found")
if oss.enabled:
return RedirectResponse(await oss.download_url(key), status_code=307)
local = oss.local_dir / key
if not local.is_file():
raise HTTPException(status_code=404, detail="安装包不存在")
return FileResponse(local)
@public_router.get("/desktop-updates/files/{key:path}", summary="安装包下载(OSS 直链/本地文件)")
async def desktop_updates_file(key: str):
try:
key = oss.clean_key(key)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
if not key.startswith("desktop-updates/"):
raise HTTPException(status_code=404, detail="not found")
if oss.enabled:
return RedirectResponse(await oss.download_url(key), status_code=307)
local = oss.local_dir / key
if not local.is_file():
raise HTTPException(status_code=404, detail="安装包不存在")
return FileResponse(local)