Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6277e86d0f | |||
| 4baf24f6f8 | |||
| ec0942e1e7 | |||
| 1e13f35219 | |||
| e4de308ba4 | |||
| 97caae95b8 | |||
| 4561ee4cc3 | |||
| a53bbe743e | |||
| 7b1f49c182 |
@@ -83,17 +83,19 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Download android artifact
|
||||
# 一次性下所有 artifact(不带 name),避免同 job 内两次复用 download-artifact
|
||||
# action → act 对其只读缓存 git 仓库做二次操作时 EACCES(pack idx 444)。
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: android
|
||||
path: dist/
|
||||
path: dist-raw/
|
||||
|
||||
- name: Download windows artifact
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: windows
|
||||
path: dist/
|
||||
- name: Flatten artifacts into dist/
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p dist
|
||||
find dist-raw -type f -exec cp {} dist/ \;
|
||||
echo "dist/ 内容:"; ls -la dist/
|
||||
|
||||
- name: Release → Forgejo
|
||||
env:
|
||||
|
||||
@@ -140,7 +140,7 @@ fi
|
||||
|
||||
# ── 下载二进制压缩包 ──────────────────────────────────────────────────────────
|
||||
printf '==> 下载 %s…\n' "${ARCHIVE_FILE}"
|
||||
curl -fSL --retry 3 --retry-delay 2 \
|
||||
curl -fSL --retry 8 --retry-delay 5 --retry-connrefused --retry-all-errors --connect-timeout 20 \
|
||||
-o "${ARCHIVE_CACHE}" \
|
||||
"${ARCHIVE_URL}"
|
||||
|
||||
@@ -197,7 +197,7 @@ if [[ "${TARGET_OS}" == "windows" ]]; then
|
||||
if [[ "${FORCE}" == false && -f "${WINTUN_OUT}" ]]; then
|
||||
printf '✓ wintun.dll 已存在,跳过(传 --force 重新下载)\n'
|
||||
else
|
||||
curl -fSL --retry 3 --retry-delay 2 \
|
||||
curl -fSL --retry 8 --retry-delay 5 --retry-connrefused --retry-all-errors --connect-timeout 20 \
|
||||
-o "${WINTUN_ZIP_CACHE}" \
|
||||
"${WINTUN_URL}"
|
||||
|
||||
|
||||
@@ -148,6 +148,14 @@ abstract class AppText {
|
||||
String get killSwitch;
|
||||
String get killSwitchSub;
|
||||
String get checkUpdate;
|
||||
String get webUserCenter; // 「用户中心(网页)」入口(App→Web SSO 免登)
|
||||
// 更新检查(设置页「检查更新」手动触发)
|
||||
String updateAvailableTitle(String version); // 「发现新版本 vX.Y.Z」
|
||||
String get updateNotesFallback; // 无 release_notes 时的兜底文案
|
||||
String get updateLater;
|
||||
String get updateDownload;
|
||||
String get updateUpToDate; // 检查后:已是最新版本
|
||||
String get updateCheckFailed; // 检查失败(网络异常)
|
||||
|
||||
// ── 套餐选择 ──
|
||||
String get choosePlan;
|
||||
|
||||
@@ -235,6 +235,20 @@ class StringsEn extends AppText {
|
||||
String get killSwitchSub => 'Block traffic if the link drops';
|
||||
@override
|
||||
String get checkUpdate => 'Check for updates';
|
||||
@override
|
||||
String get webUserCenter => 'User Center (Web)';
|
||||
@override
|
||||
String updateAvailableTitle(String version) => 'New version v$version available';
|
||||
@override
|
||||
String get updateNotesFallback => 'This update includes fixes and stability improvements.';
|
||||
@override
|
||||
String get updateLater => 'Later';
|
||||
@override
|
||||
String get updateDownload => 'Download update';
|
||||
@override
|
||||
String get updateUpToDate => 'You are up to date';
|
||||
@override
|
||||
String get updateCheckFailed => 'Update check failed, try again later';
|
||||
|
||||
@override
|
||||
String get choosePlan => 'Choose plan';
|
||||
|
||||
@@ -234,6 +234,20 @@ class StringsZh extends AppText {
|
||||
String get killSwitchSub => '断线时阻断网络,防止泄露';
|
||||
@override
|
||||
String get checkUpdate => '检查更新';
|
||||
@override
|
||||
String get webUserCenter => '用户中心(网页)';
|
||||
@override
|
||||
String updateAvailableTitle(String version) => '发现新版本 v$version';
|
||||
@override
|
||||
String get updateNotesFallback => '本次更新修复了一些问题并提升了稳定性,建议尽快更新。';
|
||||
@override
|
||||
String get updateLater => '稍后';
|
||||
@override
|
||||
String get updateDownload => '下载更新';
|
||||
@override
|
||||
String get updateUpToDate => '已是最新版本';
|
||||
@override
|
||||
String get updateCheckFailed => '检查更新失败,请稍后重试';
|
||||
|
||||
@override
|
||||
String get choosePlan => '选择套餐';
|
||||
|
||||
@@ -9,9 +9,29 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../l10n/app_text.dart';
|
||||
import '../pangolin_theme.dart';
|
||||
import '../services/web_launch.dart';
|
||||
import '../state/app_providers.dart';
|
||||
import '../state/settings_provider.dart';
|
||||
import '../state/update_provider.dart';
|
||||
import '../widgets/pangolin_icons.dart';
|
||||
import '../widgets/pangolin_toast.dart';
|
||||
import '../widgets/update_dialog.dart';
|
||||
|
||||
/// 「检查更新」手动触发:拉取 `$kApiBaseUrl/version`,失败/无更新走轻提示,
|
||||
/// 有更新则弹 [showUpdateDialog]。
|
||||
Future<void> _checkForUpdate(BuildContext context, WidgetRef ref, AppText t) async {
|
||||
final info = await ref.read(updateCheckerProvider).check();
|
||||
if (!context.mounted) return;
|
||||
if (info == null) {
|
||||
showPangolinToast(context, t.updateCheckFailed);
|
||||
return;
|
||||
}
|
||||
if (!info.hasUpdate) {
|
||||
showPangolinToast(context, t.updateUpToDate);
|
||||
return;
|
||||
}
|
||||
await showUpdateDialog(context, t, info);
|
||||
}
|
||||
|
||||
class SettingsPage extends ConsumerWidget {
|
||||
const SettingsPage({super.key});
|
||||
@@ -59,7 +79,8 @@ class SettingsPage extends ConsumerWidget {
|
||||
right: sw(isDark, (v) => ref.read(themeModeProvider.notifier).state = v ? ThemeMode.dark : ThemeMode.light),
|
||||
),
|
||||
_Row(title: t.protocol, right: Text('REALITY / Hysteria2', style: PangolinText.mono.copyWith(fontSize: 13, color: c.fg3))),
|
||||
_Row(title: t.checkUpdate, right: Icon(PangolinIcons.chevronRight, size: 18, color: c.fg3), onTap: () {}),
|
||||
_Row(title: t.webUserCenter, right: Icon(PangolinIcons.externalLink, size: 18, color: c.fg3), onTap: () => openWebUserCenter(ref)),
|
||||
_Row(title: t.checkUpdate, right: Icon(PangolinIcons.chevronRight, size: 18, color: c.fg3), onTap: () => _checkForUpdate(context, ref, t)),
|
||||
_Row(title: 'Version', last: true, right: Text(version, style: PangolinText.mono.copyWith(fontSize: 13, color: c.fg3))),
|
||||
]),
|
||||
]),
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
// web_launch.dart — App→Web 单点登录跳转(SSO 换票)。
|
||||
//
|
||||
// 「用户中心(网页)」入口:先向控制面签一张短时单次票据
|
||||
// (POST /v1/auth/web-ticket,需登录),再打开 用户中心网页版 的
|
||||
// /sso?t=<票>&redirect=<路径> 落地页兑票登录——避免用户在网页端重新输入密码。
|
||||
// 签票失败(未登录/网络异常)时降级为直接打开目标页(未登录态浏览)。
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import '../state/account_providers.dart';
|
||||
|
||||
/// 用户中心(网页版)基址。
|
||||
const String kWebUserCenterBaseUrl = 'https://app.yanmeiai.com';
|
||||
|
||||
/// 打开用户中心网页版 [path](默认首页),尝试免登录(SSO 换票)。
|
||||
Future<void> openWebUserCenter(WidgetRef ref, {String path = '/'}) async {
|
||||
Uri target = Uri.parse('$kWebUserCenterBaseUrl$path');
|
||||
try {
|
||||
final resp = await ref.read(apiClientProvider).postJson('/v1/auth/web-ticket');
|
||||
final ticket = resp['ticket'] as String?;
|
||||
if (ticket != null && ticket.isNotEmpty) {
|
||||
target = Uri.parse(
|
||||
'$kWebUserCenterBaseUrl/sso?t=$ticket&redirect=${Uri.encodeComponent(path)}');
|
||||
}
|
||||
} catch (_) {
|
||||
// 签票失败(未登录/网络异常)降级:直接打开目标页(未登录态浏览)。
|
||||
}
|
||||
await launchUrl(target, mode: LaunchMode.externalApplication);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
// update_provider.dart — 应用更新检查(轻量版,无后台轮询)。
|
||||
//
|
||||
// 手动触发(设置页「检查更新」)向控制面 GET $kApiBaseUrl/version 拉取最新版本信息,
|
||||
// 与本地版本(package_info_plus)做语义比较;有更新则由调用方(settings_page)弹窗
|
||||
// 展示(见 widgets/update_dialog.dart)。检查失败静默返回 null,不影响主流程。
|
||||
//
|
||||
// 对照 jiu client/lib/providers/update_provider.dart,去掉了定时轮询 Timer 与
|
||||
// dismiss 状态——按需求这里只做「手动检查」,启动期自动检查留作后续 TODO。
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
|
||||
import '../services/api_config.dart';
|
||||
|
||||
/// 一次更新检查的结果。
|
||||
class AppUpdateInfo {
|
||||
const AppUpdateInfo({
|
||||
required this.latestVersion,
|
||||
required this.buildNumber,
|
||||
required this.forceUpdate,
|
||||
required this.releaseNotes,
|
||||
required this.downloadUrls,
|
||||
required this.hasUpdate,
|
||||
});
|
||||
|
||||
final String latestVersion;
|
||||
final int buildNumber;
|
||||
final bool forceUpdate;
|
||||
final String releaseNotes;
|
||||
final Map<String, String> downloadUrls;
|
||||
final bool hasUpdate;
|
||||
}
|
||||
|
||||
/// 无状态更新检查服务,供设置页「检查更新」按钮直接调用。
|
||||
final updateCheckerProvider = Provider<UpdateChecker>((ref) => const UpdateChecker());
|
||||
|
||||
class UpdateChecker {
|
||||
const UpdateChecker();
|
||||
|
||||
/// 拉取 `$kApiBaseUrl/version` 并与本地版本比较。网络/解析失败返回 null(静默)。
|
||||
Future<AppUpdateInfo?> check() async {
|
||||
try {
|
||||
final resp = await http
|
||||
.get(Uri.parse('$kApiBaseUrl/version'))
|
||||
.timeout(const Duration(seconds: 8));
|
||||
if (resp.statusCode != 200) return null;
|
||||
final data = jsonDecode(resp.body) as Map<String, dynamic>;
|
||||
|
||||
final latestVersion = data['version'] as String? ?? '0.0.0';
|
||||
final buildNumber = (data['build_number'] as num?)?.toInt() ?? 0;
|
||||
final forceUpdate = data['force_update'] as bool? ?? false;
|
||||
final releaseNotes = data['release_notes'] as String? ?? '';
|
||||
final rawUrls = data['download_urls'] as Map<String, dynamic>? ?? const {};
|
||||
final downloadUrls = rawUrls.map((k, v) => MapEntry(k, v?.toString() ?? ''));
|
||||
|
||||
final info = await PackageInfo.fromPlatform();
|
||||
final hasUpdate = _isNewer(latestVersion, info.version);
|
||||
|
||||
return AppUpdateInfo(
|
||||
latestVersion: latestVersion,
|
||||
buildNumber: buildNumber,
|
||||
forceUpdate: forceUpdate,
|
||||
releaseNotes: releaseNotes,
|
||||
downloadUrls: downloadUrls,
|
||||
hasUpdate: hasUpdate,
|
||||
);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 语义化版本比较:latest > current → true。容忍 1.1.4-dev / 1.1.4+7 等后缀。
|
||||
bool _isNewer(String latest, String current) {
|
||||
final l = _parse(latest);
|
||||
final c = _parse(current);
|
||||
for (var i = 0; i < 3; i++) {
|
||||
if (l[i] > c[i]) return true;
|
||||
if (l[i] < c[i]) return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
List<int> _parse(String v) {
|
||||
final parts = v.split('.').map((s) {
|
||||
final m = RegExp(r'^\d+').firstMatch(s);
|
||||
return m == null ? 0 : int.parse(m.group(0)!);
|
||||
}).toList();
|
||||
while (parts.length < 3) {
|
||||
parts.add(0);
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
}
|
||||
|
||||
/// 按当前平台从服务端 download_urls 里取对应下载直链。
|
||||
String? platformDownloadUrl(Map<String, String> downloadUrls) {
|
||||
if (Platform.isMacOS) return downloadUrls['macos'];
|
||||
if (Platform.isWindows) return downloadUrls['windows'];
|
||||
if (Platform.isIOS) return downloadUrls['ios'];
|
||||
if (Platform.isAndroid) return downloadUrls['android'];
|
||||
return downloadUrls['web'];
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
// update_dialog.dart — 「发现新版本」更新提示弹窗。
|
||||
//
|
||||
// 只做「展示 + 引导下载」:点「下载更新」用 url_launcher 打开对应平台的下载直链
|
||||
// (浏览器下载),不做应用内静默安装/自动重启。force_update=true 时不可关闭
|
||||
// (无「稍后」按钮、点遮罩/返回也关不掉)。
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import '../l10n/app_text.dart';
|
||||
import '../pangolin_theme.dart';
|
||||
import '../state/update_provider.dart';
|
||||
import 'pangolin_icons.dart';
|
||||
|
||||
/// 展示更新弹窗。调用方(设置页「检查更新」)在拿到 `info.hasUpdate == true` 时调用。
|
||||
Future<void> showUpdateDialog(BuildContext context, AppText t, AppUpdateInfo info) {
|
||||
return showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: !info.forceUpdate,
|
||||
builder: (ctx) => PopScope(
|
||||
canPop: !info.forceUpdate,
|
||||
child: _UpdateDialog(t: t, info: info),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _UpdateDialog extends StatelessWidget {
|
||||
const _UpdateDialog({required this.t, required this.info});
|
||||
final AppText t;
|
||||
final AppUpdateInfo info;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final c = context.pangolin;
|
||||
return AlertDialog(
|
||||
backgroundColor: c.surface,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(PangolinRadius.xl)),
|
||||
title: Row(children: [
|
||||
Container(
|
||||
width: 34,
|
||||
height: 34,
|
||||
decoration: BoxDecoration(color: c.accentSubtle, shape: BoxShape.circle),
|
||||
child: Icon(PangolinIcons.zap, size: 18, color: c.accent),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
t.updateAvailableTitle(info.latestVersion),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: PangolinText.body.copyWith(color: c.fg1, fontWeight: FontWeight.w700),
|
||||
),
|
||||
),
|
||||
]),
|
||||
content: SingleChildScrollView(
|
||||
child: Text(
|
||||
info.releaseNotes.isEmpty ? t.updateNotesFallback : info.releaseNotes,
|
||||
style: PangolinText.sm.copyWith(color: c.fg2, height: 1.5),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
if (!info.forceUpdate)
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: Text(t.updateLater, style: PangolinText.sm.copyWith(color: c.fg2, fontWeight: FontWeight.w600)),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
final url = platformDownloadUrl(info.downloadUrls);
|
||||
if (url != null && url.isNotEmpty) {
|
||||
final uri = Uri.parse(url);
|
||||
if (await canLaunchUrl(uri)) {
|
||||
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
}
|
||||
}
|
||||
if (context.mounted) Navigator.of(context).pop();
|
||||
},
|
||||
child: Text(t.updateDownload, style: PangolinText.sm.copyWith(color: c.accent, fontWeight: FontWeight.w700)),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ dependencies:
|
||||
flutter_secure_storage: ^9.2.2 # JWT token 安全存储 + 稳定 device_id 持久化
|
||||
shared_preferences: ^2.5.5
|
||||
package_info_plus: ^9.0.1
|
||||
url_launcher: ^6.3.0 # 打开外部链接(用户中心 SSO 免登 / 更新下载页)
|
||||
device_info_plus: ^11.2.0 # 设备名/平台(「我的设备」上报)
|
||||
uuid: ^4.5.1 # 客户端生成稳定 device_id (UUID v4)
|
||||
launch_at_startup: ^0.5.1
|
||||
|
||||
@@ -178,6 +178,7 @@ GRPC_KEY_PATH=$ETC/grpc.key
|
||||
PANGOLIN_PUBLIC_URL=https://api.yanmeiai.com
|
||||
PANGOLIN_RULES_DIR=$DATA_DIR/rules
|
||||
DOWNLOADS_DIR=$DATA_DIR/downloads
|
||||
VERSION_MANIFEST=$ETC/version.yaml
|
||||
EOF
|
||||
if [ -n "${SMTP_HOST:-}" ]; then
|
||||
cat >> "$ETC/server.env" <<EOF
|
||||
@@ -211,6 +212,15 @@ curl -fsSL -o "$RULES_DIR/geosite-cn.srs" \
|
||||
DOWNLOADS_DIR="$DATA_DIR/downloads"
|
||||
install -d -m 755 "$DOWNLOADS_DIR"
|
||||
|
||||
# ── 6d. 客户端自动更新版本清单 ─────────────────────────────────────────────────
|
||||
# GET /version(公开、免鉴权)按 VERSION_MANIFEST(见上 server.env)读取该文件;
|
||||
# scripts/ci/release-client.sh 每次 client-v* 发版都会以本仓库这份文件为模板,
|
||||
# 改写 version/build_number 后 SSH 推到这个路径覆盖 —— 幂等重跑本脚本不应该把
|
||||
# 已发布的最新版本号退回仓库里的默认值,所以文件已存在时不覆盖。
|
||||
if [ ! -f "$ETC/version.yaml" ]; then
|
||||
install -m 644 "$HERE/version.yaml" "$ETC/version.yaml"
|
||||
fi
|
||||
|
||||
# ── 7. 迁移 + seed(SQLite)────────────────────────────────────────────────────
|
||||
log "执行迁移(sqlite)..."
|
||||
DB_DRIVER=sqlite DB_DSN="$DB_FILE" "$BIN/pangolin-migrate" up
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
# version.yaml — 客户端自动更新版本清单(committed default)。
|
||||
#
|
||||
# deploy/single-node/deploy.sh 把这份文件安装到 /etc/pangolin/version.yaml
|
||||
# (VERSION_MANIFEST 默认路径,见 server/internal/httpapi/version.go)。
|
||||
# scripts/ci/release-client.sh 在每次 client-v* 发版时,以这份文件为模板改写
|
||||
# version / build_number 字段,再原样(SSH)推到 pangolin1 的
|
||||
# /etc/pangolin/version.yaml —— 控制面每次请求都重新读该文件,发版立即生效,
|
||||
# 不需要重启/重新部署控制面。
|
||||
#
|
||||
# download_urls 目前是固定「仅保留最新一份」的稳定 URL(deploy-client.sh 每次
|
||||
# 用同名文件覆盖),不是按版本变化的路径,因此这里不需要随发版改写;
|
||||
# macos / ios 产物尚未产出,先留空字符串。
|
||||
version: "1.0.48"
|
||||
build_number: 10048
|
||||
force_update: false
|
||||
release_notes: ""
|
||||
download_urls:
|
||||
android: "https://api.yanmeiai.com/downloads/pangolin-android.apk"
|
||||
windows: "https://api.yanmeiai.com/downloads/pangolin-windows-x64-setup.exe"
|
||||
macos: ""
|
||||
ios: ""
|
||||
changelog: []
|
||||
@@ -109,9 +109,17 @@ ISS="${BUILD_CLIENT}/windows/installer/pangolin.iss"
|
||||
sed -i "s/^#define MyAppVersion .*/#define MyAppVersion \"${VER}\"/" "$ISS"
|
||||
|
||||
echo "==> building installer with Inno Setup (version ${VER})"
|
||||
# MSYS_NO_PATHCONV / MSYS2_ARG_CONV_EXCL disable Git Bash's automatic conversion
|
||||
# of paths, matching jiu's ISCC invocation fix.
|
||||
MSYS_NO_PATHCONV=1 MSYS2_ARG_CONV_EXCL='*' "$ISCC" "$ISS"
|
||||
# ISCC 是原生 Windows 程序,只认 Windows 路径(C:\...);传 MSYS 风格 /c/... 会被它
|
||||
# 当成选项 → "Unknown option: /c/...pangolin.iss"。故先用 cygpath 把 .iss 转成
|
||||
# Windows 路径再传(jiu 是直接写死 C:\ 字面量;这里用 cygpath 更稳)。
|
||||
# 不用 $() 命令替换(仓库约定):cygpath 输出落临时文件、read 读回。
|
||||
cygpath -w "$ISS" > /tmp/pangolin_iss_win.$$
|
||||
ISS_WIN=""
|
||||
read -r ISS_WIN < /tmp/pangolin_iss_win.$$
|
||||
rm -f /tmp/pangolin_iss_win.$$
|
||||
# MSYS_NO_PATHCONV / MSYS2_ARG_CONV_EXCL 关掉 Git Bash 对 /-开头参数的自动路径转换
|
||||
# (否则会把 .iss 里将来可能的 /D 定义也改坏)。ISS_WIN 已是 Windows 路径,原样传即可。
|
||||
MSYS_NO_PATHCONV=1 MSYS2_ARG_CONV_EXCL='*' "$ISCC" "$ISS_WIN"
|
||||
|
||||
# pangolin.iss has no explicit OutputDir -> Inno defaults to {src}\Output
|
||||
# (relative to the .iss file), filename OutputBaseFilename=pangolin-setup-<ver>.
|
||||
|
||||
@@ -138,6 +138,13 @@ func main() {
|
||||
downloadsHandler := httpapi.NewDownloadsHandler(os.Getenv("DOWNLOADS_DIR"))
|
||||
r.Get("/downloads/*", downloadsHandler.Serve)
|
||||
|
||||
// Public (no auth): 客户端自动更新版本清单。VERSION_MANIFEST 可配置清单路径
|
||||
// (默认 /etc/pangolin/version.yaml);deploy/single-node/deploy.sh 安装仓库内
|
||||
// 默认清单,scripts/ci/release-client.sh 在每次 client-v* 发版时改写其
|
||||
// version/build_number。每次请求都重新读文件,发版脚本改完立即生效,无需重启。
|
||||
versionHandler := httpapi.NewVersionHandler(os.Getenv("VERSION_MANIFEST"))
|
||||
r.Get("/version", versionHandler.Serve)
|
||||
|
||||
// Optional probe ingest route. sharedProbeStore is reused by the scheduler
|
||||
// (below) when both are enabled, so they share one Redis-backed store.
|
||||
var sharedProbeStore *probe.Store
|
||||
@@ -378,6 +385,9 @@ func mountV1(r chi.Router, sqlDB *sql.DB, rdb *redis.Client, nodeSvc *nodes.Serv
|
||||
v1.Post("/auth/login", authHandler.Login)
|
||||
v1.Post("/auth/refresh", authHandler.Refresh)
|
||||
v1.Post("/auth/logout", authHandler.Logout)
|
||||
// App→网页免登录换票的公开一端;票据本身即凭证,无需 Bearer(见下方
|
||||
// 受保护分组里的签票端 /auth/web-ticket)。
|
||||
v1.Post("/auth/web-ticket/exchange", authHandler.WebTicketExchange)
|
||||
if totpHandler != nil {
|
||||
v1.Post("/auth/login/totp", totpHandler.LoginTOTP)
|
||||
}
|
||||
@@ -405,6 +415,10 @@ func mountV1(r chi.Router, sqlDB *sql.DB, rdb *redis.Client, nodeSvc *nodes.Serv
|
||||
}
|
||||
})
|
||||
protected.Post("/redeem", redeemHandler.ServeHTTP)
|
||||
if authHandler != nil {
|
||||
// 签票端要求已登录(拿当前 JWT 的 uid/uuid);兑换端见上方公开分组。
|
||||
protected.Post("/auth/web-ticket", authHandler.WebTicket)
|
||||
}
|
||||
protected.Get("/usage", usageHandler.ServeHTTP)
|
||||
protected.Get("/usage/devices", deviceUsageHandler.ServeHTTP)
|
||||
protected.Post("/ads/unlock", adsHandler.ServeHTTP)
|
||||
|
||||
@@ -83,4 +83,13 @@ var (
|
||||
MessageZH: "服务器内部错误,请稍后重试",
|
||||
MessageEn: "Internal server error, please try again later",
|
||||
}
|
||||
|
||||
// ErrTicketInvalid — the web-ticket (App→网页免登录一次性票据) is missing,
|
||||
// already used, or expired. Intentionally generic (mirrors ErrCodeInvalid) so
|
||||
// the three cases can't be distinguished from the response.
|
||||
ErrTicketInvalid = &apierr.Error{
|
||||
Code: "auth.ticket_invalid",
|
||||
MessageZH: "登录票据无效或已过期,请重新从 App 打开",
|
||||
MessageEn: "Login ticket is invalid or expired, please reopen from the app",
|
||||
}
|
||||
)
|
||||
|
||||
@@ -29,6 +29,7 @@ func (h *Handler) RegisterRoutes(r chi.Router) {
|
||||
r.Post("/auth/login", h.Login)
|
||||
r.Post("/auth/refresh", h.Refresh)
|
||||
r.Post("/auth/logout", h.Logout)
|
||||
r.Post("/auth/web-ticket/exchange", h.WebTicketExchange)
|
||||
}
|
||||
|
||||
// Logout handles POST /v1/auth/logout. The refresh token to revoke is taken from
|
||||
@@ -158,6 +159,74 @@ func (h *Handler) Refresh(w http.ResponseWriter, r *http.Request) {
|
||||
writeTokenPair(w, pair)
|
||||
}
|
||||
|
||||
// ═══════════════ App → 网页免登录(一次性换票,magic-link)═══════════════
|
||||
|
||||
// webTicketRequest / webTicketResponse — POST /v1/auth/web-ticket (auth-required).
|
||||
type webTicketResponse struct {
|
||||
Ticket string `json:"ticket"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
}
|
||||
|
||||
// WebTicket handles POST /v1/auth/web-ticket (auth-required, mounted in the
|
||||
// RequireAuth group — see main.go). Mints a one-time ticket for the currently
|
||||
// authenticated user so the app can open the web user-center pre-authenticated
|
||||
// (`https://<host>/sso?t=<ticket>`). Rate-limited to 1/sec/user.
|
||||
func (h *Handler) WebTicket(w http.ResponseWriter, r *http.Request) {
|
||||
uid, ok := UserIDFromContext(r.Context())
|
||||
if !ok {
|
||||
writeAPIErr(w, ErrUnauthorized, 0)
|
||||
return
|
||||
}
|
||||
uuid, ok := UserUUIDFromContext(r.Context())
|
||||
if !ok {
|
||||
writeAPIErr(w, ErrUnauthorized, 0)
|
||||
return
|
||||
}
|
||||
ticket, ttl, retryAfter, apiErr := h.svc.IssueWebTicket(r.Context(), uid, uuid)
|
||||
if apiErr != nil {
|
||||
writeAPIErr(w, apiErr, retryAfter)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = json.NewEncoder(w).Encode(webTicketResponse{Ticket: ticket, ExpiresIn: ttl})
|
||||
}
|
||||
|
||||
// webTicketExchangeRequest is the public exchange body.
|
||||
type webTicketExchangeRequest struct {
|
||||
Ticket string `json:"ticket"`
|
||||
Device deviceBody `json:"device"`
|
||||
}
|
||||
|
||||
// WebTicketExchange handles POST /v1/auth/web-ticket/exchange (public, no
|
||||
// bearer auth — the ticket itself is the credential). Validates+consumes the
|
||||
// one-time ticket and issues the SAME token-pair shape as a normal login for
|
||||
// the ticket's user, registering the device like Login does. Invalid, expired,
|
||||
// or already-used tickets all map to a generic 401.
|
||||
func (h *Handler) WebTicketExchange(w http.ResponseWriter, r *http.Request) {
|
||||
var req webTicketExchangeRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
if req.Ticket == "" {
|
||||
writeAPIErr(w, ErrInvalidRequest, 0)
|
||||
return
|
||||
}
|
||||
pair, deviceLimit, apiErr := h.svc.LoginWithWebTicket(r.Context(), req.Ticket, clientIP(r), req.Device.toMeta())
|
||||
if apiErr != nil {
|
||||
writeAPIErr(w, apiErr, 0)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = json.NewEncoder(w).Encode(tokenPairResponse{
|
||||
AccessToken: pair.AccessToken,
|
||||
RefreshToken: pair.RefreshToken,
|
||||
ExpiresIn: pair.ExpiresIn,
|
||||
DeviceLimit: deviceLimit,
|
||||
})
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
// decodeJSON decodes the request body, writing a 400 on malformed input.
|
||||
@@ -206,7 +275,7 @@ func statusFor(e *apierr.Error) int {
|
||||
return http.StatusConflict
|
||||
case ErrRateLimited.Code, ErrAccountLocked.Code:
|
||||
return http.StatusTooManyRequests
|
||||
case ErrInvalidCredentials.Code, ErrInvalidToken.Code, ErrUnauthorized.Code:
|
||||
case ErrInvalidCredentials.Code, ErrInvalidToken.Code, ErrUnauthorized.Code, ErrTicketInvalid.Code:
|
||||
return http.StatusUnauthorized
|
||||
case ErrAccountBanned.Code:
|
||||
return http.StatusForbidden
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
|
||||
"github.com/wangjia/pangolin/server/internal/apierr"
|
||||
)
|
||||
|
||||
// ═══════════════ App → 网页免登录(一次性换票,magic-link)═══════════════
|
||||
//
|
||||
// The app (already holding a valid JWT) mints a one-time ticket via
|
||||
// IssueWebTicket and opens the system browser at .../sso?t=<ticket>. The web
|
||||
// user-center exchanges the ticket via LoginWithWebTicket for a normal token
|
||||
// pair — same shape as /auth/login — without ever seeing the app's own tokens.
|
||||
//
|
||||
// Storage: Redis only (no DB row). Tickets are single-instance, ephemeral
|
||||
// (60s TTL) credentials — a DB table would need its own cleanup job for what
|
||||
// Redis already gives for free via key expiry. Consumption uses GETDEL, which
|
||||
// is atomic server-side: a replayed/concurrent second exchange always loses
|
||||
// the race and gets redis.Nil, so single-use is enforced without a CAS dance.
|
||||
|
||||
const (
|
||||
// webTicketTTL is the ticket lifetime: long enough for the system browser
|
||||
// to open and hit the exchange endpoint, short enough to bound replay risk.
|
||||
webTicketTTL = 60 * time.Second
|
||||
// webTicketPrefix namespaces ticket keys in Redis.
|
||||
webTicketPrefix = "auth:webticket:"
|
||||
// scopeWebTicket is the rate-limit scope for ticket issuance (1/sec/user).
|
||||
scopeWebTicket = "webticket"
|
||||
)
|
||||
|
||||
func webTicketKey(ticket string) string { return webTicketPrefix + ticket }
|
||||
|
||||
// genWebTicket returns a fresh, cryptographically random one-time ticket. 32
|
||||
// bytes (256 bits) of entropy makes guessing infeasible within the 60s TTL.
|
||||
func genWebTicket() (string, error) {
|
||||
buf := make([]byte, 32)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", fmt.Errorf("auth: gen web ticket: %w", err)
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(buf), nil
|
||||
}
|
||||
|
||||
// IssueWebTicket mints a one-time ticket for an already-authenticated user
|
||||
// (userID/userUUID come from the caller's validated JWT claims — see
|
||||
// Handler.WebTicket). Rate-limited to 1/sec/user as a rebuff-abuse backstop
|
||||
// (the endpoint already requires login). The ticket value itself never
|
||||
// carries a real token — only enough to look the user up on exchange.
|
||||
func (s *Service) IssueWebTicket(ctx context.Context, userID int64, userUUID string) (ticket string, ttlSeconds int, retryAfter time.Duration, apiErr *apierr.Error) {
|
||||
ok, ra, err := s.rl.Allow(ctx, scopeWebTicket, strconv.FormatInt(userID, 10), 1, time.Second)
|
||||
if err != nil {
|
||||
return "", 0, 0, ErrInternal
|
||||
}
|
||||
if !ok {
|
||||
return "", 0, ra, ErrRateLimited
|
||||
}
|
||||
|
||||
tk, err := genWebTicket()
|
||||
if err != nil {
|
||||
return "", 0, 0, ErrInternal
|
||||
}
|
||||
val := userID2UUID(userID, userUUID)
|
||||
if err := s.rdb.Set(ctx, webTicketKey(tk), val, webTicketTTL).Err(); err != nil {
|
||||
return "", 0, 0, ErrInternal
|
||||
}
|
||||
return tk, int(webTicketTTL.Seconds()), 0, nil
|
||||
}
|
||||
|
||||
// LoginWithWebTicket validates+atomically-consumes a one-time ticket and
|
||||
// issues a fresh token pair for its user, registering the device exactly like
|
||||
// a normal Login (best-effort — see recordLogin). Any invalid/expired/
|
||||
// already-used ticket collapses to ErrTicketInvalid (no distinguishing info
|
||||
// leaked).
|
||||
func (s *Service) LoginWithWebTicket(ctx context.Context, ticket, ip string, device DeviceMeta) (*TokenPair, *DeviceLimit, *apierr.Error) {
|
||||
if ticket == "" {
|
||||
return nil, nil, ErrTicketInvalid
|
||||
}
|
||||
|
||||
// GETDEL is atomic: concurrent/replayed exchanges of the same ticket race
|
||||
// on a single Redis command, so exactly one caller ever sees the value.
|
||||
val, err := s.rdb.GetDel(ctx, webTicketKey(ticket)).Result()
|
||||
if errors.Is(err, redis.Nil) {
|
||||
return nil, nil, ErrTicketInvalid
|
||||
}
|
||||
if err != nil {
|
||||
return nil, nil, ErrInternal
|
||||
}
|
||||
|
||||
userID, userUUID, ok := splitUserID2UUID(val)
|
||||
if !ok {
|
||||
// Our own format, corrupt only via a Redis-level anomaly — never surface
|
||||
// internals to the (unauthenticated) caller.
|
||||
return nil, nil, ErrInternal
|
||||
}
|
||||
|
||||
pair, jti, err := s.tokens.IssueWithJTI(ctx, userID, userUUID)
|
||||
if err != nil {
|
||||
return nil, nil, ErrInternal
|
||||
}
|
||||
dl := s.recordLogin(ctx, userID, jti, ip, device)
|
||||
return pair, dl, nil
|
||||
}
|
||||
|
||||
// userID2UUID / splitUserID2UUID encode the ticket's Redis value as
|
||||
// "<userID>:<userUUID>". UUIDs are hyphenated hex (no ':'), so a single
|
||||
// SplitN(2) round-trips unambiguously.
|
||||
func userID2UUID(userID int64, userUUID string) string {
|
||||
return strconv.FormatInt(userID, 10) + ":" + userUUID
|
||||
}
|
||||
|
||||
func splitUserID2UUID(val string) (userID int64, userUUID string, ok bool) {
|
||||
parts := strings.SplitN(val, ":", 2)
|
||||
if len(parts) != 2 || parts[1] == "" {
|
||||
return 0, "", false
|
||||
}
|
||||
id, err := strconv.ParseInt(parts[0], 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
return 0, "", false
|
||||
}
|
||||
return id, parts[1], true
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// newWebTicketHandler wires a Handler with BOTH the public routes (via
|
||||
// RegisterRoutes) and the auth-required /auth/web-ticket route, mirroring how
|
||||
// main.go mounts them in two separate groups (public vs RequireAuth).
|
||||
func newWebTicketHandler(t *testing.T, cfg ServiceConfig) (*Service, http.Handler) {
|
||||
t.Helper()
|
||||
svc, _, _ := newService(t, cfg)
|
||||
h := NewHandler(svc)
|
||||
r := chi.NewRouter()
|
||||
h.RegisterRoutes(r) // public: includes /auth/web-ticket/exchange
|
||||
r.Group(func(protected chi.Router) {
|
||||
protected.Use(RequireAuth(svc.tokens))
|
||||
protected.Post("/auth/web-ticket", h.WebTicket)
|
||||
})
|
||||
return svc, r
|
||||
}
|
||||
|
||||
// issueAccessToken mints a real, valid access token for a fresh user id/uuid
|
||||
// pair — bypassing full register/login, since the ticket flow only cares that
|
||||
// RequireAuth's middleware injects a valid uid/uuid into the request context.
|
||||
func issueAccessToken(t *testing.T, svc *Service) (accessToken string, userID int64, userUUID string) {
|
||||
t.Helper()
|
||||
userID = 42
|
||||
userUUID = uuid.NewString()
|
||||
pair, _, err := svc.tokens.IssueWithJTI(context.Background(), userID, userUUID)
|
||||
if err != nil {
|
||||
t.Fatalf("issue access token: %v", err)
|
||||
}
|
||||
return pair.AccessToken, userID, userUUID
|
||||
}
|
||||
|
||||
func doAuthed(t *testing.T, h http.Handler, method, path, token string, body interface{}) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
if body != nil {
|
||||
_ = json.NewEncoder(&buf).Encode(body)
|
||||
}
|
||||
req := httptest.NewRequest(method, path, &buf)
|
||||
req.RemoteAddr = "203.0.113.5:1234"
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
// TestWebTicket_IssueAndExchange covers the full happy path: an authed app
|
||||
// mints a ticket, the (unauthenticated) web side exchanges it for a token
|
||||
// pair shaped exactly like a normal /auth/login response.
|
||||
func TestWebTicket_IssueAndExchange(t *testing.T) {
|
||||
svc, h := newWebTicketHandler(t, ServiceConfig{})
|
||||
token, wantUID, wantUUID := issueAccessToken(t, svc)
|
||||
|
||||
rec := doAuthed(t, h, http.MethodPost, "/auth/web-ticket", token, nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("issue status = %d, body %s", rec.Code, rec.Body)
|
||||
}
|
||||
var issued webTicketResponse
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &issued); err != nil {
|
||||
t.Fatalf("decode issue response: %v", err)
|
||||
}
|
||||
if issued.Ticket == "" {
|
||||
t.Fatal("empty ticket")
|
||||
}
|
||||
if issued.ExpiresIn != 60 {
|
||||
t.Fatalf("expires_in = %d, want 60", issued.ExpiresIn)
|
||||
}
|
||||
|
||||
rec = doAuthed(t, h, http.MethodPost, "/auth/web-ticket/exchange", "",
|
||||
map[string]any{"ticket": issued.Ticket, "device": map[string]string{"platform": "web"}})
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("exchange status = %d, body %s", rec.Code, rec.Body)
|
||||
}
|
||||
var pair tokenPairResponse
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &pair); err != nil {
|
||||
t.Fatalf("decode exchange response: %v", err)
|
||||
}
|
||||
if pair.AccessToken == "" || pair.RefreshToken == "" || pair.ExpiresIn != 900 {
|
||||
t.Fatalf("bad token pair: %+v", pair)
|
||||
}
|
||||
|
||||
// The exchanged access token must authenticate as the SAME user the app
|
||||
// ticket was issued for.
|
||||
claims, err := svc.tokens.ParseAccess(pair.AccessToken)
|
||||
if err != nil {
|
||||
t.Fatalf("parse exchanged access token: %v", err)
|
||||
}
|
||||
if claims.UID != wantUID || claims.Subject != wantUUID {
|
||||
t.Fatalf("exchanged token identifies uid=%d uuid=%s, want uid=%d uuid=%s",
|
||||
claims.UID, claims.Subject, wantUID, wantUUID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebTicket_SingleUse_ReplayRejected: a second exchange of the same
|
||||
// ticket (replay) must fail 401, even though the first succeeded.
|
||||
func TestWebTicket_SingleUse_ReplayRejected(t *testing.T) {
|
||||
svc, h := newWebTicketHandler(t, ServiceConfig{})
|
||||
token, _, _ := issueAccessToken(t, svc)
|
||||
|
||||
rec := doAuthed(t, h, http.MethodPost, "/auth/web-ticket", token, nil)
|
||||
var issued webTicketResponse
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &issued)
|
||||
|
||||
rec = doAuthed(t, h, http.MethodPost, "/auth/web-ticket/exchange", "",
|
||||
map[string]any{"ticket": issued.Ticket})
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("first exchange should succeed, got %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
|
||||
rec = doAuthed(t, h, http.MethodPost, "/auth/web-ticket/exchange", "",
|
||||
map[string]any{"ticket": issued.Ticket})
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("replayed exchange status = %d, want 401 (body %s)", rec.Code, rec.Body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebTicket_Expired_Rejected simulates TTL expiry the same way the rest of
|
||||
// this package does (service_test.go TestService_CodeExpired): delete the
|
||||
// Redis key directly rather than fast-forwarding a clock.
|
||||
func TestWebTicket_Expired_Rejected(t *testing.T) {
|
||||
svc, h := newWebTicketHandler(t, ServiceConfig{})
|
||||
token, _, _ := issueAccessToken(t, svc)
|
||||
|
||||
rec := doAuthed(t, h, http.MethodPost, "/auth/web-ticket", token, nil)
|
||||
var issued webTicketResponse
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &issued)
|
||||
|
||||
svc.rdb.Del(context.Background(), webTicketKey(issued.Ticket))
|
||||
|
||||
rec = doAuthed(t, h, http.MethodPost, "/auth/web-ticket/exchange", "",
|
||||
map[string]any{"ticket": issued.Ticket})
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expired ticket exchange status = %d, want 401 (body %s)", rec.Code, rec.Body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebTicket_Bogus_Rejected: a made-up ticket that was never issued.
|
||||
func TestWebTicket_Bogus_Rejected(t *testing.T) {
|
||||
_, h := newWebTicketHandler(t, ServiceConfig{})
|
||||
rec := doAuthed(t, h, http.MethodPost, "/auth/web-ticket/exchange", "",
|
||||
map[string]any{"ticket": "not-a-real-ticket"})
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("bogus ticket exchange status = %d, want 401 (body %s)", rec.Code, rec.Body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebTicket_RequiresAuth: minting a ticket without a bearer token is
|
||||
// rejected by RequireAuth before it ever reaches the handler.
|
||||
func TestWebTicket_RequiresAuth(t *testing.T) {
|
||||
_, h := newWebTicketHandler(t, ServiceConfig{})
|
||||
rec := doAuthed(t, h, http.MethodPost, "/auth/web-ticket", "", nil)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("unauthenticated issue status = %d, want 401", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebTicket_RateLimited: a second ticket request within the same second
|
||||
// for the same user is rejected 429 (rebuff-abuse backstop; the endpoint
|
||||
// already requires login).
|
||||
func TestWebTicket_RateLimited(t *testing.T) {
|
||||
svc, h := newWebTicketHandler(t, ServiceConfig{})
|
||||
token, _, _ := issueAccessToken(t, svc)
|
||||
|
||||
rec := doAuthed(t, h, http.MethodPost, "/auth/web-ticket", token, nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("first issue status = %d, body %s", rec.Code, rec.Body)
|
||||
}
|
||||
rec = doAuthed(t, h, http.MethodPost, "/auth/web-ticket", token, nil)
|
||||
if rec.Code != http.StatusTooManyRequests {
|
||||
t.Fatalf("second issue status = %d, want 429 (body %s)", rec.Code, rec.Body)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// defaultVersionManifestPath is VERSION_MANIFEST's default when unset — mirrors
|
||||
// how server.env wires DOWNLOADS_DIR alongside DownloadsHandler's own built-in
|
||||
// default (see downloads.go / deploy/single-node/deploy.sh).
|
||||
const defaultVersionManifestPath = "/etc/pangolin/version.yaml"
|
||||
|
||||
// Fallback values used ONLY when the manifest file itself is missing (fresh
|
||||
// box that hasn't run deploy/single-node/deploy.sh's manifest-install step
|
||||
// yet, or a local dev server). These are hand-set, not auto-derived — once a
|
||||
// box is deployed, the real source of truth is the on-disk manifest that
|
||||
// scripts/ci/release-client.sh overwrites on every client-v* release.
|
||||
const (
|
||||
defaultManifestVersion = "1.0.48"
|
||||
defaultManifestBuildNumber = 10048
|
||||
)
|
||||
|
||||
// changelogSection / changelogEntry mirror jiu's backend/config/version.yaml
|
||||
// shape (~/code/jiu/backend/internal/handler/version.go) so any future shared
|
||||
// tooling (e.g. a website changelog widget) can treat both services'
|
||||
// /version responses identically. Pangolin's release pipeline doesn't
|
||||
// populate Changelog yet (no CHANGELOG-client.md parsing wired in
|
||||
// scripts/ci/release-client.sh) — the field exists for shape-compatibility
|
||||
// and always serializes as [] rather than null.
|
||||
type changelogSection struct {
|
||||
Type string `yaml:"type" json:"type"`
|
||||
Items []string `yaml:"items" json:"items"`
|
||||
}
|
||||
|
||||
type changelogEntry struct {
|
||||
Version string `yaml:"version" json:"version"`
|
||||
Date string `yaml:"date" json:"date"`
|
||||
Intro string `yaml:"intro" json:"intro"`
|
||||
Sections []changelogSection `yaml:"sections" json:"sections"`
|
||||
}
|
||||
|
||||
// versionManifest is the on-disk (and wire) shape of the auto-update
|
||||
// manifest. download_urls keys in practice: macos, windows, ios, android
|
||||
// (web is intentionally not used — pangolin's client is native-only).
|
||||
type versionManifest struct {
|
||||
Version string `yaml:"version" json:"version"`
|
||||
BuildNumber int `yaml:"build_number" json:"build_number"`
|
||||
ForceUpdate bool `yaml:"force_update" json:"force_update"`
|
||||
ReleaseNotes string `yaml:"release_notes" json:"release_notes"`
|
||||
DownloadURLs map[string]string `yaml:"download_urls" json:"download_urls"`
|
||||
Changelog []changelogEntry `yaml:"changelog" json:"changelog"`
|
||||
}
|
||||
|
||||
// VersionHandler serves GET /version (public, no auth — mounted directly in
|
||||
// main.go next to /healthz and /downloads/*). Unlike most handlers in this
|
||||
// package it re-reads its manifest file from disk on EVERY request rather
|
||||
// than caching it in memory: scripts/ci/release-client.sh rewrites
|
||||
// /etc/pangolin/version.yaml's version/build_number on each client-v*
|
||||
// release, and that must take effect immediately without a control-plane
|
||||
// restart or redeploy (mirrors jiu's loadVersionConfig()-per-request).
|
||||
type VersionHandler struct {
|
||||
path string
|
||||
}
|
||||
|
||||
// NewVersionHandler builds a VersionHandler reading the manifest at path.
|
||||
// path=="" falls back to defaultVersionManifestPath (in production this is
|
||||
// overridden via the VERSION_MANIFEST env var — see main.go).
|
||||
func NewVersionHandler(path string) *VersionHandler {
|
||||
if path == "" {
|
||||
path = defaultVersionManifestPath
|
||||
}
|
||||
return &VersionHandler{path: path}
|
||||
}
|
||||
|
||||
// defaultManifest is returned when the manifest file doesn't exist yet, so
|
||||
// GET /version still answers usefully (client update-checks shouldn't hard
|
||||
// fail just because deploy/single-node/deploy.sh hasn't run on this box).
|
||||
func defaultManifest() versionManifest {
|
||||
return versionManifest{
|
||||
Version: defaultManifestVersion,
|
||||
BuildNumber: defaultManifestBuildNumber,
|
||||
ForceUpdate: false,
|
||||
ReleaseNotes: "",
|
||||
DownloadURLs: map[string]string{
|
||||
"android": "https://api.yanmeiai.com/downloads/pangolin-android.apk",
|
||||
"windows": "https://api.yanmeiai.com/downloads/pangolin-windows-x64-setup.exe",
|
||||
"macos": "",
|
||||
"ios": "",
|
||||
},
|
||||
Changelog: []changelogEntry{},
|
||||
}
|
||||
}
|
||||
|
||||
// loadManifest reads+parses h.path. A missing file is NOT an error (falls
|
||||
// back to defaultManifest()); any other read/parse failure IS, so ServeHTTP
|
||||
// can 500 instead of silently masking a corrupt manifest written by a bad
|
||||
// release-client.sh run.
|
||||
func (h *VersionHandler) loadManifest() (versionManifest, error) {
|
||||
data, err := os.ReadFile(h.path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return defaultManifest(), nil
|
||||
}
|
||||
return versionManifest{}, err
|
||||
}
|
||||
var m versionManifest
|
||||
if err := yaml.Unmarshal(data, &m); err != nil {
|
||||
return versionManifest{}, err
|
||||
}
|
||||
// Keep the JSON response shape stable (empty object/array, never null)
|
||||
// regardless of what the manifest on disk happens to omit.
|
||||
if m.DownloadURLs == nil {
|
||||
m.DownloadURLs = map[string]string{}
|
||||
}
|
||||
if m.Changelog == nil {
|
||||
m.Changelog = []changelogEntry{}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// Serve handles GET /version. Named Serve (not ServeHTTP) to match
|
||||
// DownloadsHandler's convention in this package (see downloads.go).
|
||||
func (h *VersionHandler) Serve(w http.ResponseWriter, r *http.Request) {
|
||||
m, err := h.loadManifest()
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"error": "version manifest unavailable"})
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = json.NewEncoder(w).Encode(m)
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// buildVersionRouter mounts VersionHandler on a real chi router (mirrors how
|
||||
// main.go mounts GET /version) so routing behaves exactly as in production.
|
||||
func buildVersionRouter(path string) chi.Router {
|
||||
h := NewVersionHandler(path)
|
||||
r := chi.NewRouter()
|
||||
r.Get("/version", h.Serve)
|
||||
return r
|
||||
}
|
||||
|
||||
func TestVersionHandler_ServesManifestFromDisk(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "version.yaml")
|
||||
yamlContent := `version: "1.2.3"
|
||||
build_number: 10203
|
||||
force_update: true
|
||||
release_notes: "测试发布说明"
|
||||
download_urls:
|
||||
android: "https://api.yanmeiai.com/downloads/pangolin-android.apk"
|
||||
windows: "https://api.yanmeiai.com/downloads/pangolin-windows-x64-setup.exe"
|
||||
macos: ""
|
||||
ios: ""
|
||||
changelog:
|
||||
- version: "1.2.3"
|
||||
date: "2026-07-06"
|
||||
intro: "小版本更新"
|
||||
sections:
|
||||
- type: "新增"
|
||||
items:
|
||||
- "示例条目"
|
||||
`
|
||||
if err := os.WriteFile(path, []byte(yamlContent), 0o644); err != nil {
|
||||
t.Fatalf("write fixture: %v", err)
|
||||
}
|
||||
|
||||
r := buildVersionRouter(path)
|
||||
req := httptest.NewRequest("GET", "/version", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
var got versionManifest
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
||||
t.Fatalf("unmarshal response: %v; body=%s", err, rec.Body.String())
|
||||
}
|
||||
|
||||
if got.Version != "1.2.3" {
|
||||
t.Errorf("version = %q, want %q", got.Version, "1.2.3")
|
||||
}
|
||||
if got.BuildNumber != 10203 {
|
||||
t.Errorf("build_number = %d, want %d", got.BuildNumber, 10203)
|
||||
}
|
||||
if !got.ForceUpdate {
|
||||
t.Errorf("force_update = false, want true")
|
||||
}
|
||||
if got.ReleaseNotes != "测试发布说明" {
|
||||
t.Errorf("release_notes = %q, want %q", got.ReleaseNotes, "测试发布说明")
|
||||
}
|
||||
if got.DownloadURLs["android"] != "https://api.yanmeiai.com/downloads/pangolin-android.apk" {
|
||||
t.Errorf("download_urls.android = %q", got.DownloadURLs["android"])
|
||||
}
|
||||
if got.DownloadURLs["windows"] != "https://api.yanmeiai.com/downloads/pangolin-windows-x64-setup.exe" {
|
||||
t.Errorf("download_urls.windows = %q", got.DownloadURLs["windows"])
|
||||
}
|
||||
if got.DownloadURLs["macos"] != "" || got.DownloadURLs["ios"] != "" {
|
||||
t.Errorf("expected empty macos/ios download URLs, got macos=%q ios=%q", got.DownloadURLs["macos"], got.DownloadURLs["ios"])
|
||||
}
|
||||
if len(got.Changelog) != 1 || got.Changelog[0].Version != "1.2.3" {
|
||||
t.Errorf("changelog = %+v, want 1 entry for version 1.2.3", got.Changelog)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVersionHandler_MissingFileReturnsDefault(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "does-not-exist.yaml")
|
||||
|
||||
r := buildVersionRouter(path)
|
||||
req := httptest.NewRequest("GET", "/version", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("status = %d, want 200 (missing file falls back to default manifest); body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
var got versionManifest
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
||||
t.Fatalf("unmarshal response: %v; body=%s", err, rec.Body.String())
|
||||
}
|
||||
if got.Version == "" {
|
||||
t.Errorf("default manifest: version should not be empty")
|
||||
}
|
||||
if got.DownloadURLs["android"] == "" {
|
||||
t.Errorf("default manifest: download_urls.android should not be empty")
|
||||
}
|
||||
if got.DownloadURLs["windows"] == "" {
|
||||
t.Errorf("default manifest: download_urls.windows should not be empty")
|
||||
}
|
||||
if got.Changelog == nil {
|
||||
t.Errorf("default manifest: changelog should be [] not null")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVersionHandler_JSONShape(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "version.yaml")
|
||||
if err := os.WriteFile(path, []byte(`version: "1.0.0"
|
||||
build_number: 10000
|
||||
force_update: false
|
||||
release_notes: ""
|
||||
download_urls:
|
||||
android: "https://example.com/a.apk"
|
||||
`), 0o644); err != nil {
|
||||
t.Fatalf("write fixture: %v", err)
|
||||
}
|
||||
|
||||
r := buildVersionRouter(path)
|
||||
req := httptest.NewRequest("GET", "/version", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
var raw map[string]json.RawMessage
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &raw); err != nil {
|
||||
t.Fatalf("unmarshal raw response: %v", err)
|
||||
}
|
||||
for _, key := range []string{"version", "build_number", "force_update", "release_notes", "download_urls", "changelog"} {
|
||||
if _, ok := raw[key]; !ok {
|
||||
t.Errorf("response missing expected top-level key %q; body=%s", key, rec.Body.String())
|
||||
}
|
||||
}
|
||||
// changelog must serialize as an array even when the manifest omits it —
|
||||
// front-ends should be able to blindly .map()/range over it.
|
||||
if string(raw["changelog"]) != "[]" {
|
||||
t.Errorf("changelog = %s, want []", raw["changelog"])
|
||||
}
|
||||
if ct := rec.Header().Get("Content-Type"); ct != "application/json; charset=utf-8" {
|
||||
t.Errorf("Content-Type = %q, want application/json; charset=utf-8", ct)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVersionHandler_DefaultPathFallback(t *testing.T) {
|
||||
h := NewVersionHandler("")
|
||||
if h.path != defaultVersionManifestPath {
|
||||
t.Errorf("NewVersionHandler(\"\").path = %q, want %q", h.path, defaultVersionManifestPath)
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,7 @@ export const viewport: Viewport = {
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="zh" data-theme="light">
|
||||
<html lang="zh" data-theme="light" suppressHydrationWarning>
|
||||
<head>
|
||||
{/* 设计令牌单一真相源,原样链入(SRI 由构建期注入) */}
|
||||
{/* eslint-disable-next-line @next/next/no-css-tags */}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
'use client';
|
||||
// app/sso/page.tsx — App→网页免登录落地页(镜像 jiu 的 web/sso.njk)。
|
||||
//
|
||||
// 流程:App 已持正常登录态,先 POST /v1/auth/web-ticket 签一次性票据(60s TTL、
|
||||
// 单次可用),再打开系统浏览器到 https://<usercenter>/sso/?t=<ticket>&redirect=<本站路径>。
|
||||
// 本页凭票 POST /v1/auth/web-ticket/exchange(公开端点)兑换与 login() 同形状的
|
||||
// token pair,经与登录页相同的 setSession(见 lib/api/http.ts::exchangeWebTicket)
|
||||
// 落地会话,再跳 redirect —— 其余页面(UserCenter.tsx)据此把它当作一次正常登录。
|
||||
//
|
||||
// 因 next.config.js 是 output:'export' 的纯静态导出,这里没有服务端 searchParams
|
||||
// 可用,票据与 redirect 目标都在挂载后从 window.location.search 读取。
|
||||
//
|
||||
// 安全要点(与 jiu 的 sso.njk 一致):
|
||||
// - URL 只携带一次性票据,从不携带真正的 access/refresh token;
|
||||
// - redirect 白名单:仅接受单个 '/' 开头、且不以 '//' 或反斜杠开头的本站相对
|
||||
// 路径,其余一律回退到 '/'(登录页),防 open redirect;
|
||||
// - 票据用后立即从地址栏抹掉(history.replaceState),不留浏览器历史;
|
||||
// - 兑换失败(票据无效/过期/已用)一律落回登录页('/'),不泄露失败原因细节。
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Mark } from '../../components/icons';
|
||||
import { ErrorLine } from '../../components/Login';
|
||||
import { card } from '../../components/shared';
|
||||
import { useUI } from '../../lib/theme';
|
||||
import { makeT } from '../../lib/i18n';
|
||||
import { getClient } from '../../lib/api/client';
|
||||
import { bilingual } from '../../lib/api/errors';
|
||||
|
||||
/** redirect 白名单:仅本站相对路径(单个 '/' 开头),其余一律回 '/'。 */
|
||||
function safeRedirect(raw: string | null): string {
|
||||
if (!raw) return '/';
|
||||
if (raw.charAt(0) !== '/' || raw.charAt(1) === '/' || raw.indexOf('\\') >= 0) return '/';
|
||||
return raw;
|
||||
}
|
||||
|
||||
export default function SsoPage() {
|
||||
const { lang } = useUI();
|
||||
const t = makeT(lang);
|
||||
const [failed, setFailed] = useState(false);
|
||||
const [msg, setMsg] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const ticket = params.get('t');
|
||||
const redirect = safeRedirect(params.get('redirect'));
|
||||
|
||||
// 票据只用一次,立刻从地址栏抹掉,不留浏览器历史(与 jiu 一致)。
|
||||
try {
|
||||
window.history.replaceState(null, '', '/sso/');
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
if (!ticket) {
|
||||
window.location.replace('/');
|
||||
return;
|
||||
}
|
||||
|
||||
const api = getClient();
|
||||
api
|
||||
.exchangeWebTicket(ticket)
|
||||
.then(() => {
|
||||
if (!alive) return;
|
||||
window.location.replace(redirect);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!alive) return;
|
||||
setMsg(bilingual(e, lang));
|
||||
setFailed(true);
|
||||
setTimeout(() => {
|
||||
window.location.replace('/');
|
||||
}, 1500);
|
||||
});
|
||||
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
// 仅挂载时读取一次 URL 并发起兑换;lang 变化不应重跑网络请求。
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'var(--bg)', padding: 24 }}>
|
||||
<div style={{ ...card, width: 420, maxWidth: '100%', padding: '36px 34px', boxSizing: 'border-box', textAlign: 'center' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'center', marginBottom: 18 }}>
|
||||
<Mark size={32} />
|
||||
</div>
|
||||
<div style={{ fontFamily: 'var(--font-display)', fontWeight: 700, fontSize: 20, color: 'var(--fg1)' }}>
|
||||
{failed ? t('ssoFailTitle') : t('ssoTitle')}
|
||||
</div>
|
||||
<div style={{ fontSize: 13.5, color: 'var(--fg3)', margin: '8px 0 0' }}>
|
||||
{failed ? t('ssoFailHint') : t('ssoMsg')}
|
||||
</div>
|
||||
{failed && msg && (
|
||||
<div style={{ marginTop: 16, display: 'flex', justifyContent: 'center' }}>
|
||||
<ErrorLine text={msg} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -100,10 +100,9 @@ export default function UserCenter() {
|
||||
setView('overview');
|
||||
}
|
||||
|
||||
if (!ready) {
|
||||
return <div style={{ minHeight: '100vh', background: 'var(--bg)' }} />;
|
||||
}
|
||||
if (!authed) {
|
||||
// 静态导出无服务端会话:首屏(!ready)与未登录一律直接渲染登录页,避免出现空白
|
||||
// 背景(慢网络下用户会看到"空的")。已登录用户(有 refresh)会话续期完成后再切面板。
|
||||
if (!ready || !authed) {
|
||||
return <Login onDone={() => { setAuthed(true); setView('overview'); }} />;
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ export const ERROR_TEXT: Record<string, { zh: string; en: string }> = {
|
||||
device_limit: { zh: '设备数量已达上限', en: 'Device limit reached' },
|
||||
device_not_found: { zh: '设备不存在', en: 'Device not found' },
|
||||
unauthorized: { zh: '登录已失效,请重新登录', en: 'Session expired, please log in again' },
|
||||
'auth.ticket_invalid': { zh: '登录票据无效或已过期,请重新从 App 打开', en: 'Login ticket is invalid or expired, please reopen from the app' },
|
||||
network: { zh: '网络异常,请稍后重试', en: 'Network error, please retry' },
|
||||
unknown: { zh: '操作失败,请稍后重试', en: 'Something went wrong, please retry' },
|
||||
};
|
||||
|
||||
@@ -142,6 +142,21 @@ export class HttpClient implements ApiClient {
|
||||
return session;
|
||||
}
|
||||
|
||||
// App→Web 免登录:POST /v1/auth/web-ticket/exchange(公开端点,无 Authorization 头)。
|
||||
// 服务端消费一次性票据后返回与 /v1/auth/login 相同的扁平 TokenPair(不会走 TOTP 分支——
|
||||
// 票据本身已代表 App 内已完成的完整登录),故直接映射 session 并 setSession,与
|
||||
// login()/loginTotp() 落地同一份会话状态。
|
||||
async exchangeWebTicket(ticket: string): Promise<Session> {
|
||||
const r = await this.request<RawTokenPair>('/v1/auth/web-ticket/exchange', {
|
||||
method: 'POST',
|
||||
body: { ticket },
|
||||
auth: false,
|
||||
});
|
||||
const session = mapSession(r);
|
||||
setSession(session);
|
||||
return session;
|
||||
}
|
||||
|
||||
async refresh(): Promise<Session> {
|
||||
const rt = getRefreshToken();
|
||||
if (!rt) throw new ApiError({ code: 'unauthorized', message_zh: '登录已失效', message_en: 'Session expired' });
|
||||
|
||||
@@ -64,6 +64,21 @@ export class MockClient implements ApiClient {
|
||||
return s;
|
||||
}
|
||||
|
||||
// 演示态:任意非 'invalid' 票据都兑换成功;'invalid' 用于验收失败态文案。
|
||||
async exchangeWebTicket(ticket: string): Promise<Session> {
|
||||
await delay(300);
|
||||
if (ticket === 'invalid') {
|
||||
throw new ApiError({
|
||||
code: 'auth.ticket_invalid',
|
||||
message_zh: '登录票据无效或已过期,请重新从 App 打开',
|
||||
message_en: 'Login ticket is invalid or expired, please reopen from the app',
|
||||
});
|
||||
}
|
||||
const s = makeSession();
|
||||
setSession(s);
|
||||
return s;
|
||||
}
|
||||
|
||||
async refresh(): Promise<Session> {
|
||||
await delay(150);
|
||||
const s = makeSession();
|
||||
|
||||
@@ -82,6 +82,8 @@ export interface ApiClient {
|
||||
login(email: string, password: string): Promise<LoginResult>;
|
||||
/** 登录二段式:提交 TOTP 动态码换取 session */
|
||||
loginTotp(pendingToken: string, code: string): Promise<Session>;
|
||||
/** App→Web 免登录:凭一次性票据兑换与 login() 同形状的会话(公开端点,无需 TOTP) */
|
||||
exchangeWebTicket(ticket: string): Promise<Session>;
|
||||
refresh(): Promise<Session>;
|
||||
getMe(): Promise<Me>;
|
||||
getSubscription(): Promise<SubscriptionInfo>;
|
||||
|
||||
@@ -27,6 +27,12 @@ export const STRINGS: Record<string, Entry> = {
|
||||
loginLocked: { zh: '失败次数过多,账户已临时锁定', en: 'Too many attempts — account temporarily locked' },
|
||||
lockedCountdown: { zh: '请于 {s} 秒后重试', en: 'Try again in {s}s' },
|
||||
|
||||
/* sso(App→网页免登录落地页) */
|
||||
ssoTitle: { zh: '正在登录…', en: 'Signing in…' },
|
||||
ssoMsg: { zh: '正在验证来自 App 的登录票据', en: 'Verifying the sign-in ticket from the app' },
|
||||
ssoFailTitle: { zh: '登录跳转失败', en: 'Sign-in redirect failed' },
|
||||
ssoFailHint: { zh: '即将跳转到登录页,请手动登录', en: 'Redirecting to the login page, please sign in manually' },
|
||||
|
||||
/* overview */
|
||||
greeting: { zh: '欢迎回来', en: 'Welcome back' },
|
||||
curPlan: { zh: '当前套餐', en: 'Current plan' },
|
||||
|
||||
@@ -6,14 +6,13 @@ import { SITE } from '../config/site';
|
||||
interface Props { t: T }
|
||||
const { t } = Astro.props;
|
||||
|
||||
// href 缺省 = 本轮未接入下载(iOS / macOS / Windows / Linux),按钮渲染为禁用态占位。
|
||||
// 本轮仅 Android 有真实产物;Windows 待其 CI(需 windows runner)产出 exe 后接
|
||||
// SITE.downloads.windows(已在 site.ts 备好)。
|
||||
// href 缺省 = 本轮未接入下载(iOS / macOS / Linux),按钮渲染为禁用态占位。
|
||||
// Android + Windows 已由客户端 CI 产出真实产物并部署到 /downloads。
|
||||
const plats: { icon: string; name: string; ver: string; href?: string }[] = [
|
||||
{ icon: 'smartphone', name: 'iOS', ver: 'iOS 16+' },
|
||||
{ icon: 'smartphone', name: 'Android', ver: 'Android 9+', href: SITE.downloads.android },
|
||||
{ icon: 'laptop', name: 'macOS', ver: 'macOS 12+' },
|
||||
{ icon: 'monitor', name: 'Windows', ver: 'Win 10/11' },
|
||||
{ icon: 'monitor', name: 'Windows', ver: 'Win 10/11', href: SITE.downloads.windows },
|
||||
{ icon: 'terminal', name: 'Linux', ver: 'deb / rpm' },
|
||||
];
|
||||
---
|
||||
|
||||
Reference in New Issue
Block a user