feat: add app update checks and standalone release publishing panel
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
.env
|
||||
.deps
|
||||
__pycache__
|
||||
.pytest_cache
|
||||
data
|
||||
tests
|
||||
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
.env
|
||||
.deps/
|
||||
__pycache__/
|
||||
.pytest_cache/
|
||||
data/
|
||||
@@ -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"]
|
||||
@@ -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按纯文本呈现。
|
||||
@@ -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:
|
||||
@@ -0,0 +1,3 @@
|
||||
from update_service import create_app
|
||||
|
||||
app = create_app()
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
@@ -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 '<script>' 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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user