' + esc(error) + '
') if error else ''} {'已发布。APP下次启动或手动检查更新时即可读取。
' if saved else ''}{form}"""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'''
先上传签名一致的APK并确认下载链接可用,再发布版本信息。重新加载
''' else: form = f'''TPSchedule · 仅限管理员
''' return f'''' + esc(error) + '
') if error else ''} {'已发布。APP下次启动或手动检查更新时即可读取。
' if saved else ''}{form}