feat: add app update checks and standalone release publishing panel

This commit is contained in:
Codex
2026-10-24 13:50:42 +08:00
parent 437ed14dbe
commit 75e0b5587d
27 changed files with 1566 additions and 7 deletions
+2 -2
View File
@@ -1,10 +1,10 @@
# TPSchedule # TPSchedule
当前Android版本0.7.5+23:兼容本科考试考场/座位未发布时的合并单元格,并提供可选本科导入HTML反馈附件。课表缺失字段问题增加具体诊断,仍需新的页面证据定位。详见[0.7.5验证记录](docs/tpschedule-0.7.5-validation.md)。 当前Android版本0.7.6+24:新增启动和手动检查更新、更新说明弹窗与浏览器下载,附带app.glowtps.cn/update独立发布后台。详见[更新功能记录](docs/tpschedule-0.7.6-updates.md)及[1Panel/雷池部署说明](server/updates/README.md)。
0.7.0补充:考试页查询类型与卡片保持16像素间距;反馈问题和建议可自选附带最近10分钟的登录/读取/提醒诊断日志。默认不发送,不含密码、Cookie或课表内容,单独作为邮件文本附件。服务端需按[日志附件更新说明](server/feedback/diagnostic-log-update.md)手动更新;真实学校登录及SMTP未由本次离线测试代替验证。玻璃取样偏移仍在调查,未纳入本次修复结论。 0.7.0补充:考试页查询类型与卡片保持16像素间距;反馈问题和建议可自选附带最近10分钟的登录/读取/提醒诊断日志。默认不发送,不含密码、Cookie或课表内容,单独作为邮件文本附件。服务端需按[日志附件更新说明](server/feedback/diagnostic-log-update.md)手动更新;真实学校登录及SMTP未由本次离线测试代替验证。玻璃取样偏移仍在调查,未纳入本次修复结论。
学生课表 APP,Flutter 实现,支持电子科技大学研究生和本科生系统。Android 0.7.5+23,Android 7.0 及以上。 学生课表 APP,Flutter 实现,支持电子科技大学研究生和本科生系统。Android 0.7.6+24,Android 7.0 及以上。
使用用户提供的原始 SVG 图标,蓝色渐变、黄色日历框及白色笔画保持一致;启动画面使用相同配色。0.5.1修复重复启动页闪烁:初始化期间保留原生启动屏,数据就绪后直接显示课表首帧,不再叠加Flutter品牌页或淡出动画。Android12+ 原生启动屏使用同色纯蓝背景及图标,旧版采用蓝色渐变。 使用用户提供的原始 SVG 图标,蓝色渐变、黄色日历框及白色笔画保持一致;启动画面使用相同配色。0.5.1修复重复启动页闪烁:初始化期间保留原生启动屏,数据就绪后直接显示课表首帧,不再叠加Flutter品牌页或淡出动画。Android12+ 原生启动屏使用同色纯蓝背景及图标,旧版采用蓝色渐变。
+1 -1
View File
@@ -1,6 +1,6 @@
# Android 首版验证记录 # Android 首版验证记录
当前版本为0.7.5+23,兼容本科考试未发布考场,并新增可选HTML反馈附件,验证见 [0.7.5记录](tpschedule-0.7.5-validation.md)。两例真实课表缺失字段问题待新页面定位。本科接入记录见 [0.7.0记录](tpschedule-0.7.0-validation.md)。下文为首版历史记录。 当前版本为0.7.6+24,应用更新检查与后台说明见[0.7.6记录](tpschedule-0.7.6-updates.md)。本科考试兼容和HTML反馈的验证见[0.7.5记录](tpschedule-0.7.5-validation.md)。两例真实课表缺失字段问题待新页面定位。本科接入记录见[0.7.0记录](tpschedule-0.7.0-validation.md)。下文为首版历史记录。
日期:2026-10-13。测试环境:Flutter 3.47.7 / Dart 3.13.5 / JDK 17;Android 16 API 36,Pixel 7 模拟器,1080×2400。首版版本 0.1.0+1,包 ID com.example.hepan_everyday,最低 Android 7.0(API 24)。 日期:2026-10-13。测试环境:Flutter 3.47.7 / Dart 3.13.5 / JDK 17;Android 16 API 36,Pixel 7 模拟器,1080×2400。首版版本 0.1.0+1,包 ID com.example.hepan_everyday,最低 Android 7.0(API 24)。
+41
View File
@@ -0,0 +1,41 @@
# TPSchedule 0.7.6+24:应用更新检查与发布后台
日期:2026-10-24。用户要求启动或手动检查服务器版本,提示更新说明并在浏览器下载;管理员通过app.glowtps.cn/update维护版本号、说明及链接。服务器已有Ubuntu/1Panel/雷池,反馈服务更新暂缓,因此新服务独立部署,不修改现有反馈功能或SMTP配置。本次没有登录服务器或执行公网部署。
## APP行为
Android首次显示首页后等待2秒再发起一次检查,最长约13秒;检查与启动初始化分离,不延迟本地课表显示。自动检查网络失败、服务器尚未部署或没有新版本时不弹错误。个人页面增加“检查更新”,查询期间显示进度、避免重复请求;手动查询明确提示最新或失败。
查询默认HTTPS地址https://app.glowtps.cn/v1/updates/android,校验schema_version=1和准确application_id,仅将大于当前构建号的发布记录视为新版本。读取容量64KiB,拒绝重定向、错误状态、HTML挑战页面、其他应用、无效版本和不安全下载地址。无需传递学校Cookie、用户身份、课表或设备标识。
发现新版显示版本与纯文本更新说明,支持稍后处理;立即更新通过安卓ACTION_VIEW打开公开HTTPS链接。浏览器不存在或打开失败时提供可复制链接。没有添加APK静默安装、安装来源权限或后台通知SDK。更新弹窗不覆盖当前其他路由的操作页面,手动入口始终可重新检查。较长说明可在小屏上滚动,操作按钮固定可用。
新增安卓MethodChannel只接受HTTPS、不含账户密码的链接,不接入学校WebView。包名仍为com.tpschedule.app,签名配置不变。APK实际构建与证书校验记录在交付目录说明。
## 更新服务器
独立FastAPI服务,Compose项目tpschedule-updates,本机127.0.0.1:18081→容器8000,使用自己的update-data持久卷。原反馈服务18080和.env、SMTP、登记文件不变。后台密码以scrypt哈希保存在服务器.env,服务器命令manage.py交互生成哈希和随机会话密钥;这些值不放入APP和交付源码。
后台使用安全Cookie、8小时会话、CSRF和准确Origin校验,登录限制每地址10次/15分钟及总体100次/15分钟,密码计算最多2个并发。请求体96KiB上限。页面输出转义、响应禁止缓存,表单限制在同一站点,禁用脚本和嵌入。管理员可编辑版本、构建号、说明、公开HTTPS链接;不上传APK、不在服务器访问该链接。
JSON文件通过同目录临时文件、fsync和原子替换写入。构建号和版本不能倒退,同一版本/构建允许改说明及链接;隐藏的修订号阻止旧页面覆盖其他发布。校验失败保留输入,旧发布记录不改;损坏存储返回503,不伪装成无更新。公开API不暴露管理员配置。
管理员页面实际模板已渲染并通过无界面Edge查看排版,预览只含合成版本和example.com链接,不代表已经部署。详细部署及日常发布操作见[服务器说明](../server/updates/README.md),包含适用于原雷池→OpenResty10080链路的三个location。
## 验证与限制
APP回归覆盖版本比较、并发去重、空发布、错误响应/应用标识/URL/容量、浏览器桥接参数、启动后检查、手动错误与最新提示、稍后、浏览器失败链接回退、销毁取消启动计时器,以及小屏长说明。
服务端18项测试覆盖首次未发布、匿名后台、登录、发布与重启持久化、字段限制、CSRF/Origin、Unicode恶意输入、错误密码限速、伪造Cookie、过大请求、构建号单调、旧表单冲突、说明转义、失败保留输入、退出及文件损坏。原反馈服务66项回归通过,合计84项。
Flutter静态分析无问题,完整套件291项通过。未新增APP依赖,沿用现有Flutter/Dart工具链及服务端已验证版本。没有验证公网代理、真实下载链接、管理员实际密码或生产容器构建;这些须按部署说明由用户操作后确认。
0.7.5及之前不含更新模块,第一轮仍需手动覆盖安装0.7.6。今后发布更高构建号后,已接入模块的APP才能获得提示;这不是关闭APP后的远程系统通知。后台版本信息必须对应真正构建并上传的同签名APK,不能只改数字而链接旧包。
## Android交付验证
正式版与调试版均构建通过,applicationId=com.tpschedule.app、versionName=0.7.6、versionCode=24,最低API24、目标API36。调试构建首次遇到本地Gradle不可变转换缓存校验失败;停止守护进程并将受影响缓存隔离到本工程.tools后重建成功,未修改源码、依赖或签名。
正式APK60270499字节,SHA256为0752E5E338B648611B7740926EFDECF7DCE15D5ABBB0E2BDBE90ACEF9AE1D542。apksigner验证通过,与0.7.5证书逐字节相同,证书SHA256仍为AF5C21B1BA1E48E461D8EA38DF0FE1C3653F64EB7E8E7728BAE6E5D988BA0D0A。私钥与登记冻结文件在打包阶段另做完整性校验。
API36专用模拟器安装正式包成功,软件渲染测试模式下冷启动Status=ok、LaunchState=COLD、TotalTime=3107ms,首页截图正常、进程持续存在且无AndroidRuntime启动异常。实际手机仍使用原默认渲染方式;模拟器数据不代表手机启动耗时。测试后已关闭本任务创建的emulator-5556。
+3 -1
View File
@@ -1,6 +1,6 @@
# TPSchedule Flutter工程 # TPSchedule Flutter工程
当前版本0.7.5+23,安卓包名 `com.tpschedule.app`。Dart包名保留 `hepan_everyday`,兼容既有代码引用。 当前版本0.7.6+24,安卓包名 `com.tpschedule.app`。Dart包名保留 `hepan_everyday`,兼容既有代码引用。
0.7.5兼容本科考试考场和座位合并为尚未发布的记录;新增可选HTML邮件附件及具体课表缺失字段诊断。HTML协议需要同步更新反馈服务器,见 `../server/feedback/html-diagnostic-update.md`。真实账号课表缺失字段根因尚待排查,见 `../docs/tpschedule-0.7.5-validation.md`。 0.7.5兼容本科考试考场和座位合并为尚未发布的记录;新增可选HTML邮件附件及具体课表缺失字段诊断。HTML协议需要同步更新反馈服务器,见 `../server/feedback/html-diagnostic-update.md`。真实账号课表缺失字段根因尚待排查,见 `../docs/tpschedule-0.7.5-validation.md`。
@@ -17,3 +17,5 @@
本科连接模块在 `lib/features/school/undergraduate/`,学校标识在 `lib/domain/school_identity.dart`。HTML解析不执行网页脚本,样例已脱敏;真实账号、验证码及在线SSO需本人在学校网页验证。 本科连接模块在 `lib/features/school/undergraduate/`,学校标识在 `lib/domain/school_identity.dart`。HTML解析不执行网页脚本,样例已脱敏;真实账号、验证码及在线SSO需本人在学校网页验证。
不要删除、重建或分发根目录 `.tools/android-user/debug.keystore`;0.5.4登记冻结和V1.0材料不得用当前源码重生成。 不要删除、重建或分发根目录 `.tools/android-user/debug.keystore`;0.5.4登记冻结和V1.0材料不得用当前源码重生成。
0.7.6新增启动后检查更新及“我的→检查更新”,从HTTPS版本接口读取构建号与说明,点击立即更新打开浏览器。更新后台部署说明位于 `../server/updates/README.md`;初次需手动覆盖安装这一版,后续才可接收更新提示。
@@ -9,6 +9,7 @@ class MainActivity : FlutterActivity() {
private var calendar: CalendarBridge? = null private var calendar: CalendarBridge? = null
private var reminders: ReminderSystemBridge? = null private var reminders: ReminderSystemBridge? = null
private var schoolDocuments: SchoolDocumentBridge? = null private var schoolDocuments: SchoolDocumentBridge? = null
private var updateBrowser: UpdateBrowserBridge? = null
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
@@ -23,10 +24,14 @@ class MainActivity : FlutterActivity() {
reminders = ReminderSystemBridge(this, flutterEngine.dartExecutor.binaryMessenger) reminders = ReminderSystemBridge(this, flutterEngine.dartExecutor.binaryMessenger)
schoolDocuments?.close() schoolDocuments?.close()
schoolDocuments = SchoolDocumentBridge(flutterEngine) schoolDocuments = SchoolDocumentBridge(flutterEngine)
updateBrowser?.close()
updateBrowser = UpdateBrowserBridge(this, flutterEngine.dartExecutor.binaryMessenger)
} }
override fun cleanUpFlutterEngine(flutterEngine: FlutterEngine) { override fun cleanUpFlutterEngine(flutterEngine: FlutterEngine) {
schoolDocuments?.close() schoolDocuments?.close()
schoolDocuments = null schoolDocuments = null
updateBrowser?.close()
updateBrowser = null
super.cleanUpFlutterEngine(flutterEngine) super.cleanUpFlutterEngine(flutterEngine)
} }
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) { override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
@@ -37,6 +42,7 @@ class MainActivity : FlutterActivity() {
calendar?.close() calendar?.close()
reminders?.close() reminders?.close()
schoolDocuments?.close() schoolDocuments?.close()
updateBrowser?.close()
super.onDestroy() super.onDestroy()
} }
} }
@@ -0,0 +1,33 @@
package com.tpschedule.app
import android.app.Activity
import android.content.ActivityNotFoundException
import android.content.Intent
import android.net.Uri
import io.flutter.plugin.common.BinaryMessenger
import io.flutter.plugin.common.MethodChannel
class UpdateBrowserBridge(private val activity: Activity, messenger: BinaryMessenger) {
private val channel = MethodChannel(messenger, "com.tpschedule.app/updates")
init {
channel.setMethodCallHandler { call, result ->
if (call.method != "openDownload") {
result.notImplemented()
} else {
val value = call.argument<String>("url")
val uri = value?.let { Uri.parse(it) }
if (value == null || value.length > 2048 || value.any { it.isWhitespace() || it.code < 32 } ||
uri?.scheme != "https" || uri.host.isNullOrBlank() || !uri.userInfo.isNullOrEmpty()) {
result.success(false)
} else {
try {
activity.startActivity(Intent(Intent.ACTION_VIEW, uri).addCategory(Intent.CATEGORY_BROWSABLE))
result.success(true)
} catch (_: ActivityNotFoundException) { result.success(false) }
catch (_: SecurityException) { result.success(false) }
}
}
}
}
fun close() { channel.setMethodCallHandler(null) }
}
+2 -2
View File
@@ -1,2 +1,2 @@
const appVersion = '0.7.5'; const appVersion = '0.7.6';
const appBuildNumber = 23; const appBuildNumber = 24;
+61
View File
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_localizations/flutter_localizations.dart';
@@ -18,6 +20,8 @@ import '../features/school/school_selection_page.dart';
import '../features/school/undergraduate/session.dart'; import '../features/school/undergraduate/session.dart';
import '../features/school/undergraduate/exam_query_picker.dart'; import '../features/school/undergraduate/exam_query_picker.dart';
import '../domain/undergraduate_exam_query.dart'; import '../domain/undergraduate_exam_query.dart';
import '../services/app_update_service.dart';
import '../features/updates/update_prompt.dart';
class HepanApp extends StatelessWidget { class HepanApp extends StatelessWidget {
const HepanApp({ const HepanApp({
@@ -29,6 +33,8 @@ class HepanApp extends StatelessWidget {
this.undergraduateBuilder, this.undergraduateBuilder,
this.notificationSelection, this.notificationSelection,
this.feedbackSender, this.feedbackSender,
this.updateService,
this.openUpdateDownload = openUpdateInBrowser,
}); });
final AppController controller; final AppController controller;
final Widget Function()? schoolBuilder; final Widget Function()? schoolBuilder;
@@ -37,6 +43,8 @@ class HepanApp extends StatelessWidget {
final Widget Function()? undergraduateBuilder; final Widget Function()? undergraduateBuilder;
final ValueNotifier<String?>? notificationSelection; final ValueNotifier<String?>? notificationSelection;
final FeedbackSender? feedbackSender; final FeedbackSender? feedbackSender;
final AppUpdateService? updateService;
final UpdateDownloadOpener openUpdateDownload;
@override @override
Widget build(BuildContext context) => MaterialApp( Widget build(BuildContext context) => MaterialApp(
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
@@ -53,6 +61,8 @@ class HepanApp extends StatelessWidget {
undergraduateBuilder: undergraduateBuilder, undergraduateBuilder: undergraduateBuilder,
notificationSelection: notificationSelection, notificationSelection: notificationSelection,
feedbackSender: feedbackSender ?? configuredFeedbackSender(), feedbackSender: feedbackSender ?? configuredFeedbackSender(),
updateService: updateService,
openUpdateDownload: openUpdateDownload,
), ),
); );
} }
@@ -66,6 +76,8 @@ class _Shell extends StatefulWidget {
this.undergraduateBuilder, this.undergraduateBuilder,
this.notificationSelection, this.notificationSelection,
required this.feedbackSender, required this.feedbackSender,
this.updateService,
required this.openUpdateDownload,
}); });
final AppController controller; final AppController controller;
final Widget Function()? schoolBuilder; final Widget Function()? schoolBuilder;
@@ -74,6 +86,8 @@ class _Shell extends StatefulWidget {
final Widget Function()? undergraduateBuilder; final Widget Function()? undergraduateBuilder;
final ValueNotifier<String?>? notificationSelection; final ValueNotifier<String?>? notificationSelection;
final FeedbackSender feedbackSender; final FeedbackSender feedbackSender;
final AppUpdateService? updateService;
final UpdateDownloadOpener openUpdateDownload;
@override @override
State<_Shell> createState() => _ShellState(); State<_Shell> createState() => _ShellState();
} }
@@ -81,6 +95,40 @@ class _Shell extends StatefulWidget {
class _ShellState extends State<_Shell> with WidgetsBindingObserver { class _ShellState extends State<_Shell> with WidgetsBindingObserver {
int _tab = 0; int _tab = 0;
bool _openingSchool = false; bool _openingSchool = false;
bool _checkingUpdate = false;
bool _updatePromptOpen = false;
Timer? _startupUpdate;
Future<void> _checkUpdate({bool silent = false}) async {
if (_checkingUpdate || _updatePromptOpen || widget.updateService == null) {
return;
}
setState(() => _checkingUpdate = true);
try {
final release = await widget.updateService!.check();
if (mounted) setState(() => _checkingUpdate = false);
if (!mounted || ModalRoute.of(context)?.isCurrent != true) return;
if (release != null) {
_updatePromptOpen = true;
await showUpdatePrompt(
context,
release,
openDownload: widget.openUpdateDownload,
);
} else if (!silent) {
ScaffoldMessenger.of(context)
.showSnackBar(const SnackBar(content: Text('当前已是最新版本')));
}
} on UpdateCheckException catch (error) {
if (!silent && mounted) {
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(error.message)));
}
} finally {
_updatePromptOpen = false;
if (mounted) setState(() => _checkingUpdate = false);
}
}
// Restore the saved school; legacy graduate imports infer their source. // Restore the saved school; legacy graduate imports infer their source.
SupportedSchool? get _knownSchool => SupportedSchool? get _knownSchool =>
widget.controller.snapshot.selectedSchool ?? widget.controller.snapshot.selectedSchool ??
@@ -102,10 +150,19 @@ class _ShellState extends State<_Shell> with WidgetsBindingObserver {
WidgetsBinding.instance.addObserver(this); WidgetsBinding.instance.addObserver(this);
widget.notificationSelection?.addListener(_selectNotification); widget.notificationSelection?.addListener(_selectNotification);
WidgetsBinding.instance.addPostFrameCallback((_) => _selectNotification()); WidgetsBinding.instance.addPostFrameCallback((_) => _selectNotification());
if (widget.updateService != null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
_startupUpdate = Timer(const Duration(seconds: 2), () {
if (mounted) unawaited(_checkUpdate(silent: true));
});
});
}
} }
@override @override
void dispose() { void dispose() {
_startupUpdate?.cancel();
WidgetsBinding.instance.removeObserver(this); WidgetsBinding.instance.removeObserver(this);
widget.notificationSelection?.removeListener(_selectNotification); widget.notificationSelection?.removeListener(_selectNotification);
super.dispose(); super.dispose();
@@ -400,6 +457,10 @@ class _ShellState extends State<_Shell> with WidgetsBindingObserver {
ProfilePage( ProfilePage(
controller: widget.controller, controller: widget.controller,
feedbackSender: widget.feedbackSender, feedbackSender: widget.feedbackSender,
onCheckUpdates: widget.updateService == null
? null
: () => _checkUpdate(),
checkingUpdates: _checkingUpdate,
onLogin: () => _school(selectSchool: true), onLogin: () => _school(selectSchool: true),
schoolName: _knownSchool?.label, schoolName: _knownSchool?.label,
onUpdateTerm: () => _school(chooseTerm: true), onUpdateTerm: () => _school(chooseTerm: true),
@@ -24,6 +24,8 @@ class ProfilePage extends StatelessWidget {
this.logoutLabel = '退出学校登录', this.logoutLabel = '退出学校登录',
this.onUpdateTerm, this.onUpdateTerm,
this.feedbackSender = const UnconfiguredFeedbackSender(), this.feedbackSender = const UnconfiguredFeedbackSender(),
this.onCheckUpdates,
this.checkingUpdates = false,
}); });
final AppController controller; final AppController controller;
final VoidCallback onLogin; final VoidCallback onLogin;
@@ -32,6 +34,8 @@ class ProfilePage extends StatelessWidget {
final VoidCallback? onUpdateTerm; final VoidCallback? onUpdateTerm;
final String authStatus, logoutLabel; final String authStatus, logoutLabel;
final FeedbackSender feedbackSender; final FeedbackSender feedbackSender;
final VoidCallback? onCheckUpdates;
final bool checkingUpdates;
Future<void> _import(BuildContext context) async { Future<void> _import(BuildContext context) async {
try { try {
final file = await FilePicker.pickFile( final file = await FilePicker.pickFile(
@@ -231,6 +235,32 @@ class ProfilePage extends StatelessWidget {
), ),
), ),
), ),
Divider(height: 1, color: ink.withValues(alpha: 0.06)),
ListTile(
key: const Key('check-app-update'),
contentPadding: EdgeInsets.zero,
leading: const Icon(
Icons.system_update_rounded,
color: river,
size: 23,
),
title: const Text(
'检查更新',
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600),
),
subtitle: Text(
checkingUpdates ? '正在检查…' : '当前版本 $appVersion',
style: const TextStyle(fontSize: 11),
),
trailing: checkingUpdates
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.chevron_right_rounded),
onTap: checkingUpdates ? null : onCheckUpdates,
),
], ],
), ),
), ),
@@ -0,0 +1,77 @@
import 'package:flutter/material.dart';
import '../../app/app_version.dart';
import '../../services/app_update_service.dart';
Future<void> showUpdatePrompt(
BuildContext context,
AppRelease release, {
UpdateDownloadOpener openDownload = openUpdateInBrowser,
}) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text('发现新版本 ${release.version}'),
content: SizedBox(
width: 360,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('当前版本 $appVersion', style: TextStyle(fontSize: 12)),
const SizedBox(height: 16),
Text(release.notes, style: const TextStyle(height: 1.6)),
const SizedBox(height: 16),
const Text(
'将打开浏览器下载,安装时请保留现有应用和课表。',
style: TextStyle(fontSize: 12),
),
],
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('稍后'),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('立即更新'),
),
],
),
);
if (confirmed != true || !context.mounted) return;
bool opened;
try {
opened = await openDownload(release.downloadUri);
} catch (_) {
opened = false;
}
if (!opened && context.mounted) {
await showDialog<void>(
context: context,
builder: (context) => AlertDialog(
title: const Text('未能打开浏览器'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text('可以复制下方链接到浏览器下载:'),
const SizedBox(height: 12),
SelectableText(release.downloadUri.toString()),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('知道了'),
),
],
),
);
}
}
+4
View File
@@ -7,6 +7,7 @@ import 'package:timezone/data/latest.dart' as tzdata;
import 'app/app_controller.dart'; import 'app/app_controller.dart';
import 'app/app_version.dart'; import 'app/app_version.dart';
import 'services/diagnostic_log.dart'; import 'services/diagnostic_log.dart';
import 'services/app_update_service.dart';
import 'app/hepan_app.dart'; import 'app/hepan_app.dart';
import 'app/tp_startup.dart'; import 'app/tp_startup.dart';
import 'data/file_snapshot_store.dart'; import 'data/file_snapshot_store.dart';
@@ -22,11 +23,13 @@ Widget buildApplication(
ValueNotifier<String?>? notificationSelection, ValueNotifier<String?>? notificationSelection,
SchoolSession? school, SchoolSession? school,
UndergraduateSession? undergraduate, UndergraduateSession? undergraduate,
AppUpdateService? updateService,
}) => HepanApp( }) => HepanApp(
controller: controller, controller: controller,
notificationSelection: notificationSelection, notificationSelection: notificationSelection,
school: school, school: school,
undergraduate: undergraduate, undergraduate: undergraduate,
updateService: updateService,
undergraduateBuilder: undergraduate == null undergraduateBuilder: undergraduate == null
? null ? null
: () => UndergraduateAuthPage( : () => UndergraduateAuthPage(
@@ -63,6 +66,7 @@ Future<void> main() async {
notificationSelection: selection, notificationSelection: selection,
school: SchoolSession(), school: SchoolSession(),
undergraduate: UndergraduateSession(), undergraduate: UndergraduateSession(),
updateService: AppUpdateService(),
); );
}, },
onHomeReady: () { onHomeReady: () {
+182
View File
@@ -0,0 +1,182 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/services.dart'
show MethodChannel, PlatformException, MissingPluginException;
import '../app/app_version.dart';
class UpdateCheckException implements Exception {
const UpdateCheckException(this.message);
final String message;
}
Uri? updateDownloadUri(String value) {
if (value.length > 2048 || RegExp(r'[\s\x00-\x1f\x7f]').hasMatch(value)) {
return null;
}
final uri = Uri.tryParse(value);
return uri != null &&
uri.scheme == 'https' &&
uri.host.isNotEmpty &&
uri.userInfo.isEmpty
? uri
: null;
}
class AppRelease {
const AppRelease({
required this.version,
required this.buildNumber,
required this.notes,
required this.downloadUri,
});
final String version, notes;
final int buildNumber;
final Uri downloadUri;
}
class UpdateHttpResponse {
const UpdateHttpResponse(this.status, this.body);
final int status;
final List<int> body;
}
abstract interface class UpdateTransport {
Future<UpdateHttpResponse> get(Uri endpoint);
}
class IoUpdateTransport implements UpdateTransport {
const IoUpdateTransport();
@override
Future<UpdateHttpResponse> get(Uri endpoint) async {
final client = HttpClient()..connectionTimeout = const Duration(seconds: 8);
Future<UpdateHttpResponse> fetch() async {
final request = await client.getUrl(endpoint);
request.followRedirects = false;
request.headers.set('Accept', 'application/json');
request.headers.set('Cache-Control', 'no-cache');
final response = await request.close();
final bytes = <int>[];
await for (final chunk in response) {
if (bytes.length + chunk.length > AppUpdateService.maxBytes) {
throw const UpdateCheckException('更新信息过大,请稍后重试');
}
bytes.addAll(chunk);
}
return UpdateHttpResponse(response.statusCode, bytes);
}
try {
return await fetch().timeout(const Duration(seconds: 12));
} finally {
client.close(force: true);
}
}
}
class AppUpdateService {
AppUpdateService({
this.transport = const IoUpdateTransport(),
this.currentBuild = appBuildNumber,
this.endpoint = const String.fromEnvironment(
'UPDATE_ENDPOINT',
defaultValue: 'https://app.glowtps.cn/v1/updates/android',
),
});
static const maxBytes = 64 * 1024;
final UpdateTransport transport;
final int currentBuild;
final String endpoint;
Future<AppRelease?>? _pending;
Future<AppRelease?> check() {
if (_pending != null) return _pending!;
final future = _check();
_pending = future;
// Attach error handling here too, so cleanup never creates an unhandled
// future when startup intentionally suppresses an unavailable server.
unawaited(
future.then<void>(
(_) {
_pending = null;
},
onError: (Object _, StackTrace _) {
_pending = null;
},
),
);
return future;
}
Future<AppRelease?> _check() async {
try {
final uri = updateDownloadUri(endpoint);
if (uri == null || uri.fragment.isNotEmpty) {
throw const UpdateCheckException('更新服务地址无效');
}
final response = await transport
.get(uri)
.timeout(const Duration(seconds: 13));
if (response.status != 200 || response.body.length > maxBytes) {
throw const UpdateCheckException('暂时无法获取更新,请稍后重试');
}
final data = jsonDecode(utf8.decode(response.body));
if (data is! Map ||
data['schema_version'] != 1 ||
data['application_id'] != 'com.tpschedule.app' ||
!data.containsKey('latest')) {
throw const FormatException();
}
final latest = data['latest'];
if (latest == null) return null;
if (latest is! Map) throw const FormatException();
final version = latest['version'],
build = latest['build_number'],
notes = latest['notes'],
url = latest['download_url'];
if (version is! String ||
!RegExp(r'^\d{1,4}\.\d{1,4}\.\d{1,4}$').hasMatch(version) ||
build is! int ||
build < 1 ||
build > 2100000000 ||
notes is! String ||
notes.trim().isEmpty ||
notes.runes.length > 8000 ||
url is! String) {
throw const FormatException();
}
final download = updateDownloadUri(url);
if (download == null) throw const FormatException();
if (build <= currentBuild) return null;
return AppRelease(
version: version,
buildNumber: build,
notes: notes.trim(),
downloadUri: download,
);
} on UpdateCheckException {
rethrow;
} on FormatException {
throw const UpdateCheckException('更新信息格式异常,请稍后重试');
} catch (_) {
throw const UpdateCheckException('无法连接更新服务,请检查网络后重试');
}
}
}
typedef UpdateDownloadOpener = Future<bool> Function(Uri uri);
Future<bool> openUpdateInBrowser(Uri uri) async {
if (updateDownloadUri(uri.toString()) == null) return false;
try {
return await const MethodChannel('com.tpschedule.app/updates')
.invokeMethod<bool>('openDownload', {'url': uri.toString()}) ??
false;
} on PlatformException {
return false;
} on MissingPluginException {
return false;
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
name: hepan_everyday name: hepan_everyday
description: TPSchedule 学生课表 description: TPSchedule 学生课表
publish_to: none publish_to: none
version: 0.7.5+23 version: 0.7.6+24
environment: environment:
sdk: ^3.13.5 sdk: ^3.13.5
dependencies: dependencies:
@@ -0,0 +1,152 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter/services.dart' show MethodCall, MethodChannel;
import 'package:hepan_everyday/services/app_update_service.dart';
class TestUpdateTransport implements UpdateTransport {
int calls = 0;
Uri? endpoint;
Completer<UpdateHttpResponse>? pending;
UpdateHttpResponse response = UpdateHttpResponse(
200,
utf8.encode(
jsonEncode({
'schema_version': 1,
'application_id': 'com.tpschedule.app',
'latest': {
'version': '0.8.0',
'build_number': 25,
'notes': '新增更新检查\n修复导入',
'download_url': 'https://app.glowtps.cn/downloads/TPSchedule.apk',
},
}),
),
);
@override
Future<UpdateHttpResponse> get(Uri endpoint) async {
calls++;
this.endpoint = endpoint;
return pending == null ? response : pending!.future;
}
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
test('browser channel receives only validated HTTPS links', () async {
final calls = <MethodCall>[];
const channel = MethodChannel('com.tpschedule.app/updates');
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, (call) async {
calls.add(call);
return true;
});
addTearDown(
() => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, null),
);
expect(
await openUpdateInBrowser(Uri.parse('javascript:alert(1)')),
isFalse,
);
expect(
await openUpdateInBrowser(
Uri.parse('https://name:password@example.com/app.apk'),
),
isFalse,
);
expect(calls, isEmpty);
expect(
await openUpdateInBrowser(Uri.parse('https://example.com/app.apk')),
isTrue,
);
expect(calls.single.method, 'openDownload');
expect(calls.single.arguments, {'url': 'https://example.com/app.apk'});
});
test(
'build number determines updates and displayed notes and URL survive',
() async {
final transport = TestUpdateTransport();
final client = AppUpdateService(transport: transport, currentBuild: 24);
final release = await client.check();
expect(release!.version, '0.8.0');
expect(release.notes, '新增更新检查\n修复导入');
expect(
release.downloadUri.toString(),
'https://app.glowtps.cn/downloads/TPSchedule.apk',
);
expect(
transport.endpoint.toString(),
'https://app.glowtps.cn/v1/updates/android',
);
expect(
await AppUpdateService(transport: transport, currentBuild: 25).check(),
isNull,
);
expect(
await AppUpdateService(transport: transport, currentBuild: 26).check(),
isNull,
);
},
);
test(
'simultaneous checks share a request and a later check refreshes',
() async {
final transport = TestUpdateTransport()..pending = Completer();
final service = AppUpdateService(transport: transport, currentBuild: 24);
final a = service.check(), b = service.check();
await Future<void>.value();
expect(transport.calls, 1);
transport.pending!.complete(transport.response);
expect((await a)!.buildNumber, 25);
expect((await b)!.buildNumber, 25);
transport.pending = null;
await service.check();
expect(transport.calls, 2);
},
);
test(
'unpublished server and equal versions are an ordinary no-update result',
() async {
final transport = TestUpdateTransport()
..response = UpdateHttpResponse(
200,
utf8.encode(
'{"schema_version":1,"application_id":"com.tpschedule.app","latest":null}',
),
);
expect(await AppUpdateService(transport: transport).check(), isNull);
},
);
test('bad identity, HTML response, unsafe URL and oversized responses fail honestly', () async {
final transport = TestUpdateTransport();
final good = utf8.decode(transport.response.body);
for (final body in [
good.replaceFirst('com.tpschedule.app', 'another.app'),
'<html>WAF challenge</html>',
good.replaceFirst(
'https://app.glowtps.cn/downloads/TPSchedule.apk',
'javascript:alert(1)',
),
good.replaceFirst(
'https://app.glowtps.cn/downloads/TPSchedule.apk',
'https://user:secret@example.com/app.apk',
),
good.replaceFirst('"build_number":25', '"build_number":0'),
good.replaceFirst('"notes":"', '"notes":123,"other":"'),
'x' * (64 * 1024 + 1),
]) {
transport.response = UpdateHttpResponse(200, utf8.encode(body));
await expectLater(
AppUpdateService(transport: transport).check(),
throwsA(isA<UpdateCheckException>()),
);
}
transport.response = UpdateHttpResponse(503, utf8.encode('{}'));
await expectLater(
AppUpdateService(transport: transport).check(),
throwsA(isA<UpdateCheckException>()),
);
});
}
+183
View File
@@ -0,0 +1,183 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:timezone/data/latest.dart' as tzdata;
import 'package:hepan_everyday/app/hepan_app.dart';
import 'package:hepan_everyday/services/app_update_service.dart';
import '../services/app_update_service_test.dart' show TestUpdateTransport;
import 'navigation_test.dart' show uiController;
Future<void> updateTile(WidgetTester tester) async {
await tester.tap(find.bySemanticsLabel('个人设置'));
await tester.pumpAndSettle();
await tester.scrollUntilVisible(
find.byKey(const Key('check-app-update')),
250,
scrollable: find.byType(Scrollable).first,
);
await tester.pumpAndSettle();
}
void main() {
setUpAll(tzdata.initializeTimeZones);
testWidgets(
'startup checks after home and update opens the supplied browser link',
(tester) async {
final controller = uiController();
await controller.initialize();
final transport = TestUpdateTransport();
Uri? opened;
await tester.pumpWidget(
HepanApp(
controller: controller,
updateService: AppUpdateService(
transport: transport,
currentBuild: 24,
),
openUpdateDownload: (uri) async {
opened = uri;
return true;
},
),
);
expect(transport.calls, 0);
expect(find.byType(AlertDialog), findsNothing);
await tester.pump(const Duration(seconds: 2));
await tester.pumpAndSettle();
expect(find.text('发现新版本 0.8.0'), findsOneWidget);
expect(find.text('新增更新检查\n修复导入'), findsOneWidget);
await tester.tap(find.text('立即更新'));
await tester.pumpAndSettle();
expect(
opened.toString(),
'https://app.glowtps.cn/downloads/TPSchedule.apk',
);
expect(transport.calls, 1);
await tester.pumpWidget(const SizedBox());
controller.dispose();
},
);
testWidgets(
'startup failure is quiet while manual checks report failure and latest version',
(tester) async {
final controller = uiController();
await controller.initialize();
final transport = TestUpdateTransport()
..response = const UpdateHttpResponse(503, []);
await tester.pumpWidget(
HepanApp(
controller: controller,
updateService: AppUpdateService(
transport: transport,
currentBuild: 26,
),
),
);
await tester.pump(const Duration(seconds: 2));
await tester.pumpAndSettle();
expect(find.byType(AlertDialog), findsNothing);
expect(find.byType(SnackBar), findsNothing);
await updateTile(tester);
await tester.tap(find.byKey(const Key('check-app-update')));
await tester.pumpAndSettle();
expect(find.textContaining('暂时无法获取更新'), findsOneWidget);
transport.response = TestUpdateTransport().response;
await tester.pump(const Duration(seconds: 5));
await tester.tap(find.byKey(const Key('check-app-update')));
await tester.pumpAndSettle();
expect(find.text('当前已是最新版本'), findsOneWidget);
expect(transport.calls, 3);
await tester.pumpWidget(const SizedBox());
controller.dispose();
},
);
testWidgets(
'later dismisses startup prompt and manual check can show it again',
(tester) async {
final controller = uiController();
await controller.initialize();
var launches = 0;
final transport = TestUpdateTransport();
await tester.pumpWidget(
HepanApp(
controller: controller,
updateService: AppUpdateService(
transport: transport,
currentBuild: 24,
),
openUpdateDownload: (_) async {
launches++;
return false;
},
),
);
await tester.pump(const Duration(seconds: 2));
await tester.pumpAndSettle();
await tester.tap(find.text('稍后'));
await tester.pumpAndSettle();
expect(launches, 0);
await updateTile(tester);
await tester.tap(find.byKey(const Key('check-app-update')));
await tester.pumpAndSettle();
await tester.tap(find.text('立即更新'));
await tester.pumpAndSettle();
expect(find.text('未能打开浏览器'), findsOneWidget);
expect(find.byType(SelectableText), findsOneWidget);
expect(launches, 1);
await tester.pumpWidget(const SizedBox());
controller.dispose();
},
);
testWidgets('disposing before the startup delay cancels checks', (
tester,
) async {
final controller = uiController();
await controller.initialize();
final transport = TestUpdateTransport();
await tester.pumpWidget(
HepanApp(
controller: controller,
updateService: AppUpdateService(transport: transport),
),
);
await tester.pumpWidget(const SizedBox());
await tester.pump(const Duration(seconds: 3));
expect(transport.calls, 0);
controller.dispose();
});
testWidgets('long release notes remain scrollable on a small phone', (
tester,
) async {
tester.view.physicalSize = const Size(360, 640);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final controller = uiController();
await controller.initialize();
final transport = TestUpdateTransport();
final body = jsonDecode(
utf8.decode(transport.response.body),
) as Map<String, dynamic>;
(body['latest'] as Map)['notes'] = List.filled(
300,
'更新功能说明,修复课程显示问题。',
).join('\n');
transport.response = UpdateHttpResponse(200, utf8.encode(jsonEncode(body)));
await tester.pumpWidget(
HepanApp(
controller: controller,
updateService: AppUpdateService(transport: transport, currentBuild: 24),
),
);
await tester.pump(const Duration(seconds: 2));
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
expect(find.text('立即更新'), findsOneWidget);
await tester.tap(find.text('稍后'));
await tester.pumpAndSettle();
await tester.pumpWidget(const SizedBox());
controller.dispose();
});
}
+6
View File
@@ -0,0 +1,6 @@
.env
.deps
__pycache__
.pytest_cache
data
tests
+7
View File
@@ -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
+5
View File
@@ -0,0 +1,5 @@
.env
.deps/
__pycache__/
.pytest_cache/
data/
+11
View File
@@ -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"]
+100
View File
@@ -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按纯文本呈现。
+33
View File
@@ -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:
+3
View File
@@ -0,0 +1,3 @@
from update_service import create_app
app = create_app()
+17
View File
@@ -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;
}
+15
View File
@@ -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
+155
View File
@@ -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 '&lt;script&gt;' 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
+398
View File
@@ -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