Commit 135068f6 by 周田

实现程序包分发管理和下载服务

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
parent 3a8f8760
# Program Distribution Server
这是根项目中的程序包管理与分发服务。唯一的依赖配置在仓库根目录 `pyproject.toml`;前端静态页面放在 `frontend/`,使用原生 HTML/CSS/JavaScript,不需要 Node 或前端构建步骤。FastAPI 以同源静态资源方式提供管理后台和公开下载页。
主要目录:
```text
distribution-server/
├─ api/routes.py # JSON API
├─ core/ # 上传、发布和 SQLite 存储
├─ frontend/ # HTML、CSS、JavaScript 页面
├─ tests/ # 后端/API 与前端资源测试
├─ app.py # HTTP app、LAN 访问限制和静态文件挂载
├─ server.py # 启动入口
└─ settings.py
```
管理 API 以 `/api` 开头;上传时服务器流式计算文件大小和 SHA-256,并记录文件名、格式、平台、架构、Markdown 版本说明、上传时间和发布状态。支持 EXE、MSI、ZIP、7Z、DMG、PKG、DEB、RPM、AppImage 和 TAR.GZ。Markdown 说明支持标题、列表、粗体、链接和代码。
版本说明也可以在管理页的程序包记录中单独编辑,无需重新上传程序包;保存后会同时显示在管理记录和公开下载页。
管理页可以删除应用;删除会一并清除该应用的版本记录及 `data/packages/{app_id}/` 下的本地程序包文件。
管理后台在 `/`;公开程序列表在 `/downloads`,单个程序及其已发布版本在 `/download/{app_id}`。只有已发布的程序包会出现在公开下载页并允许下载。每个程序的更新清单地址为 `/updates/{app_id}/latest.json`,格式继续兼容 updater 当前的 `app_id`、`version`、`url`、`size` 和 `sha256` 协议。标记为 Helper 更新包的 Windows EXE 会更新此清单;其他格式用于公开下载,不会交给当前仅支持 Windows EXE 替换流程的 updater helper。
## 直接启动
分发服务器可运行在 Linux、Windows 或 macOS。它只接收、保存并分发 Windows exe,不会在服务器上运行这些 exe。开发或试用时不必手动创建虚拟环境、设置环境变量或执行 `pip install`;在仓库根目录运行:
### Linux
```bash
cd /path/to/program-updater
uv run --extra server python -m distribution-server.server
```
### Windows PowerShell
```powershell
cd C:\path\to\program-updater
uv run --extra server python -m distribution-server.server
```
如尚未安装 uv,只需安装一次 uv 工具;之后启动命令会自动准备 Python 环境和依赖。服务器启动后,本机可访问 `http://127.0.0.1:8000/`;局域网内使用服务器 IP,例如 `http://192.168.1.20:8000/`。manifest 下载地址会按客户端请求的 host 自动生成。若通过反向代理访问,请确认传给客户端的主机名可从 helper 所在网络访问。
也可以通过 FastAPI app 模块启动同一服务:
```bash
uv run --extra server python -m distribution-server.app
```
打开 `http://192.168.1.20:8000/` 管理多个程序。创建应用后上传程序包,指定平台和架构,再选择发布版本。Windows EXE 可以标记为 Helper 更新包。公开下载页面地址为:
```text
http://192.168.1.20:8000/downloads
```
单个程序的下载页面地址为:
```text
http://192.168.1.20:8000/download/{app_id}
```
Helper 客户端清单地址为:
```text
http://192.168.1.20:8000/updates/{app_id}/latest.json
```
清单包含 `app_id`、`version`、`url`、`size` 和 `sha256`,与 updater 当前协议一致。只有上传并发布为 Helper 更新包的 Windows EXE 会成为该清单的目标;发布其他格式不会覆盖最近一次可用的 Helper 版本。
SQLite 数据库和程序文件默认存储在 `distribution-server/data/`,备份时应备份整个目录。上传大小默认上限为 2 GiB。已有数据库会在服务启动时自动补充程序包元数据字段。端口、存储目录、上传大小和允许网段集中配置在 `distribution-server/settings.py`,修改后重启服务即可生效。
## 网络访问
管理后台和管理 API 不提供登录,只有 `ALLOWED_CIDRS` 中的设备可以访问管理页面和执行写操作;默认允许私网和回环网段。公开下载页面、已发布程序包和更新清单允许远端匿名读取,以便 helper 与下载者访问。未发布的程序包不能通过公开下载 API 获取。若要让远端 helper 使用此服务,需要通过网络或反向代理开放公开读取路径;不要把管理写入接口暴露给不可信网络。服务拒绝带有跨源 `Origin` 的写请求,但这不能代替身份认证。
"""LAN distribution server package."""
"""HTTP API routes for application and release management."""
from fastapi import APIRouter, File, Form, Request, Response, UploadFile
from fastapi.responses import FileResponse
from pydantic import BaseModel, Field
from starlette.concurrency import run_in_threadpool
from ..core.service import DistributionService
router = APIRouter(prefix="/api")
release_router = APIRouter()
class ApplicationRequest(BaseModel):
app_id: str = Field(min_length=1, max_length=128)
display_name: str = Field(min_length=1, max_length=120)
class ReleaseDescriptionRequest(BaseModel):
description: str = Field(max_length=20_000)
def _service(request: Request) -> DistributionService:
return request.app.state.distribution_service
@router.get("/apps")
def list_applications(request: Request) -> list[dict[str, object]]:
return _service(request).list_applications()
@router.get("/public/apps")
def public_applications(request: Request) -> list[dict[str, object]]:
return _service(request).public_applications()
@router.get("/public/apps/{app_id}")
def public_application(app_id: str, request: Request) -> dict[str, object]:
return _service(request).public_application(app_id)
@router.post("/apps", status_code=201)
def create_application(
payload: ApplicationRequest,
request: Request,
) -> dict[str, object]:
return _service(request).create_application(payload.app_id, payload.display_name)
@router.delete("/apps/{app_id}", status_code=204)
def delete_application(app_id: str, request: Request) -> Response:
_service(request).delete_application(app_id)
return Response(status_code=204)
@router.get("/apps/{app_id}/releases")
def list_releases(app_id: str, request: Request) -> list[dict[str, object]]:
return _service(request).list_releases(app_id)
@router.patch("/apps/{app_id}/releases/{version}/description")
def update_release_description(
app_id: str,
version: str,
payload: ReleaseDescriptionRequest,
request: Request,
) -> dict[str, object]:
return _service(request).update_release_description(
app_id,
version,
payload.description,
)
@router.post("/apps/{app_id}/releases", status_code=201)
async def upload_release(
app_id: str,
request: Request,
version: str = Form(...),
file: UploadFile = File(...),
platform: str = Form(default="windows"),
architecture: str = Form(default="x64"),
helper_compatible: bool | None = Form(default=None),
description: str = Form(default=""),
) -> dict[str, object]:
try:
return await run_in_threadpool(
_service(request).upload_release,
app_id,
version,
file.filename,
file.file,
platform=platform,
architecture=architecture,
helper_compatible=helper_compatible,
description=description,
)
finally:
await file.close()
@router.post("/apps/{app_id}/releases/{version}/publish")
def publish_release(
app_id: str,
version: str,
request: Request,
) -> dict[str, object]:
return _service(request).publish_release(app_id, version)
@release_router.get("/updates/{app_id}/latest.json")
def latest_manifest(app_id: str, request: Request) -> dict[str, object]:
return _service(request).latest_manifest(app_id, str(request.base_url))
@release_router.get("/packages/{app_id}/{version}/download")
@release_router.get("/packages/{app_id}/{version}/app.exe", include_in_schema=False)
def download_package(app_id: str, version: str, request: Request) -> FileResponse:
package_path, filename, media_type = _service(request).package_download_info(
app_id,
version,
)
return FileResponse(
package_path,
media_type=media_type,
filename=filename,
)
@release_router.get("/downloads", include_in_schema=False)
def downloads_page(request: Request) -> FileResponse:
return _download_page(request)
@release_router.get("/download/{app_id}", include_in_schema=False)
def application_download_page(app_id: str, request: Request) -> FileResponse:
_ = app_id
return _download_page(request)
def _download_page(request: Request) -> FileResponse:
return FileResponse(
request.app.state.settings.frontend_dir / "downloads.html",
media_type="text/html",
)
from contextlib import asynccontextmanager
from ipaddress import IPv4Network, IPv6Address, IPv6Network, ip_address, ip_network
from urllib.parse import urlsplit
from fastapi import FastAPI, Request
from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from .api.routes import release_router, router
from .core.errors import DistributionError
from .core.service import DistributionService
from .core.store import DistributionStore
from .settings import DistributionSettings
_MAX_MULTIPART_OVERHEAD = 8 * 1024 * 1024
def create_app(settings: DistributionSettings | None = None) -> FastAPI:
config = settings or DistributionSettings()
allowed_networks = tuple(ip_network(cidr, strict=False) for cidr in config.allowed_cidrs)
@asynccontextmanager
async def lifespan(application: FastAPI):
store = DistributionStore(config.data_dir)
application.state.distribution_service = DistributionService(store, config)
yield
application = FastAPI(
title="Program Distribution API",
version="0.1.0",
lifespan=lifespan,
)
application.state.settings = config
application.add_exception_handler(DistributionError, _distribution_error_handler)
application.include_router(router)
application.mount(
"/assets",
StaticFiles(directory=config.frontend_dir / "assets", check_dir=True),
name="frontend-assets",
)
application.include_router(release_router)
@application.middleware("http")
async def restrict_to_lan(request: Request, call_next):
public_read = request.method in {"GET", "HEAD"} and (
request.url.path == "/downloads"
or request.url.path.startswith(
("/download/", "/updates/", "/packages/", "/assets/")
)
or request.url.path == "/api/public/apps"
or request.url.path.startswith("/api/public/apps/")
)
client = request.client
if (
not public_read
and (client is None or not _is_allowed_ip(client.host, allowed_networks))
):
return JSONResponse(
status_code=403,
content={"detail": "This distribution server is available only on the configured LAN."},
)
origin = request.headers.get("origin")
if request.method in {"POST", "PUT", "PATCH", "DELETE"} and origin is not None: # noqa: SIM102
if (
not _is_same_origin(
origin,
request.url.scheme,
request.headers.get("host", ""),
)
):
return JSONResponse(
status_code=403,
content={"detail": "Cross-origin management requests are not allowed."},
)
if (
request.method == "POST"
and request.url.path.startswith("/api/apps/")
and request.url.path.endswith("/releases")
):
content_length = request.headers.get("content-length")
if content_length is None:
return JSONResponse(
status_code=411,
content={"detail": "Content-Length is required for package uploads."},
)
try:
request_size = int(content_length)
except ValueError:
return JSONResponse(status_code=400, content={"detail": "Invalid Content-Length."})
if request_size < 0 or request_size > config.max_upload_size + _MAX_MULTIPART_OVERHEAD:
return JSONResponse(
status_code=413,
content={"detail": "Package upload exceeds the configured size limit."},
)
return await call_next(request)
@application.get("/", include_in_schema=False)
def frontend() -> FileResponse:
return FileResponse(config.frontend_dir / "index.html", media_type="text/html")
@application.get("/healthz")
def health_check() -> dict[str, str]:
return {"status": "ok"}
return application
async def _distribution_error_handler(
_request: Request,
exc: DistributionError,
) -> JSONResponse:
_ = _request
return JSONResponse(
status_code=exc.status_code,
content={"detail": exc.detail},
)
def _is_same_origin(origin: str, request_scheme: str, request_host: str) -> bool:
parsed = urlsplit(origin)
return (
parsed.scheme == request_scheme
and parsed.scheme in {"http", "https"}
and parsed.netloc.lower() == request_host.lower()
)
def _is_allowed_ip(
host: str,
allowed_networks: tuple[IPv4Network | IPv6Network, ...],
) -> bool:
try:
address = ip_address(host)
except ValueError:
return False
if isinstance(address, IPv6Address) and address.ipv4_mapped is not None:
address = address.ipv4_mapped
return any(address.version == network.version and address in network for network in allowed_networks)
app = create_app()
def main() -> int:
import uvicorn
settings: DistributionSettings = app.state.settings
uvicorn.run(
app,
host=settings.host,
port=settings.port,
workers=1,
proxy_headers=False,
)
return 0
if __name__ == "__main__":
raise SystemExit(main())
"""Business logic, validation, and persistence for the distribution server."""
class DistributionError(Exception):
def __init__(self, status_code: int, detail: str) -> None:
super().__init__(detail)
self.status_code = status_code
self.detail = detail
"""Identifiers and versions shared by the distribution service protocol."""
import re
class ProtocolError(ValueError):
"""An application ID or release version violates the updater protocol."""
_APP_ID_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
_VERSION_PATTERN = re.compile(r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$")
def validate_app_id(app_id: str) -> str:
if not isinstance(app_id, str) or not _APP_ID_PATTERN.fullmatch(app_id):
raise ProtocolError(
"app_id must contain only letters, digits, '.', '_' or '-'."
)
return app_id
def version_key(version: str) -> tuple[int, int, int]:
if not isinstance(version, str):
raise ProtocolError("version must use MAJOR.MINOR.PATCH format.")
match = _VERSION_PATTERN.fullmatch(version)
if match is None:
raise ProtocolError(
f"unsupported version {version!r}; use MAJOR.MINOR.PATCH, for example 1.2.3."
)
return int(match.group(1)), int(match.group(2)), int(match.group(3))
import hashlib
import os
import shutil
import sqlite3
import threading
from pathlib import Path
from typing import BinaryIO
from uuid import uuid4
from .errors import DistributionError
from .protocol import ProtocolError, validate_app_id, version_key
from .store import DistributionStore
from ..settings import DistributionSettings
_CHUNK_SIZE = 1024 * 1024
_MAX_DESCRIPTION_LENGTH = 20_000
_SUPPORTED_SUFFIXES = (
".appimage",
".deb",
".dmg",
".exe",
".msi",
".pkg",
".rpm",
".tar.gz",
".7z",
".zip",
)
_PLATFORMS = {"windows", "macos", "linux", "android", "ios", "other"}
_ARCHITECTURES = {"x86", "x64", "arm", "arm64", "universal", "any"}
_MEDIA_TYPES = {
".deb": "application/vnd.debian.binary-package",
".dmg": "application/x-apple-diskimage",
".exe": "application/vnd.microsoft.portable-executable",
".msi": "application/x-msi",
".zip": "application/zip",
}
class DistributionService:
def __init__(
self,
store: DistributionStore,
settings: DistributionSettings,
) -> None:
self.store = store
self.settings = settings
self._package_lock = threading.Lock()
def list_applications(self) -> list[dict[str, object]]:
return self.store.list_applications()
def create_application(self, app_id: str, display_name: str) -> dict[str, object]:
try:
return self.store.create_application(app_id, display_name)
except ProtocolError as exc:
raise DistributionError(422, str(exc)) from exc
except ValueError as exc:
raise DistributionError(422, str(exc)) from exc
except sqlite3.IntegrityError as exc:
raise DistributionError(409, "Application ID already exists.") from exc
def delete_application(self, app_id: str) -> None:
self._validate_app_id(app_id)
with self._package_lock:
if self.store.get_application(app_id) is None:
raise DistributionError(404, "Application not found.")
package_root = self.store.packages_dir.resolve()
package_directory = self.store.packages_dir / app_id
if package_directory.is_symlink():
raise DistributionError(
409,
"Application package directory cannot be a symbolic link.",
)
package_directory = package_directory.resolve()
if not package_directory.is_relative_to(package_root):
raise DistributionError(
409,
"Application package directory is outside package storage.",
)
quarantine = self.store.incoming_dir / f"{uuid4().hex}.deleting"
moved_packages = False
if package_directory.exists():
try:
os.replace(package_directory, quarantine)
except OSError as exc:
raise DistributionError(
500,
"Could not stage application package files for deletion.",
) from exc
moved_packages = True
try:
if not self.store.delete_application(app_id):
raise DistributionError(404, "Application not found.")
except Exception:
if moved_packages:
try:
os.replace(quarantine, package_directory)
except OSError as restore_error:
raise DistributionError(
500,
"Application deletion failed and package files could not be restored.",
) from restore_error
raise
if moved_packages:
try:
shutil.rmtree(quarantine)
except OSError as exc:
raise DistributionError(
500,
"Application records were deleted, but package files could not be removed.",
) from exc
def list_releases(self, app_id: str) -> list[dict[str, object]]:
self._validate_app_id(app_id)
if self.store.get_application(app_id) is None:
raise DistributionError(404, "Application not found.")
return [
{
**release,
"package_type": _package_suffix(str(release["filename"])).removeprefix("."),
}
for release in self.store.list_releases(app_id)
]
def update_release_description(
self,
app_id: str,
version: str,
description: str,
) -> dict[str, object]:
self._validate_app_id(app_id)
self._validate_version(version)
normalized_description = description.strip()
if len(normalized_description) > _MAX_DESCRIPTION_LENGTH:
raise DistributionError(
422,
f"description must be at most {_MAX_DESCRIPTION_LENGTH} characters.",
)
if not self.store.update_release_description(
app_id,
version,
normalized_description,
):
raise DistributionError(404, "Release not found.")
return {
"app_id": app_id,
"version": version,
"description": normalized_description,
}
def upload_release(
self,
app_id: str,
version: str,
filename: str | None,
source: BinaryIO,
*,
platform: str = "windows",
architecture: str = "x64",
helper_compatible: bool | None = None,
description: str = "",
) -> dict[str, object]:
self._validate_app_id(app_id)
self._validate_version(version)
if self.store.get_application(app_id) is None:
raise DistributionError(404, "Application not found.")
safe_filename = _safe_filename(filename)
suffix = _package_suffix(safe_filename)
if suffix not in _SUPPORTED_SUFFIXES:
raise DistributionError(
400,
"Unsupported package format. Supported formats: "
+ ", ".join(_SUPPORTED_SUFFIXES),
)
if platform not in _PLATFORMS:
raise DistributionError(422, "Unsupported platform.")
if architecture not in _ARCHITECTURES:
raise DistributionError(422, "Unsupported architecture.")
if len(description) > _MAX_DESCRIPTION_LENGTH:
raise DistributionError(
422,
f"description must be at most {_MAX_DESCRIPTION_LENGTH} characters.",
)
description = description.strip()
if helper_compatible is None:
helper_compatible = suffix == ".exe" and platform == "windows"
if helper_compatible and (suffix != ".exe" or platform != "windows"):
raise DistributionError(
422,
"Only Windows .exe packages can be used by the updater helper.",
)
temporary_path = self.store.incoming_dir / f"{uuid4().hex}.upload"
stored_filename = f"package{suffix}"
try:
try:
size, sha256 = _save_package(
source,
temporary_path,
self.settings.max_upload_size,
require_executable_header=suffix == ".exe",
)
except UploadTooLargeError as exc:
raise DistributionError(413, str(exc)) from exc
except InvalidPackageError as exc:
raise DistributionError(400, str(exc)) from exc
with self._package_lock:
if self.store.release_exists(app_id, version):
raise DistributionError(409, "This application version already exists.")
package_path = (
self.store.packages_dir / app_id / version / stored_filename
)
if package_path.exists():
raise DistributionError(
409,
"An orphaned package already exists at this version path.",
)
package_path.parent.mkdir(parents=True, exist_ok=True)
package_path = package_path.parent / stored_filename
os.replace(temporary_path, package_path)
try:
self.store.add_release(
app_id,
version,
size,
sha256,
filename=safe_filename,
stored_filename=stored_filename,
description=description,
platform=platform,
architecture=architecture,
helper_compatible=helper_compatible,
)
except sqlite3.IntegrityError as exc:
package_path.unlink(missing_ok=True)
raise DistributionError(
409,
"This application version already exists.",
) from exc
except Exception:
package_path.unlink(missing_ok=True)
raise
return {
"app_id": app_id,
"version": version,
"size": size,
"sha256": sha256,
"filename": safe_filename,
"description": description,
"package_type": suffix.removeprefix("."),
"platform": platform,
"architecture": architecture,
"helper_compatible": helper_compatible,
"published": False,
}
finally:
temporary_path.unlink(missing_ok=True)
def publish_release(self, app_id: str, version: str) -> dict[str, object]:
self._validate_app_id(app_id)
self._validate_version(version)
release = self.store.get_release(app_id, version)
if release is None:
raise DistributionError(404, "Release not found.")
package_path = self.store.package_path(app_id, version)
if not package_path.is_file():
raise DistributionError(409, "Release package is missing.")
try:
actual_size, actual_sha256 = _file_info(package_path)
except OSError as exc:
raise DistributionError(409, "Release package could not be read.") from exc
if actual_size != release["size"] or actual_sha256 != release["sha256"]:
raise DistributionError(409, "Release package failed integrity check.")
published_release = self.store.publish_release(app_id, version)
if published_release is None:
raise DistributionError(404, "Release not found.")
return {
"app_id": app_id,
"version": version,
"published": True,
"helper_compatible": bool(published_release["helper_compatible"]),
}
def latest_manifest(self, app_id: str, public_base_url: str) -> dict[str, object]:
self._validate_app_id(app_id)
application = self.store.get_application(app_id)
if application is None:
raise DistributionError(404, "Application not found.")
version = application["helper_version"]
if not isinstance(version, str):
raise DistributionError(404, "No updater-compatible release has been published.")
release = self.store.get_release(app_id, version)
if release is None:
raise DistributionError(409, "Published release metadata is missing.")
if not release["helper_compatible"] or release["published_at"] is None:
raise DistributionError(409, "Published updater release metadata is invalid.")
if not self.store.package_path(app_id, version).is_file():
raise DistributionError(409, "Published updater package is missing.")
return {
"app_id": app_id,
"version": version,
"url": f"{public_base_url.rstrip('/')}/packages/{app_id}/{version}/download",
"size": release["size"],
"sha256": release["sha256"],
}
def public_applications(self) -> list[dict[str, object]]:
return [
{
**application,
"package_type": _package_suffix(str(application["filename"])).removeprefix("."),
"download_url": f"/packages/{application['app_id']}/"
f"{application['current_version']}/download",
}
for application in self.store.list_public_applications()
]
def public_application(self, app_id: str) -> dict[str, object]:
self._validate_app_id(app_id)
application = self.store.get_application(app_id)
if application is None:
raise DistributionError(404, "Application not found.")
releases = self.store.list_public_releases(app_id)
if not releases:
raise DistributionError(404, "No published packages are available.")
packages = [
{
**release,
"package_type": _package_suffix(str(release["filename"])).removeprefix("."),
"is_current": release["version"] == application["current_version"],
"download_url": f"/packages/{app_id}/{release['version']}/download",
}
for release in releases
]
return {
"app_id": app_id,
"display_name": application["display_name"],
"current_version": application["current_version"],
"packages": packages,
}
def package_download_info(self, app_id: str, version: str) -> tuple[Path, str, str]:
self._validate_app_id(app_id)
self._validate_version(version)
release = self.store.get_release(app_id, version)
if release is None or release["published_at"] is None:
raise DistributionError(404, "Published package not found.")
package_path = self.store.package_path(app_id, version)
if not package_path.is_file():
raise DistributionError(404, "Published package file not found.")
suffix = _package_suffix(str(release["filename"]))
return package_path, str(release["filename"]), _MEDIA_TYPES.get(suffix, "application/octet-stream")
def package_path(self, app_id: str, version: str) -> Path:
self._validate_app_id(app_id)
self._validate_version(version)
if self.store.get_release(app_id, version) is None:
raise DistributionError(404, "Release not found.")
package_path = self.store.package_path(app_id, version)
if not package_path.is_file():
raise DistributionError(404, "Release executable not found.")
return package_path
@staticmethod
def _validate_app_id(app_id: str) -> None:
try:
validate_app_id(app_id)
except ProtocolError as exc:
raise DistributionError(422, str(exc)) from exc
@staticmethod
def _validate_version(version: str) -> None:
try:
version_key(version)
except ProtocolError as exc:
raise DistributionError(422, str(exc)) from exc
class UploadTooLargeError(Exception):
pass
class InvalidPackageError(Exception):
pass
def _safe_filename(filename: str | None) -> str:
safe_filename = (filename or "").replace("\\", "/").rsplit("/", 1)[-1].strip()
if (
not safe_filename
or len(safe_filename) > 255
or any(ord(character) < 32 or ord(character) == 127 for character in safe_filename)
):
raise DistributionError(400, "A valid package filename is required.")
return safe_filename
def _package_suffix(filename: str) -> str:
normalized_name = filename.lower()
return ".tar.gz" if normalized_name.endswith(".tar.gz") else Path(normalized_name).suffix
def _save_package(
source: BinaryIO,
destination: Path,
max_size: int,
*,
require_executable_header: bool,
) -> tuple[int, str]:
size = 0
digest = hashlib.sha256()
prefix = bytearray()
try:
with destination.open("xb") as output:
while chunk := source.read(_CHUNK_SIZE):
if len(prefix) < 2:
prefix.extend(chunk[: 2 - len(prefix)])
size += len(chunk)
if size > max_size:
raise UploadTooLargeError(
f"Executable exceeds the {max_size}-byte upload limit."
)
digest.update(chunk)
output.write(chunk)
except Exception:
destination.unlink(missing_ok=True)
raise
if require_executable_header and (len(prefix) < 2 or bytes(prefix) != b"MZ"):
destination.unlink(missing_ok=True)
raise InvalidPackageError("Uploaded .exe file does not have a Windows executable header.")
if size == 0:
destination.unlink(missing_ok=True)
raise InvalidPackageError("Uploaded package is empty.")
return size, digest.hexdigest()
def _file_info(path: Path) -> tuple[int, str]:
digest = hashlib.sha256()
size = 0
with path.open("rb") as file:
while chunk := file.read(_CHUNK_SIZE):
size += len(chunk)
digest.update(chunk)
return size, digest.hexdigest()
import sqlite3
from contextlib import contextmanager
from datetime import datetime, timezone
from pathlib import Path
from typing import Generator
from .protocol import validate_app_id, version_key
class DistributionStore:
def __init__(self, data_dir: str | Path) -> None:
self.data_dir = Path(data_dir).resolve()
self.database_path = self.data_dir / "distribution.sqlite3"
self.packages_dir = self.data_dir / "packages"
self.incoming_dir = self.data_dir / "incoming"
self.data_dir.mkdir(parents=True, exist_ok=True)
self.packages_dir.mkdir(parents=True, exist_ok=True)
self.incoming_dir.mkdir(parents=True, exist_ok=True)
self._initialize()
@contextmanager
def _connection(self) -> Generator[sqlite3.Connection, None, None]:
connection = sqlite3.connect(self.database_path, timeout=30)
connection.row_factory = sqlite3.Row
connection.execute("PRAGMA foreign_keys = ON")
try:
yield connection
connection.commit()
except Exception:
connection.rollback()
raise
finally:
connection.close()
def _initialize(self) -> None:
with self._connection() as connection:
connection.executescript(
"""
CREATE TABLE IF NOT EXISTS applications (
app_id TEXT PRIMARY KEY,
display_name TEXT NOT NULL,
current_version TEXT,
helper_version TEXT,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS releases (
app_id TEXT NOT NULL,
version TEXT NOT NULL,
size INTEGER NOT NULL,
sha256 TEXT NOT NULL,
uploaded_at TEXT NOT NULL,
published_at TEXT,
filename TEXT NOT NULL DEFAULT 'app.exe',
stored_filename TEXT NOT NULL DEFAULT 'app.exe',
description TEXT NOT NULL DEFAULT '',
platform TEXT NOT NULL DEFAULT 'windows',
architecture TEXT NOT NULL DEFAULT 'x64',
helper_compatible INTEGER NOT NULL DEFAULT 1,
PRIMARY KEY (app_id, version),
FOREIGN KEY (app_id) REFERENCES applications(app_id)
ON DELETE CASCADE
);
"""
)
application_columns = {
row["name"] for row in connection.execute("PRAGMA table_info(applications)")
}
if "helper_version" not in application_columns:
connection.execute("ALTER TABLE applications ADD COLUMN helper_version TEXT")
release_columns = {
row["name"] for row in connection.execute("PRAGMA table_info(releases)")
}
release_migrations = {
"published_at": "TEXT",
"filename": "TEXT NOT NULL DEFAULT 'app.exe'",
"stored_filename": "TEXT NOT NULL DEFAULT 'app.exe'",
"description": "TEXT NOT NULL DEFAULT ''",
"platform": "TEXT NOT NULL DEFAULT 'windows'",
"architecture": "TEXT NOT NULL DEFAULT 'x64'",
"helper_compatible": "INTEGER NOT NULL DEFAULT 1",
}
for name, definition in release_migrations.items():
if name not in release_columns:
connection.execute(
f"ALTER TABLE releases ADD COLUMN {name} {definition}"
)
now = _now()
connection.execute(
"""
UPDATE releases
SET published_at = ?
WHERE published_at IS NULL
AND EXISTS (
SELECT 1 FROM applications
WHERE applications.app_id = releases.app_id
AND applications.current_version = releases.version
)
""",
(now,),
)
connection.execute(
"""
UPDATE applications
SET helper_version = current_version
WHERE helper_version IS NULL
AND current_version IS NOT NULL
AND EXISTS (
SELECT 1 FROM releases
WHERE releases.app_id = applications.app_id
AND releases.version = applications.current_version
AND releases.helper_compatible = 1
)
"""
)
def create_application(self, app_id: str, display_name: str) -> dict[str, object]:
validate_app_id(app_id)
normalized_name = display_name.strip()
if not normalized_name or len(normalized_name) > 120:
raise ValueError("display_name must be between 1 and 120 characters.")
with self._connection() as connection:
connection.execute(
"""
INSERT INTO applications (app_id, display_name, created_at)
VALUES (?, ?, ?)
""",
(app_id, normalized_name, _now()),
)
application = self.get_application(app_id)
if application is None:
raise RuntimeError("created application could not be read back.")
return application
def delete_application(self, app_id: str) -> bool:
with self._connection() as connection:
cursor = connection.execute(
"DELETE FROM applications WHERE app_id = ?",
(app_id,),
)
return cursor.rowcount > 0
def list_applications(self) -> list[dict[str, object]]:
with self._connection() as connection:
rows = connection.execute(
"""
SELECT a.app_id, a.display_name, a.current_version, a.helper_version,
a.created_at, r.size AS current_size, r.sha256 AS current_sha256,
r.filename AS current_filename, r.platform AS current_platform,
r.architecture AS current_architecture
FROM applications AS a
LEFT JOIN releases AS r
ON r.app_id = a.app_id AND r.version = a.current_version
ORDER BY a.display_name COLLATE NOCASE, a.app_id
"""
).fetchall()
return [dict(row) for row in rows]
def get_application(self, app_id: str) -> dict[str, object] | None:
with self._connection() as connection:
row = connection.execute(
"""
SELECT app_id, display_name, current_version, helper_version, created_at
FROM applications
WHERE app_id = ?
""",
(app_id,),
).fetchone()
return dict(row) if row is not None else None
def list_releases(self, app_id: str) -> list[dict[str, object]]:
with self._connection() as connection:
rows = connection.execute(
"""
SELECT app_id, version, size, sha256, uploaded_at, published_at,
filename, description, platform, architecture, helper_compatible
FROM releases
WHERE app_id = ?
ORDER BY uploaded_at DESC, version DESC
""",
(app_id,),
).fetchall()
return [dict(row) for row in rows]
def get_release(self, app_id: str, version: str) -> dict[str, object] | None:
with self._connection() as connection:
row = connection.execute(
"""
SELECT app_id, version, size, sha256, uploaded_at, published_at,
filename, stored_filename, description, platform, architecture,
helper_compatible
FROM releases
WHERE app_id = ? AND version = ?
""",
(app_id, version),
).fetchone()
return dict(row) if row is not None else None
def release_exists(self, app_id: str, version: str) -> bool:
return self.get_release(app_id, version) is not None
def update_release_description(
self,
app_id: str,
version: str,
description: str,
) -> bool:
with self._connection() as connection:
cursor = connection.execute(
"""
UPDATE releases
SET description = ?
WHERE app_id = ? AND version = ?
""",
(description, app_id, version),
)
return cursor.rowcount > 0
def add_release(
self,
app_id: str,
version: str,
size: int,
sha256: str,
*,
filename: str,
stored_filename: str,
description: str,
platform: str,
architecture: str,
helper_compatible: bool,
) -> None:
with self._connection() as connection:
connection.execute(
"""
INSERT INTO releases (
app_id, version, size, sha256, uploaded_at, filename,
stored_filename, description, platform, architecture, helper_compatible
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
app_id,
version,
size,
sha256,
_now(),
filename,
stored_filename,
description,
platform,
architecture,
int(helper_compatible),
),
)
def publish_release(self, app_id: str, version: str) -> dict[str, object] | None:
with self._connection() as connection:
release = connection.execute(
"""
SELECT app_id, version, size, sha256, uploaded_at, published_at,
filename, stored_filename, description, platform, architecture,
helper_compatible
FROM releases
WHERE app_id = ? AND version = ?
""",
(app_id, version),
).fetchone()
if release is None:
return None
connection.execute(
"""
UPDATE applications
SET current_version = ?,
helper_version = CASE WHEN ? = 1 THEN ? ELSE helper_version END
WHERE app_id = ?
""",
(version, release["helper_compatible"], version, app_id),
)
connection.execute(
"UPDATE releases SET published_at = COALESCE(published_at, ?) "
"WHERE app_id = ? AND version = ?",
(_now(), app_id, version),
)
return dict(release)
def list_public_applications(self) -> list[dict[str, object]]:
with self._connection() as connection:
rows = connection.execute(
"""
SELECT a.app_id, a.display_name, a.current_version,
r.filename, r.description, r.platform, r.architecture,
r.size, r.sha256,
r.published_at
FROM applications AS a
JOIN releases AS r
ON r.app_id = a.app_id AND r.version = a.current_version
WHERE r.published_at IS NOT NULL
ORDER BY a.display_name COLLATE NOCASE, a.app_id
"""
).fetchall()
return [dict(row) for row in rows]
def list_public_releases(self, app_id: str) -> list[dict[str, object]]:
with self._connection() as connection:
rows = connection.execute(
"""
SELECT app_id, version, size, sha256, uploaded_at, published_at,
filename, description, platform, architecture, helper_compatible
FROM releases
WHERE app_id = ? AND published_at IS NOT NULL
ORDER BY published_at DESC, version DESC
""",
(app_id,),
).fetchall()
return [dict(row) for row in rows]
def package_path(self, app_id: str, version: str) -> Path:
validate_app_id(app_id)
version_key(version)
release = self.get_release(app_id, version)
if release is None:
raise ValueError("release does not exist.")
stored_filename = str(release["stored_filename"])
if Path(stored_filename).name != stored_filename:
raise ValueError("stored package filename is invalid.")
package_root = self.packages_dir.resolve()
path = (package_root / app_id / version / stored_filename).resolve()
if not path.is_relative_to(package_root):
raise ValueError("package path escapes the configured storage directory.")
return path
def _now() -> str:
return datetime.now(timezone.utc).isoformat()
:root {
color-scheme: light;
font-family: Inter, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
color: #202a3b;
background: #f5f7fb;
font-synthesis: none;
text-rendering: optimizeLegibility;
--ink: #202a3b;
--muted: #788397;
--line: #e7ebf2;
--blue: #4263eb;
--blue-dark: #3451cd;
--green: #17835d;
--sidebar: #172235;
}
* { box-sizing: border-box; }
body { margin: 0; min-width: 360px; min-height: 100vh; }
button, input { font: inherit; }
button { cursor: pointer; }
[hidden] { display: none !important; }
.shell { min-height: 100vh; display: grid; grid-template-columns: 252px minmax(0, 1fr); }
.sidebar {
position: sticky; top: 0; display: flex; flex-direction: column; height: 100vh;
padding: 25px 16px 18px; color: #e8edf7; background: var(--sidebar);
}
.brand { display: flex; gap: 11px; align-items: center; padding: 2px 8px 30px; color: inherit; text-decoration: none; }
.brand-mark { display: grid; place-items: center; width: 36px; height: 36px; border-radius: 11px; color: white; background: #4263eb; font: 800 20px Manrope, sans-serif; }
.brand strong, .brand small { display: block; }
.brand strong { font: 700 14px Manrope, sans-serif; letter-spacing: .01em; }
.brand small { margin-top: 3px; color: #9daac0; font-size: 11px; }
.side-label, .side-heading { padding: 0 9px; color: #96a2b7; font-size: 11px; }
.side-label { margin-bottom: 13px; text-transform: uppercase; letter-spacing: .12em; }
.side-heading { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; color: #d3dae7; font-weight: 600; }
.icon-button { display: inline-grid; place-items: center; width: 30px; height: 30px; border: 0; border-radius: 8px; color: inherit; background: transparent; font-size: 21px; line-height: 1; }
.icon-button:hover { background: #ffffff14; }
.app-list { display: grid; gap: 4px; }
.app-nav-item { display: flex; width: 100%; gap: 10px; align-items: center; padding: 10px; border: 0; border-radius: 8px; color: #b7c1d2; background: transparent; text-align: left; }
.app-nav-item:hover { color: white; background: #ffffff0b; }
.app-nav-item.active { color: white; background: #ffffff15; }
.app-nav-icon { display: grid; place-items: center; flex: 0 0 28px; height: 28px; border-radius: 8px; color: #a9bbff; background: #4263eb30; font: 700 12px Manrope, sans-serif; }
.app-nav-name { overflow: hidden; font-size: 13px; text-overflow: ellipsis; white-space: nowrap; }
.sidebar-footer { display: flex; gap: 8px; align-items: center; margin-top: auto; padding: 14px 9px 0; border-top: 1px solid #ffffff14; color: #9daac0; font-size: 11px; }
.status-dot { width: 7px; height: 7px; border-radius: 50%; background: #39c991; box-shadow: 0 0 0 3px #39c99122; }
.main { width: min(1440px, 100%); margin: 0 auto; padding: 34px 36px 60px; }
.topbar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 25px; }
.topbar-actions { display: flex; gap: 8px; align-items: center; }
.eyebrow, .panel-kicker { color: #8691a3; font: 700 10px Manrope, sans-serif; letter-spacing: .13em; }
h1, h2, p { margin: 0; }
h1 { margin-top: 5px; font: 700 26px Manrope, "PingFang SC", sans-serif; letter-spacing: -.025em; }
h2 { margin-top: 4px; font: 700 17px Manrope, "PingFang SC", sans-serif; }
.muted { color: var(--muted); font-size: 12px; line-height: 1.65; }
.button { display: inline-flex; min-height: 38px; justify-content: center; align-items: center; gap: 7px; padding: 0 14px; border: 1px solid transparent; border-radius: 8px; font-size: 12px; font-weight: 600; transition: .15s ease; }
.button:disabled { opacity: .55; cursor: not-allowed; }
.button-primary { color: white; background: var(--blue); box-shadow: 0 2px 5px #4263eb28; }
.button-primary:hover:not(:disabled) { background: var(--blue-dark); transform: translateY(-1px); }
.button-muted { color: #536077; background: white; border-color: var(--line); }
.button-muted:hover { border-color: #cbd3e0; background: #fbfcfe; }
.notice { margin: -8px 0 18px; padding: 11px 14px; border: 1px solid #f3d9a5; border-radius: 8px; color: #805d1c; background: #fff9eb; font-size: 12px; }
.notice.error { border-color: #f1c5c5; color: #9b3b3b; background: #fff5f5; }
.empty-state { display: grid; min-height: 430px; justify-items: center; align-content: center; padding: 40px; border: 1px dashed #d7deea; border-radius: 14px; background: #ffffffa6; text-align: center; }
.empty-icon { display: grid; place-items: center; width: 54px; height: 54px; margin-bottom: 17px; border-radius: 17px; color: var(--blue); background: #ebefff; font-size: 27px; }
.empty-state h2 { font-size: 18px; }
.empty-state p { margin: 8px 0 19px; color: var(--muted); font-size: 13px; }
.summary-grid { display: grid; grid-template-columns: 1.55fr 1fr; gap: 16px; margin-bottom: 17px; }
.panel { border: 1px solid var(--line); border-radius: 12px; background: white; box-shadow: 0 2px 8px #25314b05; }
.current-release, .app-details { min-height: 183px; padding: 20px 22px; }
.current-version { display: flex; gap: 11px; align-items: center; margin: 14px 0 6px; }
.version-number { font: 800 27px Manrope, sans-serif; letter-spacing: -.04em; }
.badge { display: inline-flex; align-items: center; padding: 4px 8px; border-radius: 999px; font-size: 10px; font-weight: 700; }
.badge-live { color: #18764f; background: #e9f7f0; }
.badge-neutral { color: #737f92; background: #f0f2f6; }
.badge-helper { color: #6652b8; background: #f0edff; }
.manifest-line { display: grid; gap: 5px; margin-top: 15px; padding-top: 12px; border-top: 1px solid #eff1f5; color: var(--muted); font-size: 10px; }
.manifest-line code { color: #59667b; font-size: 10px; overflow-wrap: anywhere; }
.download-link-line a { color: var(--blue); font-size: 10px; overflow-wrap: anywhere; text-decoration: none; }
.download-link-line a:hover { text-decoration: underline; }
.detail-row { display: flex; justify-content: space-between; gap: 12px; padding: 11px 0; border-bottom: 1px solid #eff1f5; color: var(--muted); font-size: 11px; }
.detail-row:last-child { border-bottom: 0; }
.detail-row strong, .detail-row code { color: var(--ink); font-size: 11px; font-weight: 600; }
.upload-panel, .releases-panel { padding: 20px 22px; margin-bottom: 17px; }
.section-heading { margin-bottom: 17px; }
.section-heading .muted { margin-top: 6px; }
.upload-form { display: grid; grid-template-columns: minmax(145px, .8fr) minmax(130px, .7fr) minmax(130px, .7fr) minmax(175px, 1fr); gap: 13px; align-items: end; }
.upload-form > .field { align-self: start; }
.field { display: grid; gap: 7px; min-width: 0; color: #465268; font-size: 11px; font-weight: 600; }
.field input, .field select { width: 100%; height: 40px; padding: 0 11px; border: 1px solid #dfe4ec; border-radius: 7px; outline: none; color: var(--ink); background: white; font-size: 12px; }
.field input:focus { border-color: #91a5ff; box-shadow: 0 0 0 3px #4263eb14; }
.field select:focus { border-color: #91a5ff; box-shadow: 0 0 0 3px #4263eb14; }
.field small, .file-drop small { color: #929cad; font-size: 10px; font-weight: 400; }
.helper-option { display: flex; min-height: 59px; gap: 9px; align-items: center; padding: 9px 11px; border: 1px solid #e4e9f1; border-radius: 8px; color: #465268; background: #fbfcfe; }
.upload-form > .helper-option { align-self: center; }
.helper-option input { width: 15px; height: 15px; accent-color: var(--blue); }
.helper-option span, .helper-option strong, .helper-option small { display: block; }
.helper-option strong { font-size: 11px; }
.helper-option small { margin-top: 3px; color: #929cad; font-size: 10px; }
.helper-option:has(input:disabled) { opacity: .55; }
.file-drop { display: flex; min-width: 0; min-height: 59px; gap: 11px; align-items: center; padding: 9px 12px; border: 1px dashed #cfd7e5; border-radius: 8px; cursor: pointer; background: #fbfcfe; }
.upload-form .file-drop { grid-column: span 3; }
.upload-form > .button { min-height: 59px; }
.file-drop:hover, .file-drop.dragging { border-color: #8096f4; background: #f4f6ff; }
.file-drop input { position: absolute; width: 1px; height: 1px; opacity: 0; }
.file-icon { display: grid; place-items: center; flex: 0 0 34px; height: 34px; border-radius: 9px; color: var(--blue); background: #edf0ff; font-size: 20px; }
.file-drop strong, .file-drop small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.file-drop strong { color: #465268; font-size: 11px; }
.file-drop small { margin-top: 3px; }
.progress-wrap { display: flex; gap: 10px; align-items: center; margin-top: 14px; }
.progress-track { flex: 1; height: 5px; overflow: hidden; border-radius: 5px; background: #edf0f5; }
.progress-bar { width: 0; height: 100%; border-radius: inherit; background: var(--blue); transition: width .15s; }
.progress-wrap .muted { min-width: 110px; text-align: right; }
.release-heading { display: flex; justify-content: space-between; align-items: center; }
.release-list { overflow: hidden; }
.release-row { display: grid; grid-template-columns: minmax(145px, 1.2fr) minmax(60px, .5fr) minmax(90px, .8fr) minmax(130px, 1fr) minmax(80px, 1fr) auto; gap: 14px; align-items: center; min-height: 66px; padding: 10px 0; border-top: 1px solid #eff1f5; }
.release-row:first-child { border-top: 0; }
.release-main { min-width: 0; }
.release-title { display: flex; flex-wrap: wrap; gap: 7px; align-items: center; }
.release-version { font: 700 13px Manrope, sans-serif; }
.release-filename { overflow: hidden; margin-top: 4px; color: var(--muted); font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
.release-size, .release-date, .release-target { color: var(--muted); font-size: 10px; line-height: 1.6; }
.release-target strong { color: #59667b; font-weight: 600; }
.release-hash { overflow: hidden; color: #929cad; font: 10px ui-monospace, monospace; text-overflow: ellipsis; }
.release-actions { display: flex; flex-direction: column; gap: 5px; align-items: stretch; }
.publish-button { min-height: 31px; padding: 0 10px; border: 1px solid #dce3f7; border-radius: 7px; color: var(--blue); background: #f8f9ff; font-size: 10px; font-weight: 600; }
.publish-button:hover { border-color: #9cadf5; background: #f0f3ff; }
.no-releases { padding: 22px 0; color: var(--muted); font-size: 12px; text-align: center; }
.dialog { width: min(430px, calc(100vw - 32px)); padding: 22px; border: 1px solid var(--line); border-radius: 14px; box-shadow: 0 22px 70px #1722352b; }
.dialog::backdrop { background: #17223566; backdrop-filter: blur(2px); }
.dialog form { display: grid; gap: 16px; }
.dialog-heading { display: flex; justify-content: space-between; align-items: start; }
.dialog-heading h2 { margin-top: 5px; }
.dialog-heading .icon-button { color: #68758a; }
.dialog-actions { display: flex; justify-content: end; gap: 8px; margin-top: 2px; }
@media (max-width: 1100px) {
.release-row { grid-template-columns: minmax(140px, 1fr) auto auto auto; }
.release-date, .release-hash { display: none; }
}
@media (max-width: 850px) {
.shell { grid-template-columns: 210px minmax(0, 1fr); }
.main { padding: 27px 24px 45px; }
.summary-grid { grid-template-columns: 1fr; }
.upload-form { grid-template-columns: 1fr 1fr; }
.upload-form .file-drop { grid-column: span 1; }
}
@media (max-width: 600px) {
.shell { display: block; }
.sidebar { position: static; height: auto; padding: 13px 15px; }
.brand { padding: 0 4px 12px; }
.side-label, .sidebar-footer { display: none; }
.side-heading { padding: 0 4px; }
.app-list { display: flex; overflow-x: auto; }
.app-nav-item { width: auto; min-width: max-content; }
.main { padding: 22px 15px 40px; }
.topbar { margin-bottom: 18px; }
.topbar-actions { gap: 6px; }
h1 { font-size: 22px; }
.upload-form { grid-template-columns: 1fr; }
.upload-form .file-drop { grid-column: auto; }
.upload-form .button { width: 100%; }
.release-row { grid-template-columns: 1fr auto; }
.release-size, .release-target { display: none; }
}
body { font-size: 15px; }
button, input, textarea, select { font: inherit; }
.brand strong { font-size: 15px; }
.brand small, .sidebar-footer { font-size: 12px; }
.side-label, .side-heading { font-size: 12px; }
.app-nav-name { font-size: 14px; }
.eyebrow, .panel-kicker { font-size: 11px; }
h1 { font-size: 28px; }
h2 { font-size: 19px; }
.muted { font-size: 14px; }
.button { font-size: 14px; }
.button-danger { color: #a63d47; background: #fff5f5; border-color: #f0d1d4; }
.button-danger:hover { color: #8d2934; background: #ffeded; border-color: #eab9bf; }
.notice { font-size: 13px; }
.empty-state h2 { font-size: 20px; }
.empty-state p { font-size: 14px; }
.badge { font-size: 11px; }
.manifest-line { font-size: 12px; }
.manifest-line code, .download-link-line a { font-size: 12px; }
.detail-row { font-size: 13px; }
.detail-row strong, .detail-row code { font-size: 13px; }
.field { font-size: 13px; }
.field input, .field select, .field textarea { font-size: 14px; }
.field textarea {
width: 100%;
min-height: 104px;
padding: 10px 11px;
border: 1px solid #dfe4ec;
border-radius: 7px;
outline: none;
color: var(--ink);
background: white;
line-height: 1.55;
resize: vertical;
}
.field textarea:focus { border-color: #91a5ff; box-shadow: 0 0 0 3px #4263eb14; }
.field small, .file-drop small { font-size: 12px; }
.helper-option strong, .helper-option small { font-size: 12px; }
.file-drop strong { font-size: 13px; }
.release-version { font-size: 15px; }
.release-filename, .release-size, .release-date, .release-target { font-size: 12px; }
.release-hash { font-size: 11px; }
.publish-button { font-size: 12px; }
.no-releases { font-size: 14px; }
.upload-form .markdown-field { grid-column: 1 / -1; }
.release-description { grid-column: 1 / -1; min-width: 0; padding: 12px 14px; border-radius: 8px; color: var(--muted); background: #f7f8fc; font-size: 13px; }
.release-description-title { display: block; margin-bottom: 6px; color: #536077; font-size: 13px; }
.description-dialog { width: min(640px, calc(100vw - 32px)); max-height: calc(100vh - 32px); overflow: auto; }
.description-preview { max-height: 220px; overflow: auto; padding: 12px 14px; border: 1px solid var(--line); border-radius: 8px; background: #f7f8fc; }
.markdown-body { color: #465268; font-size: 14px; line-height: 1.7; overflow-wrap: anywhere; }
.markdown-body > :first-child { margin-top: 0; }
.markdown-body > :last-child { margin-bottom: 0; }
.markdown-body h1, .markdown-body h2, .markdown-body h3 { margin: 12px 0 6px; letter-spacing: 0; }
.markdown-body h1 { font-size: 19px; }
.markdown-body h2 { font-size: 17px; }
.markdown-body h3 { font-size: 15px; }
.markdown-body p, .markdown-body ul, .markdown-body ol, .markdown-body pre { margin: 7px 0; }
.markdown-body ul, .markdown-body ol { padding-left: 22px; }
.markdown-body pre { overflow-x: auto; padding: 10px 12px; border-radius: 7px; background: #f5f7fb; }
.markdown-body code { padding: 1px 4px; border-radius: 4px; background: #f0f2f6; font: 0.9em ui-monospace, SFMono-Regular, Menlo, monospace; }
.markdown-body pre code { padding: 0; background: transparent; }
.markdown-body a { color: var(--blue); }
@media (max-width: 600px) {
h1 { font-size: 24px; }
}
@media (min-width: 1441px) {
.main { padding-right: 48px; padding-left: 48px; }
}
import { renderMarkdown } from "./markdown.js";
const appList = document.querySelector("#app-list");
const emptyState = document.querySelector("#empty-state");
const appContent = document.querySelector("#app-content");
const notice = document.querySelector("#notice");
const dialog = document.querySelector("#create-dialog");
const descriptionDialog = document.querySelector("#description-dialog");
const descriptionForm = document.querySelector("#description-form");
const deleteAppButton = document.querySelector("#delete-app-button");
const uploadForm = document.querySelector("#upload-form");
const fileInput = document.querySelector("#file-input");
const fileLabel = document.querySelector("#file-label");
const fileDrop = document.querySelector("#file-drop");
const uploadButton = document.querySelector("#upload-button");
const platformInput = uploadForm.elements.platform;
const helperCompatibleInput = document.querySelector("#helper-compatible");
const progressWrap = document.querySelector("#upload-progress");
const progressBar = document.querySelector("#progress-bar");
const progressLabel = document.querySelector("#progress-label");
let applications = [];
let selectedAppId = null;
let noticeTimer;
async function api(path, options = {}) {
const response = await fetch(path, options);
if (!response.ok) {
let detail = response.statusText;
try {
detail = (await response.json()).detail || detail;
} catch {
// Keep the HTTP status text when the server did not return JSON.
}
throw new Error(`${response.status}: ${detail}`);
}
return response.status === 204 ? null : response.json();
}
function showNotice(message, isError = false) {
clearTimeout(noticeTimer);
notice.textContent = message;
notice.classList.toggle("error", isError);
notice.hidden = false;
noticeTimer = setTimeout(() => { notice.hidden = true; }, 5000);
}
function element(tag, className, text) {
const node = document.createElement(tag);
if (className) node.className = className;
if (text !== undefined) node.textContent = text;
return node;
}
function selectedApplication() {
return applications.find(application => application.app_id === selectedAppId) || null;
}
function renderAppList() {
appList.replaceChildren();
for (const application of applications) {
const button = element("button", `app-nav-item${application.app_id === selectedAppId ? " active" : ""}`);
button.type = "button";
const icon = element("span", "app-nav-icon", application.display_name.slice(0, 1).toUpperCase());
const name = element("span", "app-nav-name", application.display_name);
button.append(icon, name);
button.addEventListener("click", () => selectApplication(application.app_id));
appList.append(button);
}
}
async function refreshApplications() {
applications = await api("/api/apps");
if (!applications.some(application => application.app_id === selectedAppId)) {
selectedAppId = applications[0]?.app_id || null;
}
renderAppList();
await renderApplication();
}
async function selectApplication(appId) {
selectedAppId = appId;
renderAppList();
await renderApplication();
}
async function renderApplication() {
const application = selectedApplication();
emptyState.hidden = Boolean(application);
appContent.hidden = !application;
deleteAppButton.hidden = !application;
if (!application) {
document.querySelector("#page-title").textContent = "选择一个应用";
return;
}
document.querySelector("#page-title").textContent = application.display_name;
document.querySelector("#app-name").textContent = application.display_name;
document.querySelector("#app-id").textContent = application.app_id;
document.querySelector("#current-version").textContent = application.current_version || "未发布";
document.querySelector("#current-status").textContent = application.current_version ? "正在分发" : "未发布";
document.querySelector("#current-status").className = `badge ${application.current_version ? "badge-live" : "badge-neutral"}`;
document.querySelector("#current-release-meta").textContent = application.current_version
? `公开下载当前版本:${application.current_version}。Helper 更新版本:${application.helper_version || "尚未配置"}。`
: "上传并发布一个版本后,程序即可出现在公开下载页。";
document.querySelector("#manifest-url").textContent =
`${window.location.origin}/updates/${encodeURIComponent(application.app_id)}/latest.json`;
const downloadPage = document.querySelector("#download-page-url");
const downloadPath = `/download/${encodeURIComponent(application.app_id)}`;
downloadPage.href = downloadPath;
downloadPage.textContent = `${window.location.origin}${downloadPath}`;
try {
const releases = await api(`/api/apps/${encodeURIComponent(application.app_id)}/releases`);
document.querySelector("#release-count").textContent = String(releases.length);
document.querySelector("#release-count-label").textContent = `${releases.length} 个程序包`;
renderReleases(releases, application.current_version, application.helper_version);
} catch (error) {
showNotice(error.message, true);
}
}
function renderReleases(releases, currentVersion, helperVersion) {
const list = document.querySelector("#release-list");
list.replaceChildren();
if (!releases.length) {
list.append(element("div", "no-releases", "还没有程序包,上传后即可记录并发布。"));
return;
}
for (const release of releases) {
const row = element("div", "release-row");
const main = element("div", "release-main");
const title = element("div", "release-title");
title.append(element("span", "release-version", release.version));
if (release.version === currentVersion) {
title.append(element("span", "badge badge-live", "当前下载"));
} else if (release.published_at) {
title.append(element("span", "badge badge-neutral", "已发布"));
} else {
title.append(element("span", "badge badge-neutral", "草稿"));
}
if (release.version === helperVersion) {
title.append(element("span", "badge badge-helper", "Helper"));
}
main.append(title, element("div", "release-filename", release.filename));
let notes;
if (release.description) {
notes = element("section", "release-description");
notes.append(
element("strong", "release-description-title", "版本更新内容"),
renderMarkdown(release.description),
);
}
const size = element("div", "release-size", `${(release.size / 1024 / 1024).toFixed(1)} MiB`);
const target = element("div", "release-target");
target.append(
element("strong", "", release.platform),
document.createTextNode(` / ${release.architecture} · ${release.package_type || "package"}`),
);
const date = element("span", "release-date", new Date(release.uploaded_at).toLocaleString());
const hash = element("code", "release-hash", release.sha256);
const actions = element("div", "release-actions");
if (release.version !== currentVersion) {
const publishButton = element("button", "publish-button", "发布此版本");
publishButton.type = "button";
publishButton.addEventListener("click", () => publishRelease(release.version, publishButton));
actions.append(publishButton);
}
const editButton = element("button", "publish-button", "编辑说明");
editButton.type = "button";
editButton.addEventListener("click", () => editReleaseDescription(release));
actions.append(editButton);
row.append(main, size, target, date, hash, actions);
if (notes) row.append(notes);
list.append(row);
}
}
function updateDescriptionPreview() {
const markdown = descriptionForm.elements.description.value;
const preview = document.querySelector("#description-preview");
preview.replaceChildren(renderMarkdown(markdown));
preview.hidden = !markdown.trim();
}
function editReleaseDescription(release) {
const application = selectedApplication();
if (!application) return;
descriptionForm.elements.description.value = release.description || "";
descriptionForm.dataset.version = release.version;
document.querySelector("#description-version").textContent =
`${application.display_name} · ${release.version}`;
updateDescriptionPreview();
descriptionDialog.showModal();
}
async function publishRelease(version, button) {
const application = selectedApplication();
if (!application) return;
button.disabled = true;
try {
await api(
`/api/apps/${encodeURIComponent(application.app_id)}/releases/${encodeURIComponent(version)}/publish`,
{ method: "POST" },
);
showNotice(`${application.display_name} ${version} 已发布。`);
await refreshApplications();
} catch (error) {
showNotice(error.message, true);
button.disabled = false;
}
}
function updateFileLabel(file) {
fileLabel.replaceChildren(
element("strong", "", file ? file.name : "选择程序包"),
element("small", "", file ? `${(file.size / 1024 / 1024).toFixed(1)} MiB` : "或拖放到此处,最大 2 GiB"),
);
configureHelperOption(file);
}
function configureHelperOption(file = fileInput.files[0] || null) {
const canUpdateHelper = Boolean(file && /\.exe$/i.test(file.name) && platformInput.value === "windows");
helperCompatibleInput.disabled = !canUpdateHelper;
helperCompatibleInput.checked = canUpdateHelper;
}
function uploadFile(file, formData) {
const application = selectedApplication();
if (!application) return Promise.reject(new Error("请先选择应用。"));
const body = new FormData();
body.append("version", formData.get("version"));
body.append("file", file);
body.append("platform", formData.get("platform"));
body.append("architecture", formData.get("architecture"));
body.append("helper_compatible", helperCompatibleInput.checked ? "true" : "false");
body.append("description", formData.get("description"));
return new Promise((resolve, reject) => {
const request = new XMLHttpRequest();
request.open("POST", `/api/apps/${encodeURIComponent(application.app_id)}/releases`);
request.upload.addEventListener("progress", event => {
if (!event.lengthComputable) return;
const percent = Math.round((event.loaded / event.total) * 100);
progressBar.style.width = `${percent}%`;
progressLabel.textContent = `正在上传 ${percent}%`;
});
request.addEventListener("load", () => {
let payload;
try {
payload = JSON.parse(request.responseText);
} catch {
reject(new Error(`服务器返回了无法解析的响应(${request.status})。`));
return;
}
if (request.status < 200 || request.status >= 300) {
reject(new Error(`${request.status}: ${payload.detail || request.statusText}`));
return;
}
resolve(payload);
});
request.addEventListener("error", () => reject(new Error("上传失败,请检查网络连接。")));
request.send(body);
});
}
document.querySelector("#new-app-button").addEventListener("click", () => dialog.showModal());
document.querySelector("#empty-create-button").addEventListener("click", () => dialog.showModal());
for (const button of dialog.querySelectorAll("[data-close-dialog]")) {
button.addEventListener("click", () => dialog.close());
}
for (const button of descriptionDialog.querySelectorAll("[data-close-description]")) {
button.addEventListener("click", () => descriptionDialog.close());
}
descriptionForm.elements.description.addEventListener("input", updateDescriptionPreview);
document.querySelector("#refresh-button").addEventListener("click", () => {
refreshApplications().catch(error => showNotice(error.message, true));
});
descriptionForm.addEventListener("submit", async event => {
event.preventDefault();
const application = selectedApplication();
const version = descriptionForm.dataset.version;
if (!application || !version) return;
const button = document.querySelector("#save-description-button");
button.disabled = true;
try {
await api(
`/api/apps/${encodeURIComponent(application.app_id)}/releases/${encodeURIComponent(version)}/description`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ description: descriptionForm.elements.description.value }),
},
);
descriptionDialog.close();
showNotice(`${version} 版本说明已保存。`);
await renderApplication();
} catch (error) {
showNotice(error.message, true);
} finally {
button.disabled = false;
}
});
deleteAppButton.addEventListener("click", async () => {
const application = selectedApplication();
if (!application) return;
const confirmed = window.confirm(
`确定删除“${application.display_name}”吗?此操作会永久删除该程序的所有版本记录和本地程序包文件。`,
);
if (!confirmed) return;
deleteAppButton.disabled = true;
try {
await api(`/api/apps/${encodeURIComponent(application.app_id)}`, { method: "DELETE" });
selectedAppId = null;
showNotice(`${application.display_name} 及其程序包记录已删除。`);
await refreshApplications();
} catch (error) {
showNotice(error.message, true);
} finally {
deleteAppButton.disabled = false;
}
});
document.querySelector("#create-form").addEventListener("submit", async event => {
event.preventDefault();
const form = event.currentTarget;
const data = new FormData(form);
const button = document.querySelector("#create-button");
button.disabled = true;
try {
await api("/api/apps", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
app_id: data.get("app_id"),
display_name: data.get("display_name"),
}),
});
form.reset();
dialog.close();
showNotice("应用已创建。");
await refreshApplications();
} catch (error) {
showNotice(error.message, true);
} finally {
button.disabled = false;
}
});
uploadForm.addEventListener("submit", async event => {
event.preventDefault();
const file = fileInput.files[0];
if (!file) return;
uploadButton.disabled = true;
progressWrap.hidden = false;
progressBar.style.width = "0";
try {
const data = new FormData(uploadForm);
await uploadFile(file, data);
showNotice(`版本 ${data.get("version")} 上传完成;请在版本记录中发布。`);
uploadForm.reset();
updateFileLabel(null);
await renderApplication();
} catch (error) {
showNotice(error.message, true);
} finally {
uploadButton.disabled = false;
progressWrap.hidden = true;
}
});
fileInput.addEventListener("change", () => updateFileLabel(fileInput.files[0] || null));
platformInput.addEventListener("change", () => configureHelperOption());
for (const eventName of ["dragenter", "dragover"]) {
fileDrop.addEventListener(eventName, event => {
event.preventDefault();
fileDrop.classList.add("dragging");
});
}
for (const eventName of ["dragleave", "drop"]) {
fileDrop.addEventListener(eventName, event => {
event.preventDefault();
fileDrop.classList.remove("dragging");
});
}
fileDrop.addEventListener("drop", event => {
const [file] = event.dataTransfer.files;
if (!file) return;
const transfer = new DataTransfer();
transfer.items.add(file);
fileInput.files = transfer.files;
updateFileLabel(file);
});
configureHelperOption(null);
refreshApplications().catch(error => showNotice(error.message, true));
:root {
color-scheme: light;
font-family: Inter, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
color: #202a3b;
background: #f5f7fb;
font-synthesis: none;
text-rendering: optimizeLegibility;
--muted: #788397;
--line: #e7ebf2;
--blue: #4263eb;
--blue-dark: #3451cd;
}
* { box-sizing: border-box; }
body { min-width: 360px; min-height: 100vh; margin: 0; }
.page { width: min(1120px, calc(100% - 40px)); margin: 0 auto; padding: 28px 0 42px; }
.masthead { display: flex; justify-content: space-between; align-items: center; padding-bottom: 23px; border-bottom: 1px solid var(--line); }
.brand { display: flex; gap: 11px; align-items: center; color: inherit; text-decoration: none; }
.brand-mark { display: grid; place-items: center; width: 38px; height: 38px; border-radius: 12px; color: white; background: var(--blue); font-size: 19px; font-weight: 800; }
.brand strong, .brand small { display: block; }
.brand strong { font-size: 14px; }
.brand small { margin-top: 3px; color: var(--muted); font-size: 10px; letter-spacing: .04em; }
.live-status { display: flex; gap: 8px; align-items: center; color: #617087; font-size: 11px; }
.live-status i { width: 7px; height: 7px; border-radius: 50%; background: #27ae7b; box-shadow: 0 0 0 3px #27ae7b1c; }
.hero { padding: 44px 0 25px; }
.eyebrow { color: #8691a3; font-size: 10px; font-weight: 700; letter-spacing: .14em; }
h1 { margin: 8px 0 7px; font-size: clamp(24px, 4vw, 32px); letter-spacing: -.03em; }
.hero p { margin: 0; color: var(--muted); font-size: 13px; line-height: 1.7; }
.package-list { display: grid; gap: 12px; }
.package-card { min-width: 0; padding: 20px 22px; border: 1px solid var(--line); border-radius: 13px; background: white; box-shadow: 0 2px 8px #25314b05; }
.package-heading { display: flex; justify-content: space-between; gap: 16px; align-items: start; }
.package-version { font-size: 18px; font-weight: 750; letter-spacing: -.02em; }
.package-name { margin-top: 3px; color: var(--muted); font-size: 11px; }
.package-badge { flex: 0 0 auto; padding: 5px 9px; border-radius: 999px; color: #56647a; background: #f0f2f6; font-size: 10px; font-weight: 700; }
.package-badge-helper { color: #6652b8; background: #f0edff; }
.package-metadata { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin: 18px 0; padding: 13px 0; border-top: 1px solid #eff1f5; border-bottom: 1px solid #eff1f5; }
.metadata-item { display: grid; gap: 5px; min-width: 0; }
.metadata-item span { color: var(--muted); font-size: 10px; }
.metadata-item strong { overflow: hidden; color: #3c485d; font-size: 11px; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; }
.integrity { color: #68758a; font-size: 10px; }
.integrity summary { cursor: pointer; }
.integrity code { display: block; overflow-wrap: anywhere; margin-top: 8px; color: #69758a; font: 10px ui-monospace, SFMono-Regular, Menlo, monospace; }
.package-actions { display: flex; gap: 14px; align-items: center; margin-top: 17px; }
.download-button { display: inline-flex; min-height: 38px; align-items: center; justify-content: center; padding: 0 16px; border-radius: 8px; color: white; background: var(--blue); font-size: 11px; font-weight: 650; text-decoration: none; transition: background .15s, transform .15s; }
.download-button:hover { background: var(--blue-dark); transform: translateY(-1px); }
.published-date, .history-link { color: var(--muted); font-size: 10px; }
.history-link { margin-left: auto; color: var(--blue); text-decoration: none; }
.history-link:hover { text-decoration: underline; }
.package-card-compact { padding: 17px 19px; }
.package-card-compact .package-metadata { margin: 13px 0; padding: 10px 0; }
.package-card-compact .integrity { display: none; }
.package-card-compact .package-actions { margin-top: 12px; }
.package-card-compact .download-button { min-height: 34px; }
.empty-card, .error-card { padding: 22px; border: 1px solid var(--line); border-radius: 12px; color: var(--muted); background: white; font-size: 12px; }
.error-card { margin-bottom: 14px; border-color: #f1c5c5; color: #9b3b3b; background: #fff5f5; }
footer { padding-top: 25px; color: #929cad; font-size: 10px; line-height: 1.7; }
@media (max-width: 600px) {
.page { width: min(100% - 28px, 940px); padding-top: 17px; }
.hero { padding: 34px 0 20px; }
.package-card { padding: 16px; }
.package-metadata { grid-template-columns: 1fr 1fr; }
.metadata-item:last-child { grid-column: span 2; }
.package-actions { flex-wrap: wrap; }
.history-link { margin-left: 0; }
}
body { font-size: 15px; }
.brand strong { font-size: 16px; }
.brand small, .live-status { font-size: 12px; }
.eyebrow { font-size: 11px; }
.hero p { font-size: 15px; }
.package-version { font-size: 20px; }
.package-name { font-size: 12px; }
.package-badge { font-size: 12px; }
.metadata-item span { font-size: 12px; }
.metadata-item strong { font-size: 13px; }
.integrity { font-size: 12px; }
.integrity code { font-size: 12px; }
.download-button { font-size: 14px; }
.published-date, .history-link { font-size: 12px; }
.empty-card, .error-card { font-size: 14px; }
footer { font-size: 12px; }
.package-description { margin: 14px 0 0; padding: 13px 15px; border-radius: 8px; color: #68758a; background: #f7f8fc; font-size: 13px; }
.package-description-title { display: block; margin-bottom: 6px; color: #536077; font-size: 13px; }
.markdown-body { color: #465268; font-size: 14px; line-height: 1.7; overflow-wrap: anywhere; }
.markdown-body > :first-child { margin-top: 0; }
.markdown-body > :last-child { margin-bottom: 0; }
.markdown-body h1, .markdown-body h2, .markdown-body h3 { margin: 12px 0 6px; letter-spacing: 0; }
.markdown-body h1 { font-size: 19px; }
.markdown-body h2 { font-size: 17px; }
.markdown-body h3 { font-size: 15px; }
.markdown-body p, .markdown-body ul, .markdown-body ol, .markdown-body pre { margin: 7px 0; }
.markdown-body ul, .markdown-body ol { padding-left: 22px; }
.markdown-body pre { overflow-x: auto; padding: 10px 12px; border-radius: 7px; background: #f5f7fb; }
.markdown-body code { padding: 1px 4px; border-radius: 4px; background: #f0f2f6; font: 0.9em ui-monospace, SFMono-Regular, Menlo, monospace; }
.markdown-body pre code { padding: 0; background: transparent; }
.markdown-body a { color: var(--blue); }
import { renderMarkdown } from "./markdown.js";
const packageList = document.querySelector("#package-list");
const errorCard = document.querySelector("#error");
async function api(path) {
const response = await fetch(path, { headers: { Accept: "application/json" } });
if (!response.ok) {
let detail = response.statusText;
try {
detail = (await response.json()).detail || detail;
} catch {
// Keep the HTTP status text when the server did not return JSON.
}
throw new Error(`${response.status}: ${detail}`);
}
return response.json();
}
function node(tag, className, text) {
const result = document.createElement(tag);
if (className) result.className = className;
if (text !== undefined) result.textContent = text;
return result;
}
function formatSize(bytes) {
if (bytes < 1024) return `${bytes} B`;
const units = ["KiB", "MiB", "GiB", "TiB"];
let value = bytes;
let unit = -1;
do {
value /= 1024;
unit += 1;
} while (value >= 1024 && unit < units.length - 1);
return `${value.toFixed(1)} ${units[unit]}`;
}
function packageCard(packageInfo, appName, { compact = false } = {}) {
const card = node("article", `package-card${compact ? " package-card-compact" : ""}`);
const heading = node("div", "package-heading");
const identity = node("div", "");
identity.append(
node("div", "package-version", packageInfo.version),
node("div", "package-name", appName),
);
const badge = node(
"span",
`package-badge${packageInfo.helper_compatible ? " package-badge-helper" : ""}`,
packageInfo.helper_compatible ? "Helper 更新包" : packageInfo.package_type.toUpperCase(),
);
heading.append(identity, badge);
if (packageInfo.description) {
const notes = node("section", "package-description");
notes.append(
node("strong", "package-description-title", "版本更新内容"),
renderMarkdown(packageInfo.description),
);
card.append(heading, notes);
} else {
card.append(heading);
}
const metadata = node("div", "package-metadata");
for (const [label, value] of [
["平台", `${packageInfo.platform} · ${packageInfo.architecture}`],
["文件", packageInfo.filename],
["大小", formatSize(packageInfo.size)],
]) {
const item = node("div", "metadata-item");
item.append(node("span", "", label), node("strong", "", value));
metadata.append(item);
}
const integrity = node("details", "integrity");
integrity.append(node("summary", "", "SHA-256 校验值"));
integrity.append(node("code", "", packageInfo.sha256));
const actions = node("div", "package-actions");
const download = node("a", "download-button", "下载程序包");
download.href = packageInfo.download_url;
download.download = packageInfo.filename;
actions.append(download);
if (!compact) {
const publishedDate = packageInfo.published_at
? new Date(packageInfo.published_at).toLocaleString()
: "";
actions.append(node("span", "published-date", `发布于 ${publishedDate}`));
}
card.append(metadata, integrity, actions);
return card;
}
function renderLibrary(applications) {
const title = document.querySelector("#page-title");
const description = document.querySelector("#page-description");
title.textContent = "获取程序与更新包";
description.textContent = `${applications.length} 个程序已发布,选择适合你设备的平台与架构。`;
packageList.replaceChildren();
if (!applications.length) {
packageList.append(node("div", "empty-card", "暂时没有已发布的程序包。"));
return;
}
for (const application of applications) {
const card = packageCard({
...application,
version: application.current_version,
}, application.display_name, { compact: true });
const detail = node("a", "history-link", "查看已发布版本");
detail.href = `/download/${encodeURIComponent(application.app_id)}`;
card.querySelector(".package-actions").append(detail);
packageList.append(card);
}
}
function renderApplication(application) {
document.querySelector("#page-title").textContent = application.display_name;
document.querySelector("#page-description").textContent =
`${application.packages.length} 个已发布程序包 · App ID:${application.app_id}`;
packageList.replaceChildren();
for (const packageInfo of application.packages) {
packageList.append(packageCard(packageInfo, application.display_name));
}
}
async function render() {
const pathSegments = window.location.pathname.split("/").filter(Boolean);
const appId = pathSegments[0] === "download" ? pathSegments[1] : null;
try {
if (appId) {
const application = await api(`/api/public/apps/${encodeURIComponent(decodeURIComponent(appId))}`);
renderApplication(application);
} else {
renderLibrary(await api("/api/public/apps"));
}
} catch (error) {
errorCard.textContent = error.message;
errorCard.hidden = false;
}
}
render();
function renderInline(parent, text) {
const pattern = /(\*\*.+?\*\*|\*[^*]+\*|`[^`]+`|\[[^\]]+\]\([^)]+\))/g;
let offset = 0;
for (const match of text.matchAll(pattern)) {
parent.append(document.createTextNode(text.slice(offset, match.index)));
const token = match[0];
let node;
if (token.startsWith("**")) {
node = document.createElement("strong");
node.textContent = token.slice(2, -2);
} else if (token.startsWith("*")) {
node = document.createElement("em");
node.textContent = token.slice(1, -1);
} else if (token.startsWith("`")) {
node = document.createElement("code");
node.textContent = token.slice(1, -1);
} else {
const linkMatch = token.match(/^\[([^\]]+)\]\(([^)]+)\)$/);
if (!linkMatch) {
parent.append(document.createTextNode(token));
offset = match.index + token.length;
continue;
}
const link = document.createElement("a");
link.textContent = linkMatch[1];
try {
const url = new URL(linkMatch[2], window.location.origin);
if (!["http:", "https:"].includes(url.protocol)) {
parent.append(document.createTextNode(token));
offset = match.index + token.length;
continue;
}
link.href = url.href;
link.target = "_blank";
link.rel = "noopener noreferrer";
} catch {
parent.append(document.createTextNode(token));
offset = match.index + token.length;
continue;
}
node = link;
}
parent.append(node);
offset = match.index + token.length;
}
parent.append(document.createTextNode(text.slice(offset)));
}
function isBlockStart(line) {
return /^\s*(#{1,3}\s+|```|([-*_])\2{2,}\s*$|[-*+]\s+|\d+\.\s+)/.test(line);
}
export function renderMarkdown(markdown) {
const container = document.createElement("div");
container.className = "markdown-body";
const lines = String(markdown || "").replace(/\r\n?/g, "\n").split("\n");
let index = 0;
while (index < lines.length) {
const line = lines[index];
if (!line.trim()) {
index += 1;
continue;
}
if (line.trimStart().startsWith("```")) {
const codeLines = [];
index += 1;
while (index < lines.length && !lines[index].trimStart().startsWith("```")) {
codeLines.push(lines[index]);
index += 1;
}
if (index < lines.length) index += 1;
const pre = document.createElement("pre");
const code = document.createElement("code");
code.textContent = codeLines.join("\n");
pre.append(code);
container.append(pre);
continue;
}
const heading = line.match(/^\s*(#{1,3})\s+(.+)$/);
if (heading) {
const element = document.createElement(`h${heading[1].length}`);
renderInline(element, heading[2]);
container.append(element);
index += 1;
continue;
}
if (/^\s*([-*_])\1{2,}\s*$/.test(line)) {
container.append(document.createElement("hr"));
index += 1;
continue;
}
const listItem = line.match(/^\s*([-*+])\s+(.+)|^\s*(\d+)\.\s+(.+)/);
if (listItem) {
const ordered = Boolean(listItem[3]);
const list = document.createElement(ordered ? "ol" : "ul");
while (index < lines.length) {
const item = lines[index].match(/^\s*([-*+])\s+(.+)|^\s*(\d+)\.\s+(.+)/);
if (!item || Boolean(item[3]) !== ordered) break;
const element = document.createElement("li");
renderInline(element, item[2] || item[4]);
list.append(element);
index += 1;
}
container.append(list);
continue;
}
const paragraphLines = [line.trim()];
index += 1;
while (index < lines.length && lines[index].trim() && !isBlockStart(lines[index])) {
paragraphLines.push(lines[index].trim());
index += 1;
}
const paragraph = document.createElement("p");
renderInline(paragraph, paragraphLines.join(" "));
container.append(paragraph);
}
return container;
}
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light">
<title>程序下载中心</title>
<link rel="stylesheet" href="/assets/downloads.css">
<script type="module" src="/assets/downloads.js"></script>
</head>
<body>
<main class="page">
<header class="masthead">
<a class="brand" href="/downloads">
<span class="brand-mark">P</span>
<span><strong>程序下载中心</strong><small>Program Distribution</small></span>
</a>
<span class="live-status"><i></i>安全分发</span>
</header>
<section class="hero">
<div class="eyebrow">DOWNLOAD LIBRARY</div>
<h1 id="page-title">获取程序与更新包</h1>
<p id="page-description">浏览已发布的程序版本,下载前请核对平台与架构。</p>
</section>
<div id="error" class="error-card" role="status" hidden></div>
<section id="package-list" class="package-list" aria-live="polite"></section>
<footer>下载文件附带 SHA-256 校验值;程序更新清单仅适用于标记为 Helper 的 Windows EXE。</footer>
</main>
</body>
</html>
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>程序分发管理</title>
<link rel="stylesheet" href="/assets/app.css">
<script type="module" src="/assets/app.js"></script>
</head>
<body>
<div class="shell">
<aside class="sidebar">
<a class="brand" href="/">
<span class="brand-mark">P</span>
<span><strong>Release Desk</strong><small>程序分发中心</small></span>
</a>
<div class="side-label">工作空间</div>
<div class="side-heading">
<span>应用程序</span>
<button id="new-app-button" class="icon-button" title="新建应用" aria-label="新建应用">+</button>
</div>
<nav id="app-list" class="app-list" aria-label="应用列表"></nav>
<div class="sidebar-footer">
<span class="status-dot"></span>
<span>局域网分发服务</span>
</div>
</aside>
<main class="main">
<header class="topbar">
<div>
<div class="eyebrow">PROGRAM DISTRIBUTION</div>
<h1 id="page-title">选择一个应用</h1>
</div>
<div class="topbar-actions">
<button id="delete-app-button" class="button button-danger" hidden>删除应用</button>
<a class="button button-muted" href="/downloads" target="_blank" rel="noopener">公开下载页</a>
<button id="refresh-button" class="button button-muted">刷新</button>
</div>
</header>
<div id="notice" class="notice" role="status" hidden></div>
<section id="empty-state" class="empty-state">
<div class="empty-icon">↗</div>
<h2>管理所有程序分发包</h2>
<p>为每个程序集中维护版本、安装包、校验信息和公开下载入口。</p>
<button id="empty-create-button" class="button button-primary">创建第一个应用</button>
</section>
<div id="app-content" hidden>
<section class="summary-grid">
<article class="panel current-release">
<div class="panel-kicker">当前发布版本</div>
<div class="current-version">
<span id="current-version" class="version-number">未发布</span>
<span id="current-status" class="badge badge-neutral">未发布</span>
</div>
<p id="current-release-meta" class="muted">上传并发布一个版本后,客户端即可检测更新。</p>
<div class="manifest-line">
<span>客户端清单地址</span>
<code id="manifest-url">—</code>
</div>
<div class="manifest-line download-link-line">
<span>公开下载页面</span>
<a id="download-page-url" href="/downloads" target="_blank" rel="noopener">—</a>
</div>
</article>
<article class="panel app-details">
<div class="panel-kicker">应用信息</div>
<div class="detail-row"><span>应用名称</span><strong id="app-name">—</strong></div>
<div class="detail-row"><span>App ID</span><code id="app-id">—</code></div>
<div class="detail-row"><span>版本总数</span><strong id="release-count">0</strong></div>
</article>
</section>
<section class="panel upload-panel">
<div class="section-heading">
<div>
<div class="panel-kicker">NEW RELEASE</div>
<h2>上传新版本</h2>
<p class="muted">支持 EXE、MSI、ZIP、DMG、PKG、DEB、RPM、AppImage 等;上传后自动计算大小与 SHA-256。</p>
</div>
</div>
<form id="upload-form" class="upload-form">
<label class="field version-field">
<span>版本号</span>
<input name="version" placeholder="例如 1.2.0" pattern="(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)" required>
<small>格式:MAJOR.MINOR.PATCH</small>
</label>
<label class="field">
<span>平台</span>
<select name="platform">
<option value="windows">Windows</option>
<option value="macos">macOS</option>
<option value="linux">Linux</option>
<option value="android">Android</option>
<option value="ios">iOS</option>
<option value="other">其他</option>
</select>
</label>
<label class="field">
<span>架构</span>
<select name="architecture">
<option value="x64">x64</option>
<option value="x86">x86</option>
<option value="arm64">ARM64</option>
<option value="arm">ARM</option>
<option value="universal">Universal</option>
<option value="any">不限</option>
</select>
</label>
<label class="helper-option">
<input id="helper-compatible" name="helper_compatible" type="checkbox" checked>
<span><strong>提供给更新 Helper</strong><small>仅 Windows EXE 可用</small></span>
</label>
<label id="file-drop" class="file-drop">
<input id="file-input" name="file" type="file" accept=".exe,.msi,.zip,.7z,.dmg,.pkg,.deb,.rpm,.appimage,.tar.gz" required>
<span class="file-icon">↑</span>
<span id="file-label"><strong>选择程序包</strong><small>或拖放到此处,最大 2 GiB</small></span>
</label>
<button id="upload-button" class="button button-primary" type="submit">上传版本</button>
<label class="field markdown-field">
<span>版本说明(Markdown,可选)</span>
<textarea name="description" rows="4" maxlength="20000" placeholder="# 更新内容&#10;- 新增功能&#10;- 修复问题"></textarea>
<small>支持标题、列表、粗体、链接和代码;会显示在公开下载页。</small>
</label>
</form>
<div id="upload-progress" class="progress-wrap" hidden>
<div class="progress-track"><div id="progress-bar" class="progress-bar"></div></div>
<span id="progress-label" class="muted">正在上传…</span>
</div>
</section>
<section class="panel releases-panel">
<div class="section-heading release-heading">
<div>
<div class="panel-kicker">HISTORY</div>
<h2>程序包记录</h2>
</div>
<span id="release-count-label" class="muted"></span>
</div>
<div id="release-list" class="release-list"></div>
</section>
</div>
</main>
</div>
<dialog id="create-dialog" class="dialog">
<form id="create-form" method="dialog">
<div class="dialog-heading">
<div><div class="panel-kicker">NEW APPLICATION</div><h2>创建应用</h2></div>
<button class="icon-button" type="button" data-close-dialog aria-label="关闭">×</button>
</div>
<label class="field">
<span>应用 ID</span>
<input name="app_id" placeholder="my-program" pattern="[A-Za-z0-9][A-Za-z0-9._-]*" maxlength="128" required>
<small>创建后不可更改,仅允许英文、数字、点、下划线和连字符。</small>
</label>
<label class="field">
<span>显示名称</span>
<input name="display_name" placeholder="我的程序" maxlength="120" required>
</label>
<div class="dialog-actions">
<button class="button button-muted" type="button" data-close-dialog>取消</button>
<button id="create-button" class="button button-primary" type="submit" value="default">创建应用</button>
</div>
</form>
</dialog>
<dialog id="description-dialog" class="dialog description-dialog">
<form id="description-form">
<div class="dialog-heading">
<div><div class="panel-kicker">RELEASE NOTES</div><h2>编辑版本说明</h2></div>
<button class="icon-button" type="button" data-close-description aria-label="关闭">×</button>
</div>
<p id="description-version" class="muted"></p>
<label class="field">
<span>Markdown 说明</span>
<textarea name="description" rows="8" maxlength="20000" placeholder="# 更新内容&#10;- 新增功能&#10;- 修复问题"></textarea>
<small>保存后会立即显示在程序包记录和公开下载页。</small>
</label>
<div id="description-preview" class="markdown-body description-preview" hidden></div>
<div class="dialog-actions">
<button class="button button-muted" type="button" data-close-description>取消</button>
<button id="save-description-button" class="button button-primary" type="submit">保存说明</button>
</div>
</form>
</dialog>
</body>
</html>
from .app import main as run_app
def main() -> int:
return run_app()
if __name__ == "__main__":
raise SystemExit(main())
from dataclasses import dataclass
from ipaddress import ip_network
from pathlib import Path
SERVER_HOST = "0.0.0.0"
SERVER_PORT = 8000
DATA_DIR = Path(__file__).resolve().parent / "data"
MAX_UPLOAD_SIZE = 2 * 1024 * 1024 * 1024
ALLOWED_CIDRS = (
"10.0.0.0/8",
"172.16.0.0/12",
"192.168.0.0/16",
"127.0.0.0/8",
"::1/128",
"fc00::/7",
"fe80::/10",
)
FRONTEND_DIR = Path(__file__).resolve().parent / "frontend"
@dataclass(frozen=True, slots=True)
class DistributionSettings:
data_dir: Path = DATA_DIR
allowed_cidrs: tuple[str, ...] = ALLOWED_CIDRS
max_upload_size: int = MAX_UPLOAD_SIZE
frontend_dir: Path = FRONTEND_DIR
host: str = SERVER_HOST
port: int = SERVER_PORT
def __post_init__(self) -> None:
if self.max_upload_size <= 0:
raise ValueError("max_upload_size must be positive.")
if not 1 <= self.port <= 65535:
raise ValueError("port must be between 1 and 65535.")
for cidr in self.allowed_cidrs:
ip_network(cidr, strict=False)
if not (self.frontend_dir / "index.html").is_file():
raise ValueError(f"frontend index.html not found in {self.frontend_dir}.")
if not (self.frontend_dir / "assets").is_dir():
raise ValueError(f"frontend assets directory not found in {self.frontend_dir}.")
"""Tests for the distribution server package."""
"""Integration tests for the independent LAN distribution server."""
import hashlib
import sqlite3
import tempfile
import unittest
from pathlib import Path
from fastapi.testclient import TestClient
from ..app import create_app
from ..settings import DistributionSettings
class DistributionTests(unittest.TestCase):
def setUp(self) -> None:
self.temp_dir = tempfile.TemporaryDirectory()
self.root = Path(self.temp_dir.name)
settings = DistributionSettings(
data_dir=self.root / "server-data",
allowed_cidrs=("127.0.0.0/8",),
max_upload_size=1024,
)
self.app = create_app(settings)
self.client = TestClient(
self.app,
base_url="http://127.0.0.1:8000",
client=("127.0.0.1", 50000),
)
self.client.__enter__()
def tearDown(self) -> None:
self.client.__exit__(None, None, None)
self.temp_dir.cleanup()
def create_application(self, app_id: str = "my-program") -> None:
response = self.client.post(
"/api/apps",
json={"app_id": app_id, "display_name": app_id},
)
self.assertEqual(response.status_code, 201, response.text)
def upload(self, app_id: str, version: str, content: bytes) -> dict[str, object]:
response = self.client.post(
f"/api/apps/{app_id}/releases",
data={"version": version},
files={"file": (f"{app_id}.exe", content, "application/octet-stream")},
)
self.assertEqual(response.status_code, 201, response.text)
return response.json()
def test_release_upload_publish_manifest_and_download(self) -> None:
self.create_application()
executable = b"MZ" + b"example executable"
uploaded = self.upload("my-program", "1.2.0", executable)
self.assertEqual(uploaded["size"], len(executable))
self.assertEqual(uploaded["sha256"], hashlib.sha256(executable).hexdigest())
self.assertFalse(uploaded["published"])
self.assertEqual(
self.client.get("/updates/my-program/latest.json").status_code,
404,
)
publish_response = self.client.post(
"/api/apps/my-program/releases/1.2.0/publish",
)
self.assertEqual(publish_response.status_code, 200, publish_response.text)
manifest_response = self.client.get("/updates/my-program/latest.json")
self.assertEqual(manifest_response.status_code, 200, manifest_response.text)
self.assertEqual(
manifest_response.json(),
{
"app_id": "my-program",
"version": "1.2.0",
"url": "http://127.0.0.1:8000/packages/my-program/1.2.0/download",
"size": len(executable),
"sha256": hashlib.sha256(executable).hexdigest(),
},
)
download_response = self.client.get("/packages/my-program/1.2.0/app.exe")
self.assertEqual(download_response.status_code, 200)
self.assertEqual(download_response.content, executable)
def test_multiple_applications_have_independent_latest_releases(self) -> None:
self.create_application("first-app")
self.create_application("second-app")
first_executable = b"MZ-first"
second_executable = b"MZ-second"
self.upload("first-app", "1.0.0", first_executable)
self.upload("second-app", "2.0.0", second_executable)
self.client.post("/api/apps/first-app/releases/1.0.0/publish")
self.client.post("/api/apps/second-app/releases/2.0.0/publish")
first_manifest = self.client.get("/updates/first-app/latest.json").json()
second_manifest = self.client.get("/updates/second-app/latest.json").json()
self.assertEqual(first_manifest["version"], "1.0.0")
self.assertEqual(second_manifest["version"], "2.0.0")
self.assertNotEqual(first_manifest["sha256"], second_manifest["sha256"])
def test_upload_rejects_non_executable_content(self) -> None:
self.create_application()
response = self.client.post(
"/api/apps/my-program/releases",
data={"version": "1.0.0"},
files={"file": ("program.exe", b"not an executable", "application/octet-stream")},
)
self.assertEqual(response.status_code, 400)
def test_generic_package_is_recorded_and_published_for_public_download(self) -> None:
self.create_application()
package = b"example mac package"
description = "# Release notes\n\n- **New:** Mac support.\n"
upload_response = self.client.post(
"/api/apps/my-program/releases",
data={
"version": "1.0.0",
"platform": "macos",
"architecture": "universal",
"helper_compatible": "false",
"description": description,
},
files={"file": ("sample.zip", package, "application/zip")},
)
self.assertEqual(upload_response.status_code, 201, upload_response.text)
uploaded = upload_response.json()
self.assertEqual(uploaded["filename"], "sample.zip")
self.assertEqual(uploaded["package_type"], "zip")
self.assertEqual(uploaded["platform"], "macos")
self.assertEqual(uploaded["architecture"], "universal")
self.assertFalse(uploaded["helper_compatible"])
self.assertEqual(uploaded["description"], description.strip())
self.assertEqual(uploaded["size"], len(package))
self.assertEqual(uploaded["sha256"], hashlib.sha256(package).hexdigest())
unpublished_download = self.client.get("/packages/my-program/1.0.0/download")
self.assertEqual(unpublished_download.status_code, 404)
publish_response = self.client.post(
"/api/apps/my-program/releases/1.0.0/publish",
)
self.assertEqual(publish_response.status_code, 200, publish_response.text)
download_response = self.client.get("/packages/my-program/1.0.0/download")
self.assertEqual(download_response.status_code, 200)
self.assertEqual(download_response.content, package)
self.assertEqual(download_response.headers["content-disposition"], 'attachment; filename="sample.zip"')
public_application = self.client.get("/api/public/apps/my-program")
self.assertEqual(public_application.status_code, 200, public_application.text)
self.assertEqual(public_application.json()["packages"][0]["filename"], "sample.zip")
self.assertEqual(public_application.json()["packages"][0]["sha256"], uploaded["sha256"])
self.assertEqual(public_application.json()["packages"][0]["description"], description.strip())
def test_delete_application_removes_records_and_package_files(self) -> None:
self.create_application()
self.upload("my-program", "1.0.0", b"MZ-package")
self.client.post("/api/apps/my-program/releases/1.0.0/publish")
package_directory = (
self.root / "server-data" / "packages" / "my-program"
)
self.assertTrue(package_directory.is_dir())
response = self.client.delete("/api/apps/my-program")
self.assertEqual(response.status_code, 204, response.text)
self.assertFalse(package_directory.exists())
self.assertEqual(self.client.get("/api/apps").json(), [])
self.assertEqual(
self.client.get("/api/apps/my-program/releases").status_code,
404,
)
self.assertEqual(
self.client.get("/updates/my-program/latest.json").status_code,
404,
)
self.assertEqual(
self.client.get("/packages/my-program/1.0.0/download").status_code,
404,
)
self.create_application()
self.assertEqual(
self.client.get("/api/apps/my-program/releases").json(),
[],
)
def test_existing_release_description_can_be_saved_and_is_returned_publicly(self) -> None:
self.create_application()
self.upload("my-program", "1.0.0", b"MZ-package")
self.client.post("/api/apps/my-program/releases/1.0.0/publish")
description = "# 修复\n\n- **修复** 更新时的错误提示。\n"
update_response = self.client.patch(
"/api/apps/my-program/releases/1.0.0/description",
json={"description": description},
)
self.assertEqual(update_response.status_code, 200, update_response.text)
self.assertEqual(update_response.json()["description"], description.strip())
releases = self.client.get("/api/apps/my-program/releases")
public_application = self.client.get("/api/public/apps/my-program")
self.assertEqual(releases.status_code, 200, releases.text)
self.assertEqual(releases.json()[0]["description"], description.strip())
self.assertEqual(public_application.status_code, 200, public_application.text)
self.assertEqual(
public_application.json()["packages"][0]["description"],
description.strip(),
)
def test_description_update_rejects_too_many_characters(self) -> None:
self.create_application()
self.upload("my-program", "1.0.0", b"MZ-package")
response = self.client.patch(
"/api/apps/my-program/releases/1.0.0/description",
json={"description": "x" * 20_001},
)
self.assertEqual(response.status_code, 422)
def test_non_helper_release_does_not_replace_updater_manifest(self) -> None:
self.create_application()
executable = b"MZ-original"
self.upload("my-program", "1.0.0", executable)
self.client.post("/api/apps/my-program/releases/1.0.0/publish")
package_response = self.client.post(
"/api/apps/my-program/releases",
data={
"version": "2.0.0",
"platform": "linux",
"architecture": "x64",
"helper_compatible": "false",
},
files={"file": ("sample.tar.gz", b"linux package", "application/gzip")},
)
self.assertEqual(package_response.status_code, 201, package_response.text)
self.client.post("/api/apps/my-program/releases/2.0.0/publish")
manifest = self.client.get("/updates/my-program/latest.json")
self.assertEqual(manifest.status_code, 200, manifest.text)
self.assertEqual(manifest.json()["version"], "1.0.0")
def test_existing_database_is_migrated_without_moving_executable(self) -> None:
legacy_dir = self.root / "legacy-data"
package = b"MZ-legacy"
database_path = legacy_dir / "distribution.sqlite3"
database_path.parent.mkdir(parents=True)
with sqlite3.connect(database_path) as connection:
connection.executescript(
"""
CREATE TABLE applications (
app_id TEXT PRIMARY KEY,
display_name TEXT NOT NULL,
current_version TEXT,
created_at TEXT NOT NULL
);
CREATE TABLE releases (
app_id TEXT NOT NULL,
version TEXT NOT NULL,
size INTEGER NOT NULL,
sha256 TEXT NOT NULL,
uploaded_at TEXT NOT NULL,
PRIMARY KEY (app_id, version),
FOREIGN KEY (app_id) REFERENCES applications(app_id)
ON DELETE CASCADE
);
"""
)
connection.execute(
"INSERT INTO applications VALUES (?, ?, ?, ?)",
("legacy-app", "Legacy App", "1.0.0", "2026-01-01T00:00:00+00:00"),
)
connection.execute(
"INSERT INTO releases VALUES (?, ?, ?, ?, ?)",
(
"legacy-app",
"1.0.0",
len(package),
hashlib.sha256(package).hexdigest(),
"2026-01-01T00:00:00+00:00",
),
)
legacy_package_path = legacy_dir / "packages" / "legacy-app" / "1.0.0" / "app.exe"
legacy_package_path.parent.mkdir(parents=True)
legacy_package_path.write_bytes(package)
legacy_app = create_app(
DistributionSettings(
data_dir=legacy_dir,
allowed_cidrs=("127.0.0.0/8",),
max_upload_size=1024,
)
)
with TestClient(
legacy_app,
base_url="http://127.0.0.1:8000",
client=("127.0.0.1", 50000),
) as legacy_client:
manifest_response = legacy_client.get("/updates/legacy-app/latest.json")
download_response = legacy_client.get("/packages/legacy-app/1.0.0/download")
releases_response = legacy_client.get("/api/apps/legacy-app/releases")
self.assertEqual(manifest_response.status_code, 200, manifest_response.text)
self.assertEqual(manifest_response.json()["version"], "1.0.0")
self.assertEqual(download_response.status_code, 200)
self.assertEqual(download_response.content, package)
self.assertEqual(releases_response.status_code, 200, releases_response.text)
self.assertEqual(releases_response.json()[0]["description"], "")
def test_upload_rejects_duplicate_version(self) -> None:
self.create_application()
executable = b"MZ-same"
self.upload("my-program", "1.0.0", executable)
response = self.client.post(
"/api/apps/my-program/releases",
data={"version": "1.0.0"},
files={"file": ("program.exe", executable, "application/octet-stream")},
)
self.assertEqual(response.status_code, 409)
def test_requests_outside_configured_lan_are_rejected(self) -> None:
outside_client = TestClient(
self.app,
client=("8.8.8.8", 50000),
)
with outside_client:
response = outside_client.get("/api/apps")
self.assertEqual(response.status_code, 403)
def test_dashboard_is_served(self) -> None:
response = self.client.get("/")
self.assertEqual(response.status_code, 200)
self.assertIn("程序分发中心", response.text)
self.assertIn('/assets/app.css', response.text)
def test_frontend_assets_are_served_separately(self) -> None:
script = self.client.get("/assets/app.js")
stylesheet = self.client.get("/assets/app.css")
download_script = self.client.get("/assets/downloads.js")
download_stylesheet = self.client.get("/assets/downloads.css")
markdown_script = self.client.get("/assets/markdown.js")
self.assertEqual(script.status_code, 200)
self.assertIn("refreshApplications", script.text)
self.assertEqual(stylesheet.status_code, 200)
self.assertIn(".shell", stylesheet.text)
self.assertEqual(download_script.status_code, 200)
self.assertIn("renderLibrary", download_script.text)
self.assertEqual(download_stylesheet.status_code, 200)
self.assertIn(".package-card", download_stylesheet.text)
self.assertEqual(markdown_script.status_code, 200)
self.assertIn("export function renderMarkdown", markdown_script.text)
self.assertIn("body { font-size: 15px; }", stylesheet.text)
self.assertIn("body { font-size: 15px; }", download_stylesheet.text)
def test_public_download_surfaces_are_remote_but_management_is_not(self) -> None:
self.create_application()
executable = b"MZ-public"
self.upload("my-program", "1.0.0", executable)
self.client.post("/api/apps/my-program/releases/1.0.0/publish")
outside_client = TestClient(
self.app,
base_url="http://distribution.example",
client=("8.8.8.8", 50000),
)
with outside_client:
page = outside_client.get("/downloads")
package_page = outside_client.get("/download/my-program")
public_api = outside_client.get("/api/public/apps")
package = outside_client.get("/packages/my-program/1.0.0/download")
management = outside_client.get("/api/apps")
self.assertEqual(page.status_code, 200)
self.assertEqual(package_page.status_code, 200)
self.assertEqual(public_api.status_code, 200)
self.assertEqual(len(public_api.json()), 1)
self.assertEqual(package.status_code, 200)
self.assertEqual(management.status_code, 403)
def test_cross_origin_management_write_is_rejected(self) -> None:
response = self.client.post(
"/api/apps",
headers={"Origin": "http://untrusted.example"},
json={"app_id": "my-program", "display_name": "My Program"},
)
self.assertEqual(response.status_code, 403)
if __name__ == "__main__":
unittest.main()
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment