feat: 增加认证更新配置与自动检查 (#94)
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user