feat: add app update checks and standalone release publishing panel

This commit is contained in:
Codex
2026-10-24 13:50:42 +08:00
parent 437ed14dbe
commit 75e0b5587d
27 changed files with 1566 additions and 7 deletions
+6
View File
@@ -0,0 +1,6 @@
.env
.deps
__pycache__
.pytest_cache
data
tests
+7
View File
@@ -0,0 +1,7 @@
PUBLIC_ORIGIN=https://app.glowtps.cn
ADMIN_USERNAME=admin
# Generate on the server using: docker compose run --rm --no-deps updates python manage.py
# Keep single quotes: password hashes contain dollar signs.
ADMIN_PASSWORD_HASH='REPLACE_WITH_GENERATED_HASH'
SESSION_SECRET='REPLACE_WITH_GENERATED_RANDOM_SECRET'
DATA_DIR=/app/data
+5
View File
@@ -0,0 +1,5 @@
.env
.deps/
__pycache__/
.pytest_cache/
data/
+11
View File
@@ -0,0 +1,11 @@
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY main.py update_service.py manage.py ./
RUN useradd --system --uid 10001 --create-home updates \
&& mkdir -p /app/data && chown updates:updates /app/data
USER updates
EXPOSE 8000
CMD ["python", "-m", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1", "--proxy-headers", "--forwarded-allow-ips", "*", "--no-access-log", "--limit-concurrency", "32", "--timeout-keep-alive", "5"]
+100
View File
@@ -0,0 +1,100 @@
# TPSchedule 更新服务
独立于反馈服务,管理员在 `https://app.glowtps.cn/update` 发布安卓版本;APP查询 `https://app.glowtps.cn/v1/updates/android`。无需接入第三方推送SDK。APP冷启动进入首页后检查一次,后台无版本或网络失败时安静结束;“我的→检查更新”提供手动查询。有新版时用户可稍后处理,或打开浏览器下载。
## Ubuntu、1Panel、雷池部署步骤
已知环境:雷池80/443 → host网络的OpenResty10080 → 原反馈容器18080。新增更新容器仅监听 `127.0.0.1:18081`;反馈服务、SMTP、域名证书、原路由均不需要修改。本工程没有访问服务器或执行部署。
### 1. 上传服务文件
把 `TPSchedule-update-server.zip` 上传到1Panel文件管理器并解压,将 `updates` 目录内的文件放到 `/opt/tpschedule-updates`。确认该目录直接包含 `Dockerfile`、`docker-compose.yml`、`main.py`、`update_service.py` 等文件。
在1Panel终端确认18081没有其他服务占用:
```sh
ss -ltn '( sport = :18081 )'
cd /opt/tpschedule-updates
cp .env.example .env
chmod 600 .env
docker compose build
```
如果18081已占用,选择未使用的本机端口并同时修改Compose与下方OpenResty示例;不要停止其他服务。首次构建需要服务器访问镜像源及Python依赖源。
### 2. 设置更新后台登录
执行:
```sh
docker compose run --rm --no-deps updates python manage.py
```
按提示输入至少12位后台密码并再次确认。工具会输出 `ADMIN_PASSWORD_HASH='…'` 和 `SESSION_SECRET='…'` 两行。只在服务器将这两行填入 `.env` 的对应位置,保留单引号,避免Docker把密码哈希中的 `$` 当作变量。不要把输出或密码发到聊天中。
`.env`其他项目:
```dotenv
PUBLIC_ORIGIN=https://app.glowtps.cn
ADMIN_USERNAME=admin
DATA_DIR=/app/data
```
`PUBLIC_ORIGIN`不加结尾斜杠或路径,必须使用浏览器实际访问的HTTPS域名。可以自行修改ADMIN_USERNAME。密码以scrypt哈希保存;明文密码不写入APP或源码。SESSION_SECRET是更新后台自己的会话密钥,与APK签名密钥和反馈服务密钥无关。日后改密码时可重新运行工具并替换上述两行,重建服务使原登录失效。
### 3. 启动并检查容器
```sh
docker compose up -d
docker compose ps
curl --fail --max-time 10 http://127.0.0.1:18081/healthz
curl --fail --max-time 10 http://127.0.0.1:18081/v1/updates/android
```
健康检查返回 `{"status":"ok"}`;首次没有发布记录,公开接口返回:
```json
{"schema_version":1,"application_id":"com.tpschedule.app","latest":null}
```
`.env`、Compose项目名`tpschedule-updates`及`update-data`数据卷要长期保留;正常重建不会清空版本。不要执行带 `-v` 的删除数据卷命令。服务配置错误时检查 `docker compose logs --tail 30 updates`,分享错误前不要附带.env或秘密值。
### 4. 配置现有OpenResty站点
1Panel“网站”→`app.glowtps.cn`→配置文件,在现有 `server { … }` 内添加 `openresty-safeline.conf.example` 中的三个 `location`。不要把三个location放到已有location内部,不要另建相同域名的server;若已有相同路径请合并替换,不重复定义。
这三个路径转发至 `http://127.0.0.1:18081`,原 `/v1/feedback` 仍转发至18080。验证配置并重载OpenResty。无需改变10080/12443监听、雷池回源地址和TLS证书;内部10080站点仍不强制跳转HTTPS。
雷池保持 `app.glowtps.cn → 127.0.0.1:10080`。如果雷池启用了必须由浏览器完成的人机验证,仅对APP查询路径 `/v1/updates/android` 关闭交互挑战;保留网站其他保护。此API是公开、只读的版本信息,不能要求管理员登录或返回HTML挑战页面。后台 `/update` 保持正常防护,登录和发布使用POST,不能缓存这些路径。
浏览器打开 `https://app.glowtps.cn/update`,应看到蓝色登录页面。后台使用Secure Cookie,必须通过外部HTTPS访问,直接用服务器IP或HTTP测试登录会失败。随后访问公开JSON接口核对转发。
### 5. 发布版本
先把APK放到你现有的公开HTTPS下载位置,例如腾讯云COS或1Panel站点中已经可以下载的目录。本服务只管理版本信息,不存放APK;后台不会代表你访问下载链接。请用手机浏览器确认链接能打开,无登录要求且能下载正确文件。
安装包仍需使用原来的 `com.tpschedule.app` 包名、原签名,并递增构建号。当前本版是 **0.7.6+24**;其中版本号填写 `0.7.6`,版本序号(构建号)填写 `24`。下一次发布的确切数字以交付时提供的版本为准,不要仅修改后台数字而不重新构建APK。
登录后台填写:版本号、构建号、更新说明、公开HTTPS下载链接,点“保存并发布”。第一次可发布0.7.6/24,使公开信息与当前交付一致;已安装本版的用户不会收到同版更新提示。以后发布比24大的构建号时,已接入更新模块的APP启动或手动检查就会弹出更新说明。
同一版本号、同一构建号允许修改说明和下载链接;新增安装包应增加构建号。后台禁止降低构建号和版本号,并检测另一个页面修改,避免旧表单误覆盖。保存失败时原版本保留。新版本号应遵循 `数字.数字.数字`,不带V或+构建号。
更新提示是启动/手动联网检查,并非APP关闭后的系统通知,也不静默安装。用户点击立即更新后通过浏览器下载并按Android提示安装,APP不申请“安装未知应用”权限。需要允许浏览器安装来源时由Android和浏览器处理。
### 6. 第一次如何让同学接入
0.7.5及之前的APP没有检查更新代码,服务器无法给它们凭空补上。第一次仍需手动分发、覆盖安装0.7.6;之后这些用户才能收到你在后台发布的后续版本。**不要卸载旧版**,原包名、签名一致时覆盖安装保留本机课表。
## 维护和备份
正常更新服务代码:保留.env和数据卷,在原目录执行 `docker compose up -d --build`。更新后台密码和版本文件不要放进Git或聊天;备份现有数据卷时保持原Compose归属。磁盘或JSON损坏会返回503,不会伪装成没有更新或自动清空。后台会话8小时有效,登录有尝试次数限制;退出清除当前浏览器的登录Cookie。
## API约定
已发布时返回200及以下JSON,响应禁止缓存。APP只在build_number大于自身构建号时提示,版本文本用于显示;跨应用、格式错误、HTTP错误、重定向或超过64KiB的响应都不会被当作更新成功。
```json
{"schema_version":1,"application_id":"com.tpschedule.app","latest":{"version":"0.7.6","build_number":24,"notes":"新增启动与手动检查更新。","download_url":"https://example.com/TPSchedule-0.7.6.apk","published_at":"2026-10-24T00:00:00+00:00"}}
```
未部署/断网时自动检查不提示错误,手动检查显示失败原因;不影响已有课表或登录。下载链接只能用HTTPS,不能包含用户名/密码。发布信息中的更新说明在后台转义显示,APP按纯文本呈现。
+33
View File
@@ -0,0 +1,33 @@
name: tpschedule-updates
services:
updates:
build: .
restart: unless-stopped
env_file:
- .env
ports:
- "127.0.0.1:18081:8000"
volumes:
- update-data:/app/data
read_only: true
tmpfs:
- /tmp:size=32m,mode=1777
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
mem_limit: 256m
cpus: 0.5
pids_limit: 64
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/healthz', timeout=3).read()"]
interval: 30s
timeout: 5s
retries: 3
logging:
driver: json-file
options:
max-size: "5m"
max-file: "2"
volumes:
update-data:
+3
View File
@@ -0,0 +1,3 @@
from update_service import create_app
app = create_app()
+17
View File
@@ -0,0 +1,17 @@
"""Run interactively on your server; do not send its output to chat."""
import getpass
import secrets
from update_service import hash_password
if __name__ == '__main__':
password = getpass.getpass('设置更新后台密码(至少12位): ')
if password != getpass.getpass('再次输入: '):
raise SystemExit('两次输入不一致,未生成配置。')
try:
password_hash = hash_password(password)
except ValueError as error:
raise SystemExit(str(error)) from None
print('仅在服务器填写下面两行到.env,保留单引号,不要发到聊天:')
print(f"ADMIN_PASSWORD_HASH='{password_hash}'")
print(f"SESSION_SECRET='{secrets.token_hex(32)}'")
@@ -0,0 +1,38 @@
# Add inside the EXISTING app.glowtps.cn HTTP server listening on 10080.
# SafeLine continues to terminate public HTTPS on 443. No new public port.
# Reuse the site's existing real-IP configuration; do not duplicate it here.
location = /v1/updates/android {
proxy_pass http://127.0.0.1:18081;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto https;
proxy_connect_timeout 5s;
proxy_read_timeout 15s;
proxy_cache off;
access_log off;
}
location = /update {
proxy_pass http://127.0.0.1:18081;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto https;
proxy_connect_timeout 5s;
proxy_read_timeout 30s;
proxy_cache off;
access_log off;
}
location ^~ /update/ {
client_max_body_size 96k;
client_body_timeout 15s;
proxy_pass http://127.0.0.1:18081;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto https;
proxy_connect_timeout 5s;
proxy_read_timeout 30s;
proxy_cache off;
access_log off;
}
+15
View File
@@ -0,0 +1,15 @@
fastapi==0.143.0
starlette==1.8.0
uvicorn==0.54.0
python-multipart==0.0.32
anyio==4.15.1
pydantic==2.14.1
pydantic-core==2.50.1
typing-extensions==4.16.0
typing-inspection==0.4.4
annotated-doc==0.0.5
annotated-types==0.8.0
opentelemetry-api==1.45.1
click==8.5.0
h11==0.16.0
idna==3.20
+155
View File
@@ -0,0 +1,155 @@
import re
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from update_service import Settings, create_app, hash_password
BASE = 'https://app.glowtps.cn'
PASSWORD = 'only-for-tests-password'
@pytest.fixture
def service(tmp_path):
settings = Settings(data_dir=str(tmp_path), public_origin=BASE,
admin_username='admin', admin_password_hash=hash_password(PASSWORD),
session_secret='test-session-secret-at-least-32-characters')
with TestClient(create_app(settings), base_url=BASE) as client:
yield client, settings
def csrf(response):
return re.search(r'name="csrf" value="([a-f0-9]+)"', response.text)[1]
def login(client):
page = client.get('/update')
return client.post('/update/login', data={'csrf': csrf(page), 'username': 'admin', 'password': PASSWORD},
headers={'Origin': BASE}, follow_redirects=False)
def publish(client, **changes):
page = client.get('/update')
fields = {'csrf': csrf(page), 'version': '0.8.0', 'build_number': '25',
'notes': '新增自动检查更新\n修复课表导入', 'download_url': BASE + '/downloads/TPSchedule.apk',
'revision': re.search(r'name="revision" value="([a-f0-9]+|none)"', page.text)[1]}
fields.update(changes)
return client.post('/update/publish', data=fields, headers={'Origin': BASE}, follow_redirects=False)
def test_private_admin_and_initial_public_empty_manifest(service):
client, _ = service
page = client.get('/update')
assert '登录更新后台' in page.text
assert 'name="download_url"' not in page.text
assert 'HttpOnly' in page.headers['set-cookie'] and 'Secure' in page.headers['set-cookie']
assert client.get('/v1/updates/android').json() == {'schema_version': 1, 'application_id': 'com.tpschedule.app', 'latest': None}
assert client.post('/update/publish', data={}).status_code == 401
def test_publish_persists_and_app_reads_exact_public_fields(service):
client, settings = service
assert login(client).status_code == 303
assert publish(client).status_code == 303
result = client.get('/v1/updates/android')
release = result.json()['latest']
assert release['version'] == '0.8.0' and release['build_number'] == 25
assert release['notes'] == '新增自动检查更新\n修复课表导入'
assert release['download_url'] == BASE + '/downloads/TPSchedule.apk'
assert 'password' not in result.text and 'session_secret' not in result.text
assert result.headers['cache-control'] == 'no-store'
with TestClient(create_app(settings), base_url=BASE) as restarted:
assert restarted.get('/v1/updates/android').json() == result.json()
@pytest.mark.parametrize('changes', [
{'download_url': 'javascript:alert(1)'}, {'download_url': 'http://example.com/a.apk'},
{'download_url': 'https://user:password@example.com/a.apk'},
{'notes': ''}, {'version': 'V1.0'}, {'build_number': '0'}, {'build_number': '2147483648'},
{'notes': 'x' * 8001},
{'version': '1.0.0'},
])
def test_bad_publish_does_not_replace_release(service, changes):
client, _ = service
login(client)
publish(client)
previous = client.get('/v1/updates/android').json()
assert publish(client, **changes).status_code == 422
assert client.get('/v1/updates/android').json() == previous
def test_validation_failure_retains_typed_release_notes(service):
client, _ = service
login(client)
response = publish(client, notes='尚未发布的完整说明', download_url='http://bad.test/app.apk')
assert response.status_code == 422
assert '尚未发布的完整说明' in response.text
assert 'http://bad.test/app.apk' in response.text
def test_csrf_and_cross_origin_login_publish_logout_are_rejected(service):
client, _ = service
page = client.get('/update')
assert client.post('/update/login', data={'username': 'admin', 'password': PASSWORD, 'csrf': csrf(page)},
headers={'Origin': 'https://evil.test'}).status_code == 403
assert login(client).status_code == 303
assert publish(client, csrf='bad').status_code == 403
assert publish(client, csrf='无效').status_code == 403
assert publish(client, revision='无效').status_code == 422
assert client.post('/update/logout', data={'csrf': 'bad'}, headers={'Origin': BASE}).status_code == 403
def test_login_rejects_unicode_username_and_limits_password_guesses(service):
client, _ = service
page = client.get('/update')
response = client.post('/update/login', data={'csrf': csrf(page), 'username': '管理员', 'password': PASSWORD}, headers={'Origin': BASE})
assert response.status_code == 401
for _ in range(9):
page = client.get('/update')
response = client.post('/update/login', data={'csrf': csrf(page), 'username': 'admin', 'password': 'wrong'}, headers={'Origin': BASE})
assert response.status_code == 401
assert login(client).status_code == 429
def test_tampered_cookie_cannot_publish_and_oversized_body_is_rejected(service):
client, _ = service
login(client)
cookie = client.cookies.get('tpschedule_update_session')
client.cookies.clear()
client.cookies.set('tpschedule_update_session', cookie + 'tampered', path='/update')
assert client.post('/update/publish', data={}).status_code == 401
assert client.post('/update/login', content=b'x' * (96 * 1024 + 1)).status_code == 413
def test_monotonic_build_and_stale_form_do_not_overwrite_published_version(service):
client, _ = service
login(client)
assert publish(client).status_code == 303
assert publish(client, version='0.7.9', build_number='24').status_code == 422
assert publish(client, version='0.8.1', build_number='25').status_code == 422
assert publish(client, version='0.8.1', build_number='26', revision='none').status_code == 409
assert publish(client, version='0.8.1', build_number='26').status_code == 303
def test_release_notes_are_escaped_in_admin_and_login_does_not_echo_password(service):
client, _ = service
page = client.get('/update')
bad = client.post('/update/login', data={'csrf': csrf(page), 'username': 'admin', 'password': 'wrong-unique-password'}, headers={'Origin': BASE})
assert bad.status_code == 401 and 'wrong-unique-password' not in bad.text
login(client)
publish(client, notes='<script>alert(1)</script>')
page = client.get('/update')
assert '<script>alert(1)</script>' not in page.text
assert '&lt;script&gt;' in page.text
def test_logout_invalidates_browser_session_and_corrupt_file_is_not_no_update(service):
client, settings = service
login(client)
page = client.get('/update')
assert client.post('/update/logout', data={'csrf': csrf(page)}, headers={'Origin': BASE}, follow_redirects=False).status_code == 303
assert '登录更新后台' in client.get('/update').text
Path(settings.data_dir, 'android-release.json').write_text('broken', encoding='utf-8')
assert client.get('/v1/updates/android').status_code == 503
+398
View File
@@ -0,0 +1,398 @@
"""Standalone update manifest and owner-only publishing panel. No APK upload or SMTP."""
import asyncio
import base64
import hashlib
import hmac
import html
import json
import os
import re
import secrets
import tempfile
import threading
import time
from collections import deque
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from urllib.parse import urlsplit
from fastapi import FastAPI, Request
from starlette.concurrency import run_in_threadpool
from starlette.responses import HTMLResponse, JSONResponse, RedirectResponse
COOKIE = 'tpschedule_update_session'
APP_ID = 'com.tpschedule.app'
BODY_LIMIT = 96 * 1024
def valid_https(value):
if not isinstance(value, str):
return False
try:
uri = urlsplit(value)
return (isinstance(value, str) and len(value) <= 2048 and
not re.search(r'[\s\x00-\x1f\x7f]', value) and uri.scheme == 'https'
and bool(uri.hostname) and uri.username is None and uri.password is None
and uri.port != 0)
except (ValueError, TypeError):
return False
def hash_password(password):
if not isinstance(password, str) or len(password) < 12 or len(password) > 256:
raise ValueError('Use a password between 12 and 256 characters.')
salt = secrets.token_hex(16)
digest = hashlib.scrypt(password.encode(), salt=bytes.fromhex(salt), n=16384, r=8, p=1, dklen=32)
return f'scrypt$16384$8$1${salt}${digest.hex()}'
def verify_password(password, stored):
if not isinstance(password, str) or len(password) > 256:
return False
try:
kind, n, r, p, salt, expected = stored.split('$')
if (kind, n, r, p) != ('scrypt', '16384', '8', '1'):
return False
actual = hashlib.scrypt(password.encode(), salt=bytes.fromhex(salt), n=16384, r=8, p=1, dklen=32)
return hmac.compare_digest(actual.hex(), expected)
except (ValueError, TypeError):
return False
@dataclass
class Settings:
data_dir: str = '/app/data'
public_origin: str = 'https://app.glowtps.cn'
admin_username: str = 'admin'
admin_password_hash: str = field(default='', repr=False)
session_secret: str = field(default='', repr=False)
@classmethod
def from_env(cls):
return cls(data_dir=os.getenv('DATA_DIR', '/app/data'),
public_origin=os.getenv('PUBLIC_ORIGIN', 'https://app.glowtps.cn'),
admin_username=os.getenv('ADMIN_USERNAME', 'admin'),
admin_password_hash=os.getenv('ADMIN_PASSWORD_HASH', ''),
session_secret=os.getenv('SESSION_SECRET', ''))
def validate(self):
uri = urlsplit(self.public_origin)
if not valid_https(self.public_origin) or uri.path or uri.query or uri.fragment:
raise ValueError('PUBLIC_ORIGIN must be an HTTPS origin without a path.')
if not re.fullmatch(r'[A-Za-z0-9_.-]{1,64}', self.admin_username):
raise ValueError('ADMIN_USERNAME must use 1-64 letters, numbers, dots, underscores or hyphens.')
if not re.fullmatch(r'scrypt\$16384\$8\$1\$[0-9a-f]{32}\$[0-9a-f]{64}', self.admin_password_hash):
raise ValueError('Configure ADMIN_PASSWORD_HASH using python manage.py before starting.')
if len(self.session_secret.encode()) < 32 or self.session_secret.startswith('REPLACE_'):
raise ValueError('SESSION_SECRET must be a persistent random secret of at least 32 bytes.')
class UpdateError(Exception):
def __init__(self, status, message):
self.status, self.message = status, message
def validate_release(raw):
if not isinstance(raw, dict):
raise UpdateError(422, '版本信息无效。')
version, code, notes, link = (raw.get(k) for k in ('version', 'build_number', 'notes', 'download_url'))
if (not isinstance(version, str) or not re.fullmatch(r'[0-9]{1,4}\.[0-9]{1,4}\.[0-9]{1,4}', version)
or type(code) is not int or not 1 <= code <= 2100000000
or not isinstance(notes, str) or not notes.strip() or len(notes) > 8000
or '\x00' in notes or not valid_https(link)):
raise UpdateError(422, '请填写有效版本(如0.7.6)、构建号(正整数)、更新说明(最多8000字)和公开HTTPS下载链接。')
return {'version': version, 'build_number': code, 'notes': notes.strip(), 'download_url': link}
class ReleaseStore:
def __init__(self, directory):
self.path = Path(directory) / 'android-release.json'
self.path.parent.mkdir(parents=True, exist_ok=True)
self.lock = threading.Lock()
def read(self):
try:
with self.path.open('rb') as stream:
data = stream.read(64 * 1024 + 1)
except FileNotFoundError:
return None, 'none'
except OSError:
raise UpdateError(503, '版本存储暂不可用,请检查数据卷。') from None
try:
if len(data) > 64 * 1024:
raise ValueError()
saved = json.loads(data)
release = validate_release(saved)
published = saved['published_at']
if not isinstance(published, str) or len(published) > 40:
raise ValueError()
release['published_at'] = published
return release, hashlib.sha256(data).hexdigest()
except (ValueError, TypeError, KeyError, UpdateError):
raise UpdateError(503, '版本文件无效,请先恢复数据卷备份;未覆盖原文件。') from None
def publish(self, raw, revision):
release = validate_release(raw)
if not re.fullmatch(r'[a-f0-9]{64}|none', revision):
raise UpdateError(422, '版本页面标识无效,请重新打开后台。')
with self.lock:
previous, current_revision = self.read()
if not hmac.compare_digest(current_revision, revision):
raise UpdateError(409, '版本信息已在其他页面修改,请重新打开后台后再提交。')
if previous:
old_code, new_code = previous['build_number'], release['build_number']
version_key = lambda text: tuple(map(int, text.split('.')))
if (new_code < old_code or version_key(release['version']) < version_key(previous['version'])
or new_code == old_code and release['version'] != previous['version']):
raise UpdateError(422, '新版本构建号必须增加,版本号不可倒退;相同版本和构建号允许修改说明、下载链接。')
release['published_at'] = datetime.now(timezone.utc).isoformat()
data = json.dumps(release, ensure_ascii=False, indent=2).encode('utf-8')
temporary = None
try:
with tempfile.NamedTemporaryFile(dir=self.path.parent, prefix='.release-', delete=False) as stream:
temporary = stream.name
stream.write(data)
stream.flush()
os.fsync(stream.fileno())
os.replace(temporary, self.path)
except OSError:
raise UpdateError(503, '保存失败,原版本仍保留;请检查数据卷权限和磁盘空间。') from None
finally:
if temporary and os.path.exists(temporary):
os.unlink(temporary)
class Sessions:
def __init__(self, settings):
self.secret = settings.session_secret.encode()
def sign(self, value):
return hmac.new(self.secret, value.encode(), hashlib.sha256).hexdigest()
def make(self, authenticated=False):
value = base64.urlsafe_b64encode(json.dumps({
'authenticated': authenticated, 'expires': int(time.time()) + (28800 if authenticated else 3600),
'nonce': secrets.token_hex(16),
}, separators=(',', ':')).encode()).decode()
return value + '.' + self.sign(value)
def read(self, token):
try:
if not token or len(token) > 512:
return None
payload, signature = token.split('.')
if not hmac.compare_digest(self.sign(payload), signature):
return None
value = json.loads(base64.urlsafe_b64decode(payload))
if (type(value['authenticated']) is not bool or type(value['expires']) is not int
or value['expires'] <= time.time() or value['expires'] > time.time() + 28801):
return None
return value
except (ValueError, TypeError, KeyError):
return None
def csrf(self, token):
return self.sign('csrf:' + token)
class LoginLimit:
def __init__(self, secret):
self.secret, self.lock = secret.encode(), threading.Lock()
self.ip, self.global_times = {}, deque()
def check(self, address):
now = time.monotonic()
key = hmac.new(self.secret, address.encode(), hashlib.sha256).digest()
with self.lock:
self.ip = {key: times for key, times in self.ip.items() if times[-1] > now - 900}
while self.global_times and self.global_times[0] <= now - 900:
self.global_times.popleft()
times = self.ip.get(key, deque())
while times and times[0] <= now - 900:
times.popleft()
if len(times) >= 10 or len(self.global_times) >= 100:
raise UpdateError(429, '登录尝试过于频繁,请15分钟后重试。')
times.append(now)
self.global_times.append(now)
self.ip[key] = times
class RequestBounds:
def __init__(self, app):
self.app = app
async def __call__(self, scope, receive, send):
if scope['type'] != 'http':
return await self.app(scope, receive, send)
async def secure_send(message):
if message['type'] == 'http.response.start':
message['headers'] += [
(b'cache-control', b'no-store'), (b'x-content-type-options', b'nosniff'),
(b'referrer-policy', b'no-referrer'), (b'x-frame-options', b'DENY'),
(b'content-security-policy', b"default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'"),
]
await send(message)
if scope['method'] == 'POST' and scope['path'].startswith('/update/'):
body = bytearray()
async def read_body():
while True:
event = await receive()
if event['type'] != 'http.request':
raise UpdateError(400, '请求已中断。')
body.extend(event.get('body', b''))
if len(body) > BODY_LIMIT:
raise UpdateError(413, '提交内容过大。')
if not event.get('more_body'):
break
try:
await asyncio.wait_for(read_body(), timeout=15)
except UpdateError as error:
return await JSONResponse({'error': error.message}, status_code=error.status)(scope, receive, secure_send)
except asyncio.TimeoutError:
return await JSONResponse({'error': '请求超时。'}, status_code=408)(scope, receive, secure_send)
delivered = False
async def bounded_receive():
nonlocal delivered
if delivered:
return await receive()
delivered = True
return {'type': 'http.request', 'body': bytes(body), 'more_body': False}
return await self.app(scope, bounded_receive, secure_send)
return await self.app(scope, receive, secure_send)
def render(settings, csrf, release=None, revision='none', error='', authenticated=False, saved=False):
esc = lambda value: html.escape(str(value), quote=True)
if authenticated:
r = release or {}
form = fr'''<h1>TPSchedule 更新管理</h1><p>先上传签名一致的APK并确认下载链接可用,再发布版本信息。<a href="/update">重新加载</a></p>
<form method="post" action="/update/publish"><input type="hidden" name="csrf" value="{csrf}">
<input type="hidden" name="revision" value="{esc(revision)}">
<label>版本号<input name="version" placeholder="0.7.6" value="{esc(r.get('version', ''))}" required pattern="[0-9]{{1,4}}\.[0-9]{{1,4}}\.[0-9]{{1,4}}"></label>
<label>版本序号(构建号)<input name="build_number" type="number" min="1" max="2100000000" value="{esc(r.get('build_number', ''))}" required></label>
<p class="hint">使用交付包提供的构建号,例如0.7.6+24对应24。每次发布新安装包都应增加,APP据此比较新旧。</p>
<label>更新说明<textarea name="notes" rows="8" maxlength="8000" required>{esc(r.get('notes', ''))}</textarea></label>
<label>公开HTTPS下载链接<input name="download_url" type="url" maxlength="2048" placeholder="https://…/TPSchedule.apk" value="{esc(r.get('download_url', ''))}" required></label>
<p class="hint">填写APK或下载页面链接;本后台不上传APK。发布后用户点击“立即更新”将在浏览器中打开此链接。</p>
<button>保存并发布</button></form>
<form method="post" action="/update/logout"><input type="hidden" name="csrf" value="{csrf}"><button class="secondary">退出后台</button></form>'''
else:
form = f'''<h1>登录更新后台</h1><p>TPSchedule · 仅限管理员</p>
<form method="post" action="/update/login"><input type="hidden" name="csrf" value="{csrf}">
<label>账号<input name="username" autocomplete="username" value="{esc(settings.admin_username)}" maxlength="64" required></label>
<label>密码<input name="password" type="password" autocomplete="current-password" maxlength="256" required></label>
<button>登录</button></form>'''
return f'''<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>TPSchedule 更新后台</title>
<style>body{{margin:0;background:#eff6ff;color:#173453;font:16px/1.6 system-ui,sans-serif}}main{{max-width:640px;margin:40px auto;background:white;padding:28px;border-radius:24px;box-shadow:0 10px 40px #16375c10}}h1{{font-size:25px;margin-top:0}}label{{display:block;margin:20px 0;font-weight:600}}input,textarea{{box-sizing:border-box;width:100%;margin-top:8px;border:1px solid #c3d5ea;border-radius:12px;padding:12px;font:inherit}}button{{background:#007aff;color:white;border:0;border-radius:12px;padding:12px 20px;font:inherit;cursor:pointer}}.secondary{{margin-top:20px;background:#e8f0fa;color:#274e7c}}.hint{{font-size:13px;color:#536e8c}}.error{{background:#fff0ee;color:#9d2c20;padding:12px;border-radius:12px}}.saved{{color:#186641}}@media(max-width:700px){{main{{margin:14px;padding:22px}}}}</style></head><body><main>
{('<p class="error" role="alert">' + esc(error) + '</p>') if error else ''}
{'<p class="saved" role="status">已发布。APP下次启动或手动检查更新时即可读取。</p>' if saved else ''}{form}</main></body></html>'''
def create_app(settings=None):
settings = settings or Settings.from_env()
settings.validate()
store, sessions = ReleaseStore(settings.data_dir), Sessions(settings)
limiter = LoginLimit(settings.session_secret)
password_slots = asyncio.Semaphore(2)
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
app.add_middleware(RequestBounds)
def token_for(request):
token = request.cookies.get(COOKIE, '')
return token, sessions.read(token)
def require_admin(request):
token, value = token_for(request)
if value is None or not value['authenticated']:
raise UpdateError(401, '请先登录更新后台。')
return token
async def fields(request, token):
if request.headers.get('origin') != settings.public_origin:
raise UpdateError(403, '请求来源无效,请从本站更新后台提交。')
if request.headers.get('content-type', '').split(';')[0] != 'application/x-www-form-urlencoded':
raise UpdateError(415, '请使用后台表单提交。')
form = await request.form(max_files=0, max_fields=8, max_part_size=40000)
if any(len(form.getlist(key)) != 1 for key in form.keys()):
raise UpdateError(422, '表单字段重复。')
values = dict(form)
csrf = values.get('csrf', '')
if (not token or sessions.read(token) is None or not re.fullmatch(r'[a-f0-9]{64}', csrf)
or not hmac.compare_digest(sessions.csrf(token), csrf)):
raise UpdateError(403, '页面已过期,请重新打开更新后台。')
return values
def html_response(request, *, error='', status=200):
token, value = token_for(request)
if value is None:
token, value = sessions.make(), {'authenticated': False}
authenticated = value['authenticated']
release, revision = store.read() if authenticated else (None, 'none')
draft = getattr(request.state, 'publish_draft', None)
if authenticated and error and draft is not None:
release = draft
revision = draft.get('revision', revision)
response = HTMLResponse(render(settings, sessions.csrf(token), release, revision, error,
authenticated, request.query_params.get('saved') == '1'), status_code=status)
response.set_cookie(COOKIE, token, secure=True, httponly=True, samesite='strict', path='/update',
max_age=28800 if authenticated else 3600)
return response
@app.exception_handler(UpdateError)
async def error_handler(request, error):
if request.url.path.startswith('/update') and error.status not in {503}:
return html_response(request, error=error.message, status=error.status)
return JSONResponse({'error': error.message}, status_code=error.status)
@app.get('/healthz')
async def health():
return {'status': 'ok'}
@app.get('/v1/updates/android')
async def latest():
release, _ = await run_in_threadpool(store.read)
return {'schema_version': 1, 'application_id': APP_ID, 'latest': release}
@app.get('/update')
@app.get('/update/')
async def panel(request: Request):
return html_response(request)
@app.post('/update/login')
async def login(request: Request):
token, _ = token_for(request)
values = await fields(request, token)
limiter.check(request.client.host if request.client else 'unknown')
async with password_slots:
good_password = await run_in_threadpool(verify_password, values.get('password', ''), settings.admin_password_hash)
if not hmac.compare_digest(values.get('username', '').encode(), settings.admin_username.encode()) or not good_password:
raise UpdateError(401, '账号或密码不正确。')
response = RedirectResponse('/update', status_code=303)
response.set_cookie(COOKIE, sessions.make(True), secure=True, httponly=True,
samesite='strict', path='/update', max_age=28800)
return response
@app.post('/update/publish')
async def publish(request: Request):
token = require_admin(request)
values = await fields(request, token)
request.state.publish_draft = values
try:
code = int(values.get('build_number', ''))
except ValueError:
raise UpdateError(422, '构建号必须是正整数。') from None
raw = {key: values.get(key, '') for key in ('version', 'notes', 'download_url')}
raw['build_number'] = code
await run_in_threadpool(store.publish, raw, values.get('revision', ''))
return RedirectResponse('/update?saved=1', status_code=303)
@app.post('/update/logout')
async def logout(request: Request):
token = require_admin(request)
await fields(request, token)
response = RedirectResponse('/update', status_code=303)
response.delete_cookie(COOKIE, path='/update', secure=True, httponly=True, samesite='strict')
return response
return app