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))
: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."""
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