feat: add app update checks and standalone release publishing panel
This commit is contained in:
@@ -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+ 原生启动屏使用同色纯蓝背景及图标,旧版采用蓝色渐变。
|
||||
|
||||
|
||||
@@ -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)。
|
||||
|
||||
|
||||
@@ -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工程
|
||||
|
||||
当前版本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`;初次需手动覆盖安装这一版,后续才可接收更新提示。
|
||||
|
||||
@@ -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<out String>, grantResults: IntArray) {
|
||||
@@ -37,6 +42,7 @@ class MainActivity : FlutterActivity() {
|
||||
calendar?.close()
|
||||
reminders?.close()
|
||||
schoolDocuments?.close()
|
||||
updateBrowser?.close()
|
||||
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 appBuildNumber = 23;
|
||||
const appVersion = '0.7.6';
|
||||
const appBuildNumber = 24;
|
||||
|
||||
@@ -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<String?>? 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<String?>? 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<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.
|
||||
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),
|
||||
|
||||
@@ -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<void> _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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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_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<String?>? 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<void> main() async {
|
||||
notificationSelection: selection,
|
||||
school: SchoolSession(),
|
||||
undergraduate: UndergraduateSession(),
|
||||
updateService: AppUpdateService(),
|
||||
);
|
||||
},
|
||||
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
|
||||
description: TPSchedule 学生课表
|
||||
publish_to: none
|
||||
version: 0.7.5+23
|
||||
version: 0.7.6+24
|
||||
environment:
|
||||
sdk: ^3.13.5
|
||||
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