From 75e0b5587d02847e4643bb92009aa2c568d3007c Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 24 Oct 2026 13:50:42 +0800 Subject: [PATCH] feat: add app update checks and standalone release publishing panel --- README.md | 4 +- docs/android-mvp-validation.md | 2 +- docs/tpschedule-0.7.6-updates.md | 41 ++ mobile/README.md | 4 +- .../kotlin/com/tpschedule/app/MainActivity.kt | 6 + .../com/tpschedule/app/UpdateBrowserBridge.kt | 33 ++ mobile/lib/app/app_version.dart | 4 +- mobile/lib/app/hepan_app.dart | 61 +++ mobile/lib/features/profile/profile_page.dart | 30 ++ .../lib/features/updates/update_prompt.dart | 77 ++++ mobile/lib/main.dart | 4 + mobile/lib/services/app_update_service.dart | 182 ++++++++ mobile/pubspec.yaml | 2 +- .../services/app_update_service_test.dart | 152 +++++++ mobile/test/widgets/app_update_test.dart | 183 ++++++++ server/updates/.dockerignore | 6 + server/updates/.env.example | 7 + server/updates/.gitignore | 5 + server/updates/Dockerfile | 11 + server/updates/README.md | 100 +++++ server/updates/docker-compose.yml | 33 ++ server/updates/main.py | 3 + server/updates/manage.py | 17 + .../updates/openresty-safeline.conf.example | 38 ++ server/updates/requirements.txt | 15 + server/updates/tests/test_updates.py | 155 +++++++ server/updates/update_service.py | 398 ++++++++++++++++++ 27 files changed, 1566 insertions(+), 7 deletions(-) create mode 100644 docs/tpschedule-0.7.6-updates.md create mode 100644 mobile/android/app/src/main/kotlin/com/tpschedule/app/UpdateBrowserBridge.kt create mode 100644 mobile/lib/features/updates/update_prompt.dart create mode 100644 mobile/lib/services/app_update_service.dart create mode 100644 mobile/test/services/app_update_service_test.dart create mode 100644 mobile/test/widgets/app_update_test.dart create mode 100644 server/updates/.dockerignore create mode 100644 server/updates/.env.example create mode 100644 server/updates/.gitignore create mode 100644 server/updates/Dockerfile create mode 100644 server/updates/README.md create mode 100644 server/updates/docker-compose.yml create mode 100644 server/updates/main.py create mode 100644 server/updates/manage.py create mode 100644 server/updates/openresty-safeline.conf.example create mode 100644 server/updates/requirements.txt create mode 100644 server/updates/tests/test_updates.py create mode 100644 server/updates/update_service.py diff --git a/README.md b/README.md index f8a523a..185c641 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ # 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未由本次离线测试代替验证。玻璃取样偏移仍在调查,未纳入本次修复结论。 -学生课表 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+ 原生启动屏使用同色纯蓝背景及图标,旧版采用蓝色渐变。 diff --git a/docs/android-mvp-validation.md b/docs/android-mvp-validation.md index 049f6f7..9c3d033 100644 --- a/docs/android-mvp-validation.md +++ b/docs/android-mvp-validation.md @@ -1,6 +1,6 @@ # 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)。 diff --git a/docs/tpschedule-0.7.6-updates.md b/docs/tpschedule-0.7.6-updates.md new file mode 100644 index 0000000..82a8b87 --- /dev/null +++ b/docs/tpschedule-0.7.6-updates.md @@ -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。 diff --git a/mobile/README.md b/mobile/README.md index c744a87..614d105 100644 --- a/mobile/README.md +++ b/mobile/README.md @@ -1,6 +1,6 @@ # 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`。 @@ -17,3 +17,5 @@ 本科连接模块在 `lib/features/school/undergraduate/`,学校标识在 `lib/domain/school_identity.dart`。HTML解析不执行网页脚本,样例已脱敏;真实账号、验证码及在线SSO需本人在学校网页验证。 不要删除、重建或分发根目录 `.tools/android-user/debug.keystore`;0.5.4登记冻结和V1.0材料不得用当前源码重生成。 + +0.7.6新增启动后检查更新及“我的→检查更新”,从HTTPS版本接口读取构建号与说明,点击立即更新打开浏览器。更新后台部署说明位于 `../server/updates/README.md`;初次需手动覆盖安装这一版,后续才可接收更新提示。 diff --git a/mobile/android/app/src/main/kotlin/com/tpschedule/app/MainActivity.kt b/mobile/android/app/src/main/kotlin/com/tpschedule/app/MainActivity.kt index a0ff41f..880b6dd 100644 --- a/mobile/android/app/src/main/kotlin/com/tpschedule/app/MainActivity.kt +++ b/mobile/android/app/src/main/kotlin/com/tpschedule/app/MainActivity.kt @@ -9,6 +9,7 @@ class MainActivity : FlutterActivity() { private var calendar: CalendarBridge? = null private var reminders: ReminderSystemBridge? = null private var schoolDocuments: SchoolDocumentBridge? = null + private var updateBrowser: UpdateBrowserBridge? = null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { @@ -23,10 +24,14 @@ class MainActivity : FlutterActivity() { reminders = ReminderSystemBridge(this, flutterEngine.dartExecutor.binaryMessenger) schoolDocuments?.close() schoolDocuments = SchoolDocumentBridge(flutterEngine) + updateBrowser?.close() + updateBrowser = UpdateBrowserBridge(this, flutterEngine.dartExecutor.binaryMessenger) } override fun cleanUpFlutterEngine(flutterEngine: FlutterEngine) { schoolDocuments?.close() schoolDocuments = null + updateBrowser?.close() + updateBrowser = null super.cleanUpFlutterEngine(flutterEngine) } override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) { @@ -37,6 +42,7 @@ class MainActivity : FlutterActivity() { calendar?.close() reminders?.close() schoolDocuments?.close() + updateBrowser?.close() super.onDestroy() } } diff --git a/mobile/android/app/src/main/kotlin/com/tpschedule/app/UpdateBrowserBridge.kt b/mobile/android/app/src/main/kotlin/com/tpschedule/app/UpdateBrowserBridge.kt new file mode 100644 index 0000000..2b02df9 --- /dev/null +++ b/mobile/android/app/src/main/kotlin/com/tpschedule/app/UpdateBrowserBridge.kt @@ -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("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) } +} diff --git a/mobile/lib/app/app_version.dart b/mobile/lib/app/app_version.dart index 5ae5856..3acfd65 100644 --- a/mobile/lib/app/app_version.dart +++ b/mobile/lib/app/app_version.dart @@ -1,2 +1,2 @@ -const appVersion = '0.7.5'; -const appBuildNumber = 23; +const appVersion = '0.7.6'; +const appBuildNumber = 24; diff --git a/mobile/lib/app/hepan_app.dart b/mobile/lib/app/hepan_app.dart index 566a7c4..f6d6374 100644 --- a/mobile/lib/app/hepan_app.dart +++ b/mobile/lib/app/hepan_app.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.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/exam_query_picker.dart'; import '../domain/undergraduate_exam_query.dart'; +import '../services/app_update_service.dart'; +import '../features/updates/update_prompt.dart'; class HepanApp extends StatelessWidget { const HepanApp({ @@ -29,6 +33,8 @@ class HepanApp extends StatelessWidget { this.undergraduateBuilder, this.notificationSelection, this.feedbackSender, + this.updateService, + this.openUpdateDownload = openUpdateInBrowser, }); final AppController controller; final Widget Function()? schoolBuilder; @@ -37,6 +43,8 @@ class HepanApp extends StatelessWidget { final Widget Function()? undergraduateBuilder; final ValueNotifier? notificationSelection; final FeedbackSender? feedbackSender; + final AppUpdateService? updateService; + final UpdateDownloadOpener openUpdateDownload; @override Widget build(BuildContext context) => MaterialApp( debugShowCheckedModeBanner: false, @@ -53,6 +61,8 @@ class HepanApp extends StatelessWidget { undergraduateBuilder: undergraduateBuilder, notificationSelection: notificationSelection, feedbackSender: feedbackSender ?? configuredFeedbackSender(), + updateService: updateService, + openUpdateDownload: openUpdateDownload, ), ); } @@ -66,6 +76,8 @@ class _Shell extends StatefulWidget { this.undergraduateBuilder, this.notificationSelection, required this.feedbackSender, + this.updateService, + required this.openUpdateDownload, }); final AppController controller; final Widget Function()? schoolBuilder; @@ -74,6 +86,8 @@ class _Shell extends StatefulWidget { final Widget Function()? undergraduateBuilder; final ValueNotifier? notificationSelection; final FeedbackSender feedbackSender; + final AppUpdateService? updateService; + final UpdateDownloadOpener openUpdateDownload; @override State<_Shell> createState() => _ShellState(); } @@ -81,6 +95,40 @@ class _Shell extends StatefulWidget { class _ShellState extends State<_Shell> with WidgetsBindingObserver { int _tab = 0; bool _openingSchool = false; + bool _checkingUpdate = false; + bool _updatePromptOpen = false; + Timer? _startupUpdate; + Future _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. SupportedSchool? get _knownSchool => widget.controller.snapshot.selectedSchool ?? @@ -102,10 +150,19 @@ class _ShellState extends State<_Shell> with WidgetsBindingObserver { WidgetsBinding.instance.addObserver(this); widget.notificationSelection?.addListener(_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 void dispose() { + _startupUpdate?.cancel(); WidgetsBinding.instance.removeObserver(this); widget.notificationSelection?.removeListener(_selectNotification); super.dispose(); @@ -400,6 +457,10 @@ class _ShellState extends State<_Shell> with WidgetsBindingObserver { ProfilePage( controller: widget.controller, feedbackSender: widget.feedbackSender, + onCheckUpdates: widget.updateService == null + ? null + : () => _checkUpdate(), + checkingUpdates: _checkingUpdate, onLogin: () => _school(selectSchool: true), schoolName: _knownSchool?.label, onUpdateTerm: () => _school(chooseTerm: true), diff --git a/mobile/lib/features/profile/profile_page.dart b/mobile/lib/features/profile/profile_page.dart index 26ee75d..af39e57 100644 --- a/mobile/lib/features/profile/profile_page.dart +++ b/mobile/lib/features/profile/profile_page.dart @@ -24,6 +24,8 @@ class ProfilePage extends StatelessWidget { this.logoutLabel = '退出学校登录', this.onUpdateTerm, this.feedbackSender = const UnconfiguredFeedbackSender(), + this.onCheckUpdates, + this.checkingUpdates = false, }); final AppController controller; final VoidCallback onLogin; @@ -32,6 +34,8 @@ class ProfilePage extends StatelessWidget { final VoidCallback? onUpdateTerm; final String authStatus, logoutLabel; final FeedbackSender feedbackSender; + final VoidCallback? onCheckUpdates; + final bool checkingUpdates; Future _import(BuildContext context) async { try { 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, + ), ], ), ), diff --git a/mobile/lib/features/updates/update_prompt.dart b/mobile/lib/features/updates/update_prompt.dart new file mode 100644 index 0000000..e7855b6 --- /dev/null +++ b/mobile/lib/features/updates/update_prompt.dart @@ -0,0 +1,77 @@ +import 'package:flutter/material.dart'; + +import '../../app/app_version.dart'; +import '../../services/app_update_service.dart'; + +Future showUpdatePrompt( + BuildContext context, + AppRelease release, { + UpdateDownloadOpener openDownload = openUpdateInBrowser, +}) async { + final confirmed = await showDialog( + 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( + 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('知道了'), + ), + ], + ), + ); + } +} diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 56caaba..36fb091 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -7,6 +7,7 @@ import 'package:timezone/data/latest.dart' as tzdata; import 'app/app_controller.dart'; import 'app/app_version.dart'; import 'services/diagnostic_log.dart'; +import 'services/app_update_service.dart'; import 'app/hepan_app.dart'; import 'app/tp_startup.dart'; import 'data/file_snapshot_store.dart'; @@ -22,11 +23,13 @@ Widget buildApplication( ValueNotifier? notificationSelection, SchoolSession? school, UndergraduateSession? undergraduate, + AppUpdateService? updateService, }) => HepanApp( controller: controller, notificationSelection: notificationSelection, school: school, undergraduate: undergraduate, + updateService: updateService, undergraduateBuilder: undergraduate == null ? null : () => UndergraduateAuthPage( @@ -63,6 +66,7 @@ Future main() async { notificationSelection: selection, school: SchoolSession(), undergraduate: UndergraduateSession(), + updateService: AppUpdateService(), ); }, onHomeReady: () { diff --git a/mobile/lib/services/app_update_service.dart b/mobile/lib/services/app_update_service.dart new file mode 100644 index 0000000..11c1299 --- /dev/null +++ b/mobile/lib/services/app_update_service.dart @@ -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 body; +} + +abstract interface class UpdateTransport { + Future get(Uri endpoint); +} + +class IoUpdateTransport implements UpdateTransport { + const IoUpdateTransport(); + @override + Future get(Uri endpoint) async { + final client = HttpClient()..connectionTimeout = const Duration(seconds: 8); + Future 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 = []; + 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? _pending; + + Future 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( + (_) { + _pending = null; + }, + onError: (Object _, StackTrace _) { + _pending = null; + }, + ), + ); + return future; + } + + Future _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 Function(Uri uri); + +Future openUpdateInBrowser(Uri uri) async { + if (updateDownloadUri(uri.toString()) == null) return false; + try { + return await const MethodChannel('com.tpschedule.app/updates') + .invokeMethod('openDownload', {'url': uri.toString()}) ?? + false; + } on PlatformException { + return false; + } on MissingPluginException { + return false; + } +} diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index f0a51bc..da9fca3 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -1,7 +1,7 @@ name: hepan_everyday description: TPSchedule 学生课表 publish_to: none -version: 0.7.5+23 +version: 0.7.6+24 environment: sdk: ^3.13.5 dependencies: diff --git a/mobile/test/services/app_update_service_test.dart b/mobile/test/services/app_update_service_test.dart new file mode 100644 index 0000000..3ca2300 --- /dev/null +++ b/mobile/test/services/app_update_service_test.dart @@ -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? 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 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 = []; + 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.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'), + 'WAF challenge', + 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()), + ); + } + transport.response = UpdateHttpResponse(503, utf8.encode('{}')); + await expectLater( + AppUpdateService(transport: transport).check(), + throwsA(isA()), + ); + }); +} diff --git a/mobile/test/widgets/app_update_test.dart b/mobile/test/widgets/app_update_test.dart new file mode 100644 index 0000000..61bea8b --- /dev/null +++ b/mobile/test/widgets/app_update_test.dart @@ -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 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; + (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(); + }); +} diff --git a/server/updates/.dockerignore b/server/updates/.dockerignore new file mode 100644 index 0000000..8018e17 --- /dev/null +++ b/server/updates/.dockerignore @@ -0,0 +1,6 @@ +.env +.deps +__pycache__ +.pytest_cache +data +tests diff --git a/server/updates/.env.example b/server/updates/.env.example new file mode 100644 index 0000000..67e1ede --- /dev/null +++ b/server/updates/.env.example @@ -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 diff --git a/server/updates/.gitignore b/server/updates/.gitignore new file mode 100644 index 0000000..22ac6c1 --- /dev/null +++ b/server/updates/.gitignore @@ -0,0 +1,5 @@ +.env +.deps/ +__pycache__/ +.pytest_cache/ +data/ diff --git a/server/updates/Dockerfile b/server/updates/Dockerfile new file mode 100644 index 0000000..7e8e060 --- /dev/null +++ b/server/updates/Dockerfile @@ -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"] diff --git a/server/updates/README.md b/server/updates/README.md new file mode 100644 index 0000000..d00fcd3 --- /dev/null +++ b/server/updates/README.md @@ -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按纯文本呈现。 diff --git a/server/updates/docker-compose.yml b/server/updates/docker-compose.yml new file mode 100644 index 0000000..b22693b --- /dev/null +++ b/server/updates/docker-compose.yml @@ -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: diff --git a/server/updates/main.py b/server/updates/main.py new file mode 100644 index 0000000..fb118a1 --- /dev/null +++ b/server/updates/main.py @@ -0,0 +1,3 @@ +from update_service import create_app + +app = create_app() diff --git a/server/updates/manage.py b/server/updates/manage.py new file mode 100644 index 0000000..a02e760 --- /dev/null +++ b/server/updates/manage.py @@ -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)}'") diff --git a/server/updates/openresty-safeline.conf.example b/server/updates/openresty-safeline.conf.example new file mode 100644 index 0000000..d098767 --- /dev/null +++ b/server/updates/openresty-safeline.conf.example @@ -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; +} diff --git a/server/updates/requirements.txt b/server/updates/requirements.txt new file mode 100644 index 0000000..94ba370 --- /dev/null +++ b/server/updates/requirements.txt @@ -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 diff --git a/server/updates/tests/test_updates.py b/server/updates/tests/test_updates.py new file mode 100644 index 0000000..166e908 --- /dev/null +++ b/server/updates/tests/test_updates.py @@ -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='') + page = client.get('/update') + assert '' not in page.text + assert '<script>' 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 diff --git a/server/updates/update_service.py b/server/updates/update_service.py new file mode 100644 index 0000000..12e3e70 --- /dev/null +++ b/server/updates/update_service.py @@ -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'''

