Files
server-core/app/api/routers/rbac_desktop_updates.py
T
Pine 416ade4842 feat: 统一权限聚合端点 + 事件名同步昵称 + IM/企业路由补强
- 新增 rbac_permissions 统一权限/组织归属聚合端点
- sync_event_name_to_nickname 脚本:事件名同步用户昵称
- im router/client、rbac_enterprise/opc/org/public 增强
- compute_catalog、nginx 配置、env.example 更新
2026-09-11 21:15:36 +08:00

1112 lines
44 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, Query, 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",
)
# Tauri v2 可能携带的 target 格式映射(带 bundle 类型后缀)
# 例如 darwin-aarch64-app -> darwin-aarch64, windows-x86_64-nsis -> windows-x86_64
_TARGET_ALIASES = {
"darwin-aarch64-app": "darwin-aarch64",
"darwin-aarch64-dmg": "darwin-aarch64",
"darwin-x86_64-app": "darwin-x86_64",
"darwin-x86_64-dmg": "darwin-x86_64",
"windows-x86_64-nsis": "windows-x86_64",
"windows-x86_64-msi": "windows-x86_64",
"linux-x86_64-appimage": "linux-x86_64",
"linux-x86_64-deb": "linux-x86_64",
"linux-x86_64-rpm": "linux-x86_64",
}
def _normalize_target(target: str) -> str:
"""将 Tauri v2 可能携带的各种 target 格式规范化为标准格式。
例如:
darwin-aarch64-app -> darwin-aarch64
windows-x86_64-nsis -> windows-x86_64
"""
target = (target or "").strip().lower()
if not target:
return ""
# 直接匹配
if target in DESKTOP_TARGETS:
return target
# 别名映射
if target in _TARGET_ALIASES:
return _TARGET_ALIASES[target]
# 尝试去掉最后一个后缀(如 -app, -nsis
parts = target.rsplit("-", 1)
if len(parts) == 2 and parts[0] in DESKTOP_TARGETS:
return parts[0]
return target
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)
class DesktopPresignRequest(BaseModel):
"""OSS 预签名直传请求:前端先拿临时 PUT URL,直传 OSS 后再 confirm 登记。"""
target: str = Field(..., description="平台 target,如 darwin-aarch64")
filename: str = Field(..., description="原始文件名(用于拼 OSS key")
content_type: str = Field("application/octet-stream", description="Content-Type")
expires_in: int = Field(600, ge=60, le=3600, description="预签名 URL 有效期(秒)")
class DesktopArtifactConfirmRequest(BaseModel):
"""更新包直传完成后确认登记。"""
target: str
key: str = Field(..., description="OSS 对象 keypresign 返回的 key")
filename: str
sha256: str
size: int = Field(..., ge=0)
signature_text: str = Field("", description=".sig 文件内容(明文 minisign 或 Tauri v2 base64")
class DesktopArtifactSignatureRequest(BaseModel):
"""仅登记更新包 minisign 签名(更新包已上传,只补/换 .sig)。"""
target: str
signature_text: str = Field(..., description=".sig 文件内容(明文 minisign 或 Tauri v2 base64")
class DesktopInstallerConfirmRequest(BaseModel):
"""标准安装包直传完成后确认登记(无签名)。"""
target: str
key: str
filename: str
sha256: str
size: int = Field(..., ge=0)
# ---------------------------------------------------------------------------
# 工具
# ---------------------------------------------------------------------------
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, target: str | None = None) -> dict:
"""组装 Tauri updater 标准 manifestversion/notes/pub_date/platforms)。
Args:
version: 版本记录
base_url: 基础 URL
target: 如果指定,只包含该平台的 artifacts;否则包含所有平台
"""
platforms: dict[str, dict] = {}
artifacts = version.get("artifacts") or {}
if target:
# 指定了 target,使用 _has_target_artifact 找到实际匹配的 target key
# (支持 darwin-aarch64-app 等带后缀的格式)
has, actual_key = _has_target_artifact(version, target)
if has and actual_key:
art = artifacts.get(actual_key) or {}
url = art.get("url", "")
signature = art.get("signature", "")
if url:
# manifest 中使用规范化后的 targetdarwin-aarch64),Tauri 能识别
platforms[target] = {
"url": f"{base_url}{url}",
"signature": signature or "",
}
else:
# 未指定 target,包含所有平台
for t, art in artifacts.items():
url = (art or {}).get("url", "")
signature = (art or {}).get("signature", "")
if not url:
continue
platforms[t] = {
"url": f"{base_url}{url}",
"signature": signature or "",
}
return {
"version": version["version"],
"notes": version.get("notes", "") or "",
"pub_date": version.get("pub_date") or utcnow_iso(),
"platforms": platforms,
}
def _has_target_artifact(version: dict, target: str) -> tuple[bool, str | None]:
"""检查版本是否包含指定平台的 artifacts,返回 (是否包含, 实际匹配的 target key)。
支持多种 target 格式匹配:
- 精确匹配:darwin-aarch64
- 带后缀匹配:darwin-aarch64-app, darwin-aarch64-dmg
- 反向匹配:如果 artifacts 中的 key 是 darwin-aarch64-apptarget 是 darwin-aarch64,也能匹配
"""
artifacts = version.get("artifacts") or {}
if not artifacts:
return False, None
# 1. 精确匹配
if target in artifacts:
art = artifacts[target] or {}
if art.get("url"):
return True, target
# 2. 尝试带后缀的格式(target + 后缀)
for suffix in ("-app", "-dmg", "-nsis", "-msi", "-appimage", "-deb", "-rpm"):
key = f"{target}{suffix}"
if key in artifacts:
art = artifacts[key] or {}
if art.get("url"):
return True, key
# 3. 反向匹配:artifacts 中的 key 可能带后缀,去掉后缀后匹配
for key in artifacts:
if not key:
continue
# 去掉最后一个后缀(如 -app, -nsis
parts = key.rsplit("-", 1)
if len(parts) == 2 and parts[0] == target:
art = artifacts[key] or {}
if art.get("url"):
return True, key
return False, None
def _select_latest_for_target(
candidates: list[dict],
target: str,
client_version: str | None,
channel: str = "stable",
) -> dict | None:
"""为指定平台选择最新可用版本。
遍历所有候选版本,找到包含该平台 artifacts 的最新版本。
支持灰度逻辑:灰度未命中时退回上一全量版本。
注意:先尝试筛选指定 channel 的版本,如果找不到该平台的 artifacts,
就回退到所有版本(不筛选 channel)。这样即使最新版本的 channel
不同,只要包含该平台的 artifacts,就能被找到。
Args:
candidates: 所有可发布版本(按版本从高到低排序)
target: 平台 target(如 darwin-aarch64
client_version: 客户端版本号(用于灰度判定)
channel: 发布渠道(stable/beta
Returns:
该平台的最新版本记录,或 None
"""
def _find_in_versions(versions: list[dict]) -> list[dict]:
"""在指定版本列表中找到包含该平台 artifacts 的所有版本。"""
result = []
for v in versions:
has, _ = _has_target_artifact(v, target)
if has:
result.append(v)
return result
# 1. 先尝试筛选指定 channel 的版本
channel_versions = [v for v in candidates if (v.get("channel") or "stable") == channel]
target_versions = _find_in_versions(channel_versions)
# 2. 如果在指定 channel 中找不到,回退到所有版本(不筛选 channel)
if not target_versions:
target_versions = _find_in_versions(candidates)
if not target_versions:
return None
latest = target_versions[0]
# 灰度逻辑:如果最新版本是灰度且未命中,退回上一全量版本
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 target_versions[1:]:
if v.get("status") == "published":
return v
return None
return latest
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
# ---------------------------------------------------------------------------
# OSS 预签名直传(前端直传 OSS,绕过后端带宽;confirm 时登记元信息)
# ---------------------------------------------------------------------------
def _validate_oss_key(key: str, expected_prefix: str) -> str:
"""校验 OSS key:非空、无目录穿越、前缀匹配。"""
key = (key or "").strip().strip("/")
if not key or ".." in key.split("/"):
raise HTTPException(status_code=400, detail=f"非法 OSS key: {key!r}")
if not key.startswith(expected_prefix):
raise HTTPException(status_code=400, detail=f"key 前缀应为 {expected_prefix}/")
return key
@router.post("/desktop-versions/{vid}/artifacts/presign", summary="生成更新包 OSS 预签名直传链接")
async def presign_desktop_artifact(
vid: str,
payload: DesktopPresignRequest,
db: Database = Depends(get_db),
actor: dict = Depends(require_permission("menu:admin_desktop_updates")),
):
if payload.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="已下架版本不可再上传产物")
if not oss.enabled:
raise HTTPException(status_code=503, detail="OSS 未配置,无法生成直传链接;请使用服务端中转上传")
fname = Path(payload.filename).name or f"artifact-{secrets.token_hex(4)}"
key = f"desktop-updates/{version['version']}/{payload.target}/{fname}"
upload_url = await oss.presigned_put(
key, expires=payload.expires_in, content_type=payload.content_type
)
await write_audit(
db, action="desktop_version.artifact_presign", resource="desktop_version", resource_id=vid,
detail=f"version={version['version']} target={payload.target} key={key}",
user=actor,
)
return {"upload_url": upload_url, "key": key, "method": "PUT", "expires_in": payload.expires_in}
@router.post("/desktop-versions/{vid}/artifacts/confirm", summary="确认更新包直传完成并登记")
async def confirm_desktop_artifact(
vid: str,
payload: DesktopArtifactConfirmRequest,
db: Database = Depends(get_db),
actor: dict = Depends(require_permission("menu:admin_desktop_updates")),
):
if payload.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="已下架版本不可再登记产物")
key = _validate_oss_key(payload.key, "desktop-updates/")
if payload.size > MAX_ARTIFACT_BYTES:
raise HTTPException(status_code=413, detail=f"安装包超过 {MAX_ARTIFACT_BYTES // 1024 // 1024 // 1024}GB 限制")
signature = ""
if payload.signature_text:
signature = _verify_signature(payload.signature_text)
artifacts = dict(version.get("artifacts") or {})
artifacts[payload.target] = {
"filename": Path(payload.filename).name,
"url": f"/desktop-updates/files/{key}",
"signature": signature,
"sha256": payload.sha256,
"size": payload.size,
}
updated = await db.desktop_versions.update(
vid, {"artifacts_json": json.dumps(artifacts, ensure_ascii=False)}
)
await write_audit(
db, action="desktop_version.artifact_confirm", resource="desktop_version", resource_id=vid,
detail=f"version={version['version']} target={payload.target} {payload.size}B sha256={payload.sha256[:12]}",
user=actor,
)
return updated
@router.post("/desktop-versions/{vid}/artifacts/signature", summary="仅登记更新包 minisign 签名(更新包已上传时补/换 .sig)")
async def set_desktop_artifact_signature(
vid: str,
payload: DesktopArtifactSignatureRequest,
db: Database = Depends(get_db),
actor: dict = Depends(require_permission("menu:admin_desktop_updates")),
):
if payload.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="已下架版本不可再登记签名")
artifacts = dict(version.get("artifacts") or {})
art = artifacts.get(payload.target)
if not art or not (art.get("url") or "").strip():
raise HTTPException(
status_code=400,
detail=f"target {payload.target} 尚未上传更新包,请先上传更新包后再登记签名",
)
signature = _verify_signature(payload.signature_text)
art = dict(art)
art["signature"] = signature
artifacts[payload.target] = art
updated = await db.desktop_versions.update(
vid, {"artifacts_json": json.dumps(artifacts, ensure_ascii=False)}
)
await write_audit(
db, action="desktop_version.artifact_signature", resource="desktop_version", resource_id=vid,
detail=f"version={version['version']} target={payload.target} signature=updated",
user=actor,
)
return updated
@router.post("/desktop-versions/{vid}/installers/presign", summary="生成标准安装包 OSS 预签名直传链接")
async def presign_desktop_installer(
vid: str,
payload: DesktopPresignRequest,
db: Database = Depends(get_db),
actor: dict = Depends(require_permission("menu:admin_desktop_updates")),
):
if payload.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="已下架版本不可再上传安装包")
if not oss.enabled:
raise HTTPException(status_code=503, detail="OSS 未配置,无法生成直传链接;请使用服务端中转上传")
fname = Path(payload.filename).name or f"installer-{secrets.token_hex(4)}"
expect_ext = _installer_extension(payload.target)
if not fname.lower().endswith(expect_ext.lower()):
raise HTTPException(
status_code=400,
detail=f"target={payload.target} 的安装包扩展名应为 {expect_ext}(当前: {Path(fname).suffix or '(无)'}",
)
key = f"desktop-installers/{version['version']}/{payload.target}/{fname}"
upload_url = await oss.presigned_put(
key, expires=payload.expires_in, content_type=payload.content_type
)
await write_audit(
db, action="desktop_version.installer_presign", resource="desktop_version", resource_id=vid,
detail=f"version={version['version']} target={payload.target} key={key}",
user=actor,
)
return {"upload_url": upload_url, "key": key, "method": "PUT", "expires_in": payload.expires_in}
@router.post("/desktop-versions/{vid}/installers/confirm", summary="确认标准安装包直传完成并登记")
async def confirm_desktop_installer(
vid: str,
payload: DesktopInstallerConfirmRequest,
db: Database = Depends(get_db),
actor: dict = Depends(require_permission("menu:admin_desktop_updates")),
):
if payload.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="已下架版本不可再登记安装包")
key = _validate_oss_key(payload.key, "desktop-installers/")
if payload.size > MAX_ARTIFACT_BYTES:
raise HTTPException(status_code=413, detail=f"安装包超过 {MAX_ARTIFACT_BYTES // 1024 // 1024 // 1024}GB 限制")
installers = dict(version.get("installers") or {})
installers[payload.target] = {
"filename": Path(payload.filename).name,
"url": f"/desktop-installers/files/{key}",
"sha256": payload.sha256,
"size": payload.size,
}
updated = await db.desktop_versions.update(
vid, {"installers_json": json.dumps(installers, ensure_ascii=False)}
)
await write_audit(
db, action="desktop_version.installer_confirm", resource="desktop_version", resource_id=vid,
detail=f"version={version['version']} target={payload.target} {payload.size}B sha256={payload.sha256[:12]}",
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,
target: str = Query("", description="Tauri updater 自动携带的平台 target,如 darwin-aarch64"),
db: Database = Depends(get_db),
):
"""Tauri updater 更新清单。
支持两种模式:
1. 指定 targetTauri v2 自动携带):返回该平台的最新可用版本
- 例如 macOS 用户请求 target=darwin-aarch64,返回 macOS 平台的最新版本
- 即使 Windows 有更新的版本,macOS 用户也只看到 macOS 的最新版本
2. 未指定 target(兼容旧版本):返回全局最新版本的所有平台 artifacts
版本号比较由 Tauri 客户端完成,服务端只负责返回对应平台的最新可用版本。
"""
client_version = _client_version(request)
channel = request.headers.get("X-App-Channel", "stable").strip() or "stable"
candidates = await db.desktop_versions.publishable()
# 如果指定了 target,按平台选择最新版本
# Tauri v2 可能携带 darwin-aarch64-app 等带后缀的格式,需要规范化
target = _normalize_target(target)
if target and target in DESKTOP_TARGETS:
latest = _select_latest_for_target(candidates, target, client_version, channel)
if latest is None:
# 该平台没有可用更新,但 Tauri updater 要求 platforms 中必须包含当前平台的 key,
# 否则会报错 "None of the fallback platforms were found in the response platforms object"。
# 返回版本号 0.0.0(小于任何真实版本),Tauri 会判定为无更新。
return {
"version": "0.0.0",
"notes": "",
"pub_date": utcnow_iso(),
"platforms": {
target: {
"url": "",
"signature": "",
}
},
}
# 只返回该平台的 artifacts,version 字段填该平台的最新版本号
return _build_manifest(latest, _base_url(request), target=target)
# 未指定 target:返回每个平台各自的最新版本
# 这样即使某个平台(如 macOS)没有全局最新版本的包,也能拿到该平台的最新可用版本
# 例如:Windows 最新是 b3macOS 最新是 b2,则返回的 platforms 中
# windows-x86_64 对应 b3 的包,darwin-aarch64 对应 b2 的包
base_url = _base_url(request)
platforms: dict[str, dict] = {}
version_records: list[dict] = []
for t in DESKTOP_TARGETS:
latest_for_target = _select_latest_for_target(candidates, t, client_version, channel)
if latest_for_target is None:
continue
# 获取该平台的 artifacts(支持带后缀的格式匹配)
has, actual_key = _has_target_artifact(latest_for_target, t)
if has and actual_key:
art = (latest_for_target.get("artifacts") or {}).get(actual_key) or {}
url = art.get("url", "")
signature = art.get("signature", "")
if url:
platforms[t] = {
"url": f"{base_url}{url}",
"signature": signature or "",
}
version_records.append(latest_for_target)
if not platforms:
return {
"version": "0.0.0",
"notes": "",
"pub_date": utcnow_iso(),
"platforms": {},
}
# version 字段填所有平台中最高的版本号
# Tauri updater 会比较这个版本号和当前版本号,如果更高则下载对应平台的包
try:
from packaging.version import Version
highest_record = max(version_records, key=lambda v: Version(v["version"]))
except Exception:
# 回退:按字符串排序
highest_record = max(version_records, key=lambda v: v["version"])
return {
"version": highest_record["version"],
"notes": highest_record.get("notes", "") or "",
"pub_date": highest_record.get("pub_date") or utcnow_iso(),
"platforms": platforms,
}
@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)