Compare commits

..

5 Commits

Author SHA1 Message Date
wangjia 84448b3064 fix(server/cors): 放行 X-Refresh-Token 头(修用户中心刷新掉线)
Deploy Server / deploy-server (push) Successful in 3m3s
用户中心用 header token 方案:access token 只存内存、refresh token 存
localStorage,页面刷新后靠 POST /v1/auth/refresh 带 X-Refresh-Token 头静默续期
(web/usercenter/lib/api/http.ts)。

但 CORS Allow-Headers 只放行 Content-Type/Authorization,浏览器预检发现
X-Refresh-Token 不在白名单 → 拦截真正的 refresh 请求 → api.refresh() 抛网络错
→ setAuthed(false) → 甩回登录页。表现:登录能成功(不带该头),但一刷新就掉线,
用量/续费等需登录态的页面全打不开。

修:Allow-Headers 加 X-Refresh-Token。加回归断言(cors_test)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 23:03:39 +08:00
wangjia 386e0bce90 fix(web): 语言下拉换自定义菜单(原生 select 弹层被系统定位/漂移)
Deploy Site / deploy-site (push) Successful in 4m31s
原生 <select> 的选项弹层由 OS 定位(macOS 锚在选中项上、整体偏移=漂移),CSS 控
不了。改成自控下拉:按钮(复用药丸样式)+ 绝对定位菜单(top:100%,right:0)+ 点外/
Esc 关闭 + 选中高亮。用户中心 LangSeg 与官网 Header 同款;官网选项用 <a href> 导航
(SEO 友好)、用户中心用 setLang。两端 build 通过。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
2026-07-07 22:42:00 +08:00
wangjia b584188ce2 fix(ci/site): 用 ! Content-Security-Policy 删官网 CSP(CF _headers 叠加,浏览器取交集)
Deploy Site / deploy-site (push) Successful in 4m36s
上一版判断错了:CF Pages _headers 不是'首个匹配生效',而是【叠加下发】——/* 和
/user/* 都匹配 /user/,两条 CSP 都发,浏览器对多条 CSP 取交集(最严)→ 官网严格
CSP 的 style-src(无 unsafe-inline)/connect-src('self')赢,用户中心内联样式被拦、
连 API 被拦 → 裸奔 + 登录失败。正解:/user/* 里  先删掉
/* 继承的官网 CSP,再设用户中心自己的(排 /* 之后,先加后删)。其余安全头沿用 /*。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
2026-07-07 22:25:36 +08:00
wangjia 60cd28e085 fix(ci/site): _headers 里 /user/* 须排在 /* 之前(CF 首个匹配 header 生效)
Deploy Site / deploy-site (push) Successful in 4m3s
用户中心 /user/ 页裸奔根因:CF Pages _headers 对同一 header 取【首个匹配规则】值,
而 combine-site 原把 /user/* 追加在官网 /* 之后 → /user/ 命中在前的官网严格 CSP
(style-src 无 unsafe-inline),用户中心的大量内联样式被拦、连 API 的 connect-src
也被收窄 → 样式全丢。改为把 /user/* 规则前置到官网 /* 之前,用户中心 CSP 才生效。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
2026-07-07 21:56:29 +08:00
wangjia 11ca7531a3 ci: flutter build 前显式 pub get + 重试(防 pub.flutter-io.cn 被 GFW 抖断)
Deploy Client / build-android (push) Successful in 1m46s
Deploy Client / build-windows (push) Successful in 1m55s
Deploy Client / build-macos (push) Successful in 3m40s
Deploy Client / build-ios (push) Successful in 3m28s
Deploy Client / release-deploy (push) Successful in 2m14s
iOS build 曾因 flutter build 内隐式 pub get 拉 google_fonts 时 pub.flutter-io.cn
socket error(exit 69)而失败。_env.sh 加 flutter_pub_get_retry(5 次重试),四个
compile 脚本在 flutter build 前显式预取,成功后 build 命中缓存不再拉网,减少偶发
网络失败。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
2026-07-07 20:25:01 +08:00
10 changed files with 186 additions and 45 deletions
+15
View File
@@ -29,3 +29,18 @@ ver_from_tag() {
ref="${ref#"${prefix}"-v}"
printf '%s' "$ref"
}
# flutter_pub_get_retry — pub.flutter-io.cn 常被 GFW 抖断(socket error / exit 69),
# 让 flutter build 内隐式的 pub get 偶发失败(见 iOS build 曾因 google_fonts 拉包
# 断线而挂)。构建前显式预取 + 重试;成功后 build 命中缓存不再拉网。在 flutter
# 项目根目录调用。
flutter_pub_get_retry() {
local i
for i in 1 2 3 4 5; do
if flutter pub get; then return 0; fi
echo "==> flutter pub get 失败(第 ${i}/5 次,pub.flutter-io.cn 抖?),8s 后重试..." >&2
sleep 8
done
echo "==> flutter pub get 5 次仍失败,放弃" >&2
return 1
}
+10 -5
View File
@@ -22,14 +22,19 @@ rm -rf "${DIST}/user"
mkdir -p "${DIST}/user"
cp -R "${UC}/." "${DIST}/user/"
# 2) 合并 _headers:用户中心路径规则前缀 /user,追加到官网 _headers 之后。
# 删掉并入产物里那份会被 CF 忽略的 /user/_headers,避免误导。
# 2) 合并 _headers 的 CSP。⚠️ CF Pages _headers 是【叠加】的:/* 与 /user/* 都匹配
# /user/,两条 CSP 都会下发,浏览器对多条 CSP 取【交集(最严)】→ 官网严格 CSP
# 的「style-src 无 unsafe-inline / connect-src 'self'」赢,用户中心的内联样式被拦、
# 连 API 被拦 → 页面裸奔且登录失败。故在 /user/* 里用 `! Content-Security-Policy`
# 先【删掉】/* 继承来的官网 CSP,再设用户中心自己的(须排在 /* 之后:先加后删)。
# 其余安全头(HSTS/X-Frame/…)/* 与用户中心一致,沿用 /* 即可,不重复。
rm -f "${DIST}/user/_headers"
{
echo ""
echo "# ==== 用户中心(/user/*)独立 CSP,combine-site.sh 从 usercenter/_headers 重定 ===="
# 行首 '/'(路径规则)前缀 /user;'#' 注释与缩进的 header 行不动。
sed -E 's#^/#/user/#' "${UC}/_headers"
echo "# ==== 用户中心(/user/*):删掉官网 /* 继承的 CSP,换用用户中心自己的 ===="
echo "/user/*"
echo " ! Content-Security-Policy"
grep -iE '^[[:space:]]*Content-Security-Policy:' "${UC}/_headers"
} >> "${DIST}/_headers"
echo "==> combine-site: done — 用户中心并入 ${DIST}/user/,_headers 已按 /user/* 分域合并"
+1
View File
@@ -131,6 +131,7 @@ EOF
# 现代绝大多数 Android 机)作为唯一下载,约 ~80MB;32 位老机(armeabi-v7a)/模拟器
# (x86_64)本轮不分发(需要再加)。这样官网 + deploy-client 仍是单一稳定 URL。
cd client
flutter_pub_get_retry # 预取包 + 重试,防 pub.flutter-io.cn 被 GFW 抖断(见 _env.sh)
flutter build apk --release --split-per-abi \
"--dart-define=PANGOLIN_API_URL=${API_URL}"
cd ..
+1
View File
@@ -173,6 +173,7 @@ EOF
# ── [6/6] flutter build ipa + 上传 TestFlight ───────────────────────────────
cd "${REPO_ROOT}/client"
flutter_pub_get_retry # 预取包 + 重试,防 pub.flutter-io.cn 被 GFW 抖断(见 _env.sh)
flutter build ipa --release \
--build-name="${VER}" \
--build-number="${BUILD}" \
+1
View File
@@ -182,6 +182,7 @@ echo "==> compile-macos: signing identity '${IDENTITY}'"
# 明),本步应已产出 Developer ID 签名 + hardened runtime 的 .app;下一步的
# inside-out 重签是幂等的安全网,不依赖这一步是否已经"恰好签对"。
cd "${REPO_ROOT}/client"
flutter_pub_get_retry # 预取包 + 重试,防 pub.flutter-io.cn 被 GFW 抖断(见 _env.sh)
flutter build macos --release --dart-define="PANGOLIN_API_URL=${API_URL}"
cd "$REPO_ROOT"
+1
View File
@@ -103,6 +103,7 @@ echo "==> compile-windows: pubspec version -> ${VER}+${BUILD}"
# on first real CI run that none of those get clobbered.
pushd "$BUILD_CLIENT" > /dev/null
flutter create --platforms=windows . --project-name pangolin_vpn
flutter_pub_get_retry # 预取包 + 重试,防 pub.flutter-io.cn 被 GFW 抖断(见 _env.sh)
flutter build windows --release "--dart-define=PANGOLIN_API_URL=${API_URL}"
popd > /dev/null
+4 -1
View File
@@ -35,7 +35,10 @@ func NewCORS() func(http.Handler) http.Handler {
h.Set("Access-Control-Allow-Origin", origin)
h.Add("Vary", "Origin")
h.Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
h.Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
// X-Refresh-Token:静默续期/登出把 refresh token 放在此自定义头里
// (header token 方案,见 web/usercenter/lib/api/http.ts)。不放行则浏览器
// 预检拦截 /v1/auth/refresh → 登录后一刷新即掉线(login 不带此头故不受影响)。
h.Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Refresh-Token")
h.Set("Access-Control-Max-Age", "600")
}
// 预检请求直接 204(不落到业务路由,避免 /v1/... 的 OPTIONS 404)。
+6
View File
@@ -3,6 +3,7 @@ package httpapi
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)
@@ -35,6 +36,11 @@ func TestCORS(t *testing.T) {
if w.Header().Get("Access-Control-Allow-Methods") == "" {
t.Fatalf("preflight missing Allow-Methods")
}
// 静默续期/登出把 refresh token 放 X-Refresh-Token 头,必须在允许头里,
// 否则浏览器预检拦截 /v1/auth/refresh → 登录后一刷新即掉线。
if ah := w.Header().Get("Access-Control-Allow-Headers"); !strings.Contains(ah, "X-Refresh-Token") {
t.Fatalf("preflight Allow-Headers must include X-Refresh-Token, got %q", ah)
}
// 未白名单 Origin:不补 Allow-Origin。
r = httptest.NewRequest("POST", "/v1/auth/login", nil)
+105 -28
View File
@@ -1,5 +1,6 @@
// shared.tsx — 公共样式常量与原子(承袭 ucapp.jsx 的 ucCard / ucInput / UCLang
import React from 'react';
'use client';
import React, { useEffect, useRef, useState } from 'react';
import type { Lang } from '../lib/i18n';
export const card: React.CSSProperties = {
@@ -22,34 +23,110 @@ export const input: React.CSSProperties = {
boxSizing: 'border-box',
};
const LANGS: [Lang, string][] = [
['en', 'English'], ['zh', '中文'], ['ja', '日本語'],
['ko', '한국어'], ['ru', 'Русский'], ['es', 'Español'],
];
// 自定义下拉:原生 <select> 的弹层由系统定位(macOS 锚在选中项、会"漂移"),
// 改成自控菜单(按钮 + 绝对定位列表),不漂移、样式统一、跨端一致。
export function LangSeg({ lang, setLang }: { lang: Lang; setLang: (l: Lang) => void }) {
// 6 语言用原生下拉(段控横排会挤)。默认 en。
const langs: [Lang, string][] = [
['en', 'English'], ['zh', '中文'], ['ja', '日本語'],
['ko', '한국어'], ['ru', 'Русский'], ['es', 'Español'],
];
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const onDoc = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false); };
document.addEventListener('mousedown', onDoc);
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('mousedown', onDoc);
document.removeEventListener('keydown', onKey);
};
}, [open]);
const current = LANGS.find(([v]) => v === lang)?.[1] ?? String(lang);
return (
<select
value={lang}
onChange={(e) => setLang(e.target.value as Lang)}
aria-label="Language"
style={{
// 圆角矩形 + 柔和边框(原纯 --border 太生硬)。
border: '1px solid color-mix(in oklab, var(--border), transparent 55%)',
cursor: 'pointer',
borderRadius: 10,
padding: '5px 10px',
fontFamily: 'var(--font-sans)',
fontSize: 12.5,
fontWeight: 700,
background: 'var(--bg-subtle)',
color: 'var(--fg2)',
boxShadow: '0 1px 2px rgba(45, 30, 20, 0.05)',
}}
>
{langs.map(([v, l]) => (
<option key={v} value={v}>{l}</option>
))}
</select>
<div ref={ref} style={{ position: 'relative', display: 'inline-block' }}>
<button
type="button"
onClick={() => setOpen((o) => !o)}
aria-haspopup="listbox"
aria-expanded={open}
aria-label="Language"
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 6,
border: '1px solid color-mix(in oklab, var(--border), transparent 55%)',
cursor: 'pointer',
borderRadius: 10,
padding: '5px 10px',
fontFamily: 'var(--font-sans)',
fontSize: 12.5,
fontWeight: 700,
background: 'var(--bg-subtle)',
color: 'var(--fg2)',
boxShadow: '0 1px 2px rgba(45, 30, 20, 0.05)',
}}
>
<span>{current}</span>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" aria-hidden="true"
style={{ transform: open ? 'rotate(180deg)' : 'none', transition: 'transform 140ms' }}>
<path d="M6 9l6 6 6-6" stroke="currentColor" strokeWidth="2.4"
strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
{open && (
<div
role="listbox"
style={{
position: 'absolute',
top: 'calc(100% + 6px)',
right: 0,
minWidth: 132,
background: 'var(--surface)',
border: '1px solid var(--border)',
borderRadius: 12,
boxShadow: '0 10px 30px rgba(20, 12, 6, 0.22)',
padding: 5,
zIndex: 60,
display: 'flex',
flexDirection: 'column',
gap: 1,
}}
>
{LANGS.map(([v, l]) => {
const on = v === lang;
return (
<button
key={v}
type="button"
role="option"
aria-selected={on}
onClick={() => { setLang(v); setOpen(false); }}
style={{
textAlign: 'left',
border: 'none',
cursor: 'pointer',
background: on ? 'var(--accent-subtle, var(--bg-subtle))' : 'transparent',
color: on ? 'var(--accent)' : 'var(--fg1)',
fontWeight: on ? 700 : 500,
fontFamily: 'var(--font-sans)',
fontSize: 13,
padding: '8px 11px',
borderRadius: 8,
whiteSpace: 'nowrap',
}}
>
{l}
</button>
);
})}
</div>
)}
</div>
);
}
+42 -11
View File
@@ -3,7 +3,7 @@
* 迁自 design/ui_kits/website/index.html 的 <header> + site.js 的菜单/滚动毛玻璃逻辑。
* 语言切换由原型的 JS 文本替换改为「路由跳转」(zh=/, en=/en/),单显不并排(铁律 6)。
*/
import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { Download, Menu } from 'lucide-react';
import { SITE } from '../config/site';
@@ -29,6 +29,17 @@ function Mark() {
export default function Header({ lang = 'zh', t = {} }) {
const [open, setOpen] = useState(false);
const [scrolled, setScrolled] = useState(false);
const [langOpen, setLangOpen] = useState(false);
const langRef = useRef(null);
useEffect(() => {
if (!langOpen) return;
const onDoc = (e) => { if (langRef.current && !langRef.current.contains(e.target)) setLangOpen(false); };
const onKey = (e) => { if (e.key === 'Escape') setLangOpen(false); };
document.addEventListener('mousedown', onDoc);
document.addEventListener('keydown', onKey);
return () => { document.removeEventListener('mousedown', onDoc); document.removeEventListener('keydown', onKey); };
}, [langOpen]);
useEffect(() => {
const onScroll = () => setScrolled(window.scrollY > 8);
@@ -64,16 +75,36 @@ export default function Header({ lang = 'zh', t = {} }) {
))}
</nav>
<div class="right">
<select
class="langsel"
value={lang}
onChange={(e) => { window.location.href = langHref(e.target.value); }}
aria-label="Language"
>
{langs.map(([code, label]) => (
<option key={code} value={code}>{label}</option>
))}
</select>
<div ref={langRef} style={{ position: 'relative', display: 'inline-block' }}>
<button
type="button"
class="langsel"
onClick={() => setLangOpen((o) => !o)}
aria-haspopup="listbox"
aria-expanded={langOpen}
aria-label="Language"
style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}
>
<span>{(langs.find(([c]) => c === lang) || ['', 'English'])[1]}</span>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" aria-hidden="true"
style={{ transform: langOpen ? 'rotate(180deg)' : 'none', transition: 'transform 140ms' }}>
<path d="M6 9l6 6 6-6" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
{langOpen && (
<div role="listbox" style={{ position: 'absolute', top: 'calc(100% + 6px)', right: 0, minWidth: 132, background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 12, boxShadow: '0 10px 30px rgba(20, 12, 6, 0.22)', padding: 5, zIndex: 60, display: 'flex', flexDirection: 'column', gap: 1 }}>
{langs.map(([code, label]) => {
const on = code === lang;
return (
<a key={code} role="option" aria-selected={on} href={langHref(code)}
style={{ textAlign: 'left', textDecoration: 'none', background: on ? 'var(--accent-subtle, var(--bg-subtle))' : 'transparent', color: on ? 'var(--accent)' : 'var(--fg1)', fontWeight: on ? 700 : 500, fontFamily: 'var(--font-sans)', fontSize: 13, padding: '8px 11px', borderRadius: 8, whiteSpace: 'nowrap' }}>
{label}
</a>
);
})}
</div>
)}
</div>
<a class="linklogin" href={SITE.usercenter}>{t.login}</a>
<a class="btn btn-primary" href="#download">
<Download />