TPSchedule 更新管理

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

+
+ + + +

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

+ + +

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

+
+
''' + else: + form = f'''

登录更新后台

TPSchedule · 仅限管理员

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

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

' if saved else ''}{form}
''' + + +def create_app(settings=None): + settings = settings or Settings.from_env() + settings.validate() + store, sessions = ReleaseStore(settings.data_dir), Sessions(settings) + limiter = LoginLimit(settings.session_secret) + password_slots = asyncio.Semaphore(2) + app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None) + app.add_middleware(RequestBounds) + + def token_for(request): + token = request.cookies.get(COOKIE, '') + return token, sessions.read(token) + + def require_admin(request): + token, value = token_for(request) + if value is None or not value['authenticated']: + raise UpdateError(401, '请先登录更新后台。') + return token + + async def fields(request, token): + if request.headers.get('origin') != settings.public_origin: + raise UpdateError(403, '请求来源无效,请从本站更新后台提交。') + if request.headers.get('content-type', '').split(';')[0] != 'application/x-www-form-urlencoded': + raise UpdateError(415, '请使用后台表单提交。') + form = await request.form(max_files=0, max_fields=8, max_part_size=40000) + if any(len(form.getlist(key)) != 1 for key in form.keys()): + raise UpdateError(422, '表单字段重复。') + values = dict(form) + csrf = values.get('csrf', '') + if (not token or sessions.read(token) is None or not re.fullmatch(r'[a-f0-9]{64}', csrf) + or not hmac.compare_digest(sessions.csrf(token), csrf)): + raise UpdateError(403, '页面已过期,请重新打开更新后台。') + return values + + def html_response(request, *, error='', status=200): + token, value = token_for(request) + if value is None: + token, value = sessions.make(), {'authenticated': False} + authenticated = value['authenticated'] + release, revision = store.read() if authenticated else (None, 'none') + draft = getattr(request.state, 'publish_draft', None) + if authenticated and error and draft is not None: + release = draft + revision = draft.get('revision', revision) + response = HTMLResponse(render(settings, sessions.csrf(token), release, revision, error, + authenticated, request.query_params.get('saved') == '1'), status_code=status) + response.set_cookie(COOKIE, token, secure=True, httponly=True, samesite='strict', path='/update', + max_age=28800 if authenticated else 3600) + return response + + @app.exception_handler(UpdateError) + async def error_handler(request, error): + if request.url.path.startswith('/update') and error.status not in {503}: + return html_response(request, error=error.message, status=error.status) + return JSONResponse({'error': error.message}, status_code=error.status) + + @app.get('/healthz') + async def health(): + return {'status': 'ok'} + + @app.get('/v1/updates/android') + async def latest(): + release, _ = await run_in_threadpool(store.read) + return {'schema_version': 1, 'application_id': APP_ID, 'latest': release} + + @app.get('/update') + @app.get('/update/') + async def panel(request: Request): + return html_response(request) + + @app.post('/update/login') + async def login(request: Request): + token, _ = token_for(request) + values = await fields(request, token) + limiter.check(request.client.host if request.client else 'unknown') + async with password_slots: + good_password = await run_in_threadpool(verify_password, values.get('password', ''), settings.admin_password_hash) + if not hmac.compare_digest(values.get('username', '').encode(), settings.admin_username.encode()) or not good_password: + raise UpdateError(401, '账号或密码不正确。') + response = RedirectResponse('/update', status_code=303) + response.set_cookie(COOKIE, sessions.make(True), secure=True, httponly=True, + samesite='strict', path='/update', max_age=28800) + return response + + @app.post('/update/publish') + async def publish(request: Request): + token = require_admin(request) + values = await fields(request, token) + request.state.publish_draft = values + try: + code = int(values.get('build_number', '')) + except ValueError: + raise UpdateError(422, '构建号必须是正整数。') from None + raw = {key: values.get(key, '') for key in ('version', 'notes', 'download_url')} + raw['build_number'] = code + await run_in_threadpool(store.publish, raw, values.get('revision', '')) + return RedirectResponse('/update?saved=1', status_code=303) + + @app.post('/update/logout') + async def logout(request: Request): + token = require_admin(request) + await fields(request, token) + response = RedirectResponse('/update', status_code=303) + response.delete_cookie(COOKIE, path='/update', secure=True, httponly=True, samesite='strict') + return response + + return app