Files
TPSchedule/server/updates/update_service.py
T

399 lines
20 KiB
Python

"""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