402 lines
20 KiB
Python
402 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'),
|
|
# 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'''<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
|