"""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'), # no-referrer makes native form POSTs send Origin: null. # Keep same-origin form authentication working without # disclosing referrers to other sites. (b'referrer-policy', b'same-origin'), (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'''

TPSchedule 更新管理

先上传签名一致的APK并确认下载链接可用,再发布版本信息。重新加载

使用交付包提供的构建号,例如0.7.6+24对应24。每次发布新安装包都应增加,APP据此比较新旧。

填写APK或下载页面链接;本后台不上传APK。发布后用户点击“立即更新”将在浏览器中打开此链接。

''' else: form = f'''

登录更新后台

TPSchedule · 仅限管理员

''' return f'''TPSchedule 更新后台
{('') if error else ''} {'

已发布。APP下次启动或手动检查更新时即可读取。

' if saved else ''}{form}
''' 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