feat: add app update checks and standalone release publishing panel
This commit is contained in:
@@ -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,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)。
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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) }
|
||||||
|
}
|
||||||
@@ -1,2 +1,2 @@
|
|||||||
const appVersion = '0.7.5';
|
const appVersion = '0.7.6';
|
||||||
const appBuildNumber = 23;
|
const appBuildNumber = 24;
|
||||||
|
|||||||
@@ -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('知道了'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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: () {
|
||||||
|
|||||||
@@ -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
@@ -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>()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
.env
|
||||||
|
.deps
|
||||||
|
__pycache__
|
||||||
|
.pytest_cache
|
||||||
|
data
|
||||||
|
tests
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
.env
|
||||||
|
.deps/
|
||||||
|
__pycache__/
|
||||||
|
.pytest_cache/
|
||||||
|
data/
|
||||||
@@ -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"]
|
||||||
@@ -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按纯文本呈现。
|
||||||
@@ -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:
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from update_service import create_app
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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 '<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
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user