feat: 增加认证更新配置与自动检查 (#94)

This commit is contained in:
chengma
2026-08-10 14:31:44 +08:00
parent 48bbe1cc2c
commit dc375ff2af
14 changed files with 727 additions and 107 deletions
+86 -16
View File
@@ -6,6 +6,7 @@
from __future__ import annotations
import base64
import hashlib
import json
import os
@@ -16,7 +17,7 @@ import urllib.error
import urllib.parse
import urllib.request
import zipfile
from dataclasses import dataclass
from dataclasses import dataclass, field
from pathlib import Path, PurePosixPath
from typing import Callable, Optional
@@ -28,6 +29,10 @@ MAX_MANIFEST_BYTES = 1024 * 1024
MAX_UPDATE_BYTES = 500 * 1024 * 1024
MAX_EXTRACTED_BYTES = 1024 * 1024 * 1024
UPDATE_MANIFEST_SETTING = "update.manifest_url"
UPDATE_USERNAME_SETTING = "update.username"
DEFAULT_UPDATE_MANIFEST_URL = "http://cm.xiapi.com/autobuy——manifest.json"
DEFAULT_UPDATE_USERNAME = "admin"
_ALLOWED_HTTP_UPDATE_HOST = "cm.xiapi.com"
_VERSION_PATTERN = re.compile(r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:\.(0|[1-9]\d*))?$")
_SHA256_PATTERN = re.compile(r"^[0-9a-f]{64}$")
@@ -56,6 +61,14 @@ class UpdateCancelled(UpdateError):
"""用户关闭页面后取消继续处理更新。"""
@dataclass(frozen=True)
class UpdateCredentials:
"""仅在内存中使用的更新服务器 Basic Authentication 凭据。"""
username: str
password: str = field(repr=False)
@dataclass(frozen=True)
class UpdateInfo:
"""清单中一份可下载更新的信息。"""
@@ -93,12 +106,25 @@ def parse_version(version: str) -> tuple[int, int, int, int]:
def validate_manifest_url(url: str) -> str:
"""验证并返回只允许 HTTPS、且不含凭据的清单地址。"""
"""验证更新地址;HTTP 只对白名单发布主机开放。"""
normalized = url.strip()
parsed = urllib.parse.urlsplit(normalized)
if parsed.scheme.lower() != "https" or not parsed.hostname:
raise UpdateConfigurationError("更新清单地址必须是有效的 HTTPS 地址")
scheme = parsed.scheme.lower()
try:
port = parsed.port
except ValueError as exc:
raise UpdateConfigurationError("更新清单地址端口无效") from exc
is_https = scheme == "https" and bool(parsed.hostname)
is_allowed_http = (
scheme == "http"
and (parsed.hostname or "").lower() == _ALLOWED_HTTP_UPDATE_HOST
and port in {None, 80}
)
if not is_https and not is_allowed_http:
raise UpdateConfigurationError(
"更新清单地址必须使用 HTTPS;HTTP 只允许固定发布服务器"
)
if parsed.username is not None or parsed.password is not None:
raise UpdateConfigurationError("更新清单地址不能包含账号或密码")
if parsed.query:
@@ -110,24 +136,54 @@ def validate_manifest_url(url: str) -> str:
def _origin(url: str) -> tuple[str, str, int]:
parsed = urllib.parse.urlsplit(url)
scheme = parsed.scheme.lower()
return (
parsed.scheme.lower(),
scheme,
(parsed.hostname or "").lower(),
parsed.port or 443,
parsed.port or (443 if scheme == "https" else 80),
)
def _ascii_request_url(url: str) -> str:
"""只编码 URL 路径中的非 ASCII 字符,避免重复编码已有百分号。"""
parsed = urllib.parse.urlsplit(url)
encoded_path = urllib.parse.quote(parsed.path, safe="/%")
return urllib.parse.urlunsplit(
(parsed.scheme, parsed.netloc, encoded_path, parsed.query, parsed.fragment)
)
class _SameOriginRedirectHandler(urllib.request.HTTPRedirectHandler):
"""在发送认证头之前拒绝跨源重定向。"""
def redirect_request(self, req, fp, code, msg, headers, newurl):
validated_url = validate_manifest_url(newurl)
if _origin(req.full_url) != _origin(validated_url):
raise UpdateConfigurationError("更新请求不允许跨服务器重定向")
return super().redirect_request(
req,
fp,
code,
msg,
headers,
_ascii_request_url(validated_url),
)
class UpdateService:
"""检查并把更新安全暂存到 ``data/update``。"""
def __init__(
self,
update_directory: Optional[Path] = None,
urlopen: Callable = urllib.request.urlopen,
urlopen: Optional[Callable] = None,
timeout_seconds: float = 10.0,
):
self._update_directory = update_directory or (data_dir() / "update")
self._urlopen = urlopen
self._urlopen = urlopen or urllib.request.build_opener(
_SameOriginRedirectHandler()
).open
self._timeout_seconds = timeout_seconds
@property
@@ -139,6 +195,7 @@ class UpdateService:
manifest_url: str,
current_version: str = __version__,
is_cancelled: Optional[Callable[[], bool]] = None,
credentials: Optional[UpdateCredentials] = None,
) -> UpdateCheckResult:
"""下载并解析清单,返回是否存在新版本。"""
@@ -148,6 +205,7 @@ class UpdateService:
configured_url,
MAX_MANIFEST_BYTES,
is_cancelled,
credentials,
)
try:
manifest = json.loads(content.decode("utf-8-sig"))
@@ -168,6 +226,7 @@ class UpdateService:
info: UpdateInfo,
is_cancelled: Optional[Callable[[], bool]] = None,
on_progress: Optional[Callable[[int], None]] = None,
credentials: Optional[UpdateCredentials] = None,
) -> Path:
"""下载、校验并安全解压更新,返回暂存的 ``app.new``。"""
@@ -184,10 +243,7 @@ class UpdateService:
bytes_written = 0
try:
request = urllib.request.Request(
info.update_url,
headers={"User-Agent": f"CMAutoBuy/{__version__}"},
)
request = self._make_request(info.update_url, credentials)
with self._open(request) as response:
final_url = validate_manifest_url(response.geturl())
if _origin(final_url) != _origin(info.manifest_url):
@@ -294,11 +350,9 @@ class UpdateService:
url: str,
maximum_bytes: int,
is_cancelled: Optional[Callable[[], bool]],
credentials: Optional[UpdateCredentials],
) -> tuple[bytes, str]:
request = urllib.request.Request(
url,
headers={"User-Agent": f"CMAutoBuy/{__version__}"},
)
request = self._make_request(url, credentials)
with self._open(request) as response:
final_url = validate_manifest_url(response.geturl())
declared_size = self._content_length(response)
@@ -317,6 +371,22 @@ class UpdateService:
chunks.append(block)
return b"".join(chunks), final_url
@staticmethod
def _make_request(
url: str,
credentials: Optional[UpdateCredentials],
) -> urllib.request.Request:
headers = {"User-Agent": f"CMAutoBuy/{__version__}"}
if credentials is not None:
username = credentials.username.strip()
if not username or not credentials.password:
raise UpdateConfigurationError("更新账号和密码不能为空")
raw = f"{username}:{credentials.password}".encode("utf-8")
headers["Authorization"] = "Basic " + base64.b64encode(raw).decode(
"ascii"
)
return urllib.request.Request(_ascii_request_url(url), headers=headers)
def _open(self, request):
try:
return self._urlopen(request, timeout=self._timeout_seconds)