两份审查报告(`docs/reviews/electron-gui-review.md` /
`harmony-client-review.md`)里两条**数据隔离**缺陷。
## ① 换身份不清数据 ⇒ 在新账号的界面下显示旧账号的邮件
`setActive` 之后 `api/config` 单例里的 API_BASE 与 bearer 就翻到了新账号,
于是此后每个请求都带**新账号**的凭证。而各 store 里还留着**旧**账号的:
· mailStore.sent / currentMail
· sessionStore.sessions / currentSession / currentSessionMails
· contactStore.contacts / archivedContacts
⇒ 肉眼完全看不出来(不报错、不空屏),而**从旧视图发出的写操作**
(归档 / 转发 / 批准权限)改的是**新账号**。
新增 `src/lib/resetAccountData.ts` —— **一处实现,三个入口都调它**:
① 主动切账号(AccountSwitcher.pick)
② 登出(authStore.logout)
③ 任意接口 401(api/client.ts 的 unauthorized 回调 → markAnonymous)
只在 ① 里清是最容易漏的那种做法:② 和 ③ 各自还会重新泄露一次,
而它们都不在切换账号的代码路径上,grep 也找不到。
**身份变化有三条路径,清空也该有三条。**
★ 只碰**数据** store;`uiStore` 的 reset 仍由 App.tsx 负责
(它还要复位窄屏分栏、写信态那些纯界面状态)。
判据:`test/stores/resetAccountData.test.ts`(4 格)。
## ② 鸿蒙管理台门禁**失败开放**(fail-open)
`AdminUsersPage.ets` 原来的条件是 `roleKnown && !this.isAdmin`,
于是 `roleKnown === false`(loadRole() 失败、**身份还没读到**)
落进 else 分支 ⇒ **把完整管理台整个渲染出来**。
一次网络抖动 = 管理入口对所有人可见。
讽刺的是该文件自己的头注释写的就是正确规则
(「不能把读不到当成是管理员」)—— 代码做的正是这条注释禁止的事。
⇒ 改三态:`!roleKnown` 显示「正在确认身份…」、`!isAdmin` 显示墙、
否则管理台。
服务端 `middleware/user.go` 的 `AdminOnly` 仍在,所以**不是越权**;
但非管理员会看到完整用户列表、建号表单、改密入口 ——
属于客户端信息泄露 + 无意义的失败请求风暴。
186 lines
7.6 KiB
TypeScript
186 lines
7.6 KiB
TypeScript
import { create } from 'zustand';
|
||
|
||
import { activeAuth } from '../api/config';
|
||
import {
|
||
payloadFromLocal,
|
||
pullAppearance,
|
||
pushAppearance,
|
||
uploadAppearanceImage,
|
||
type AppearanceAuth
|
||
} from '../lib/appearance';
|
||
import { useAccountStore, isAggregate } from './accountStore';
|
||
import { useBackgroundStore } from './backgroundStore';
|
||
import { useThemeStore } from './themeStore';
|
||
|
||
/**
|
||
* 外观同步:服务端是权威,本地是缓存。
|
||
*
|
||
* 三条不变量(都有判据):
|
||
* 1. **登录后以服务端为准**(换设备/换浏览器能拿回来);
|
||
* 2. **本地改动会推上去**(换设备再看还是这个外观);
|
||
* 3. **拉下来的东西不会再推回去**(否则就是自己触发自己的回环 —— 一次拉取
|
||
* 会连带 PUT 一次,服务端 updated_at 被无意义地刷新,还会掩盖真实的本地改动)。
|
||
*
|
||
* 服务端不可达时状态是 `local-only`,界面上**必须看得见** —— 静默失败会让用户
|
||
* 以为"已经同步了",然后在另一台机器上发现没有,正是这次的缺陷。
|
||
*/
|
||
export type SyncStatus = 'idle' | 'synced' | 'pending' | 'local-only';
|
||
|
||
interface AppearanceSyncState {
|
||
status: SyncStatus;
|
||
/** 最近一次成功同步的时刻(ms)。0 = 从没成功过。 */
|
||
lastSyncedAt: number;
|
||
/** 正在把服务端的值灌进 store —— 此时不推回服务端。 */
|
||
applyingRemote: boolean;
|
||
pull: () => Promise<void>;
|
||
push: () => Promise<void>;
|
||
}
|
||
|
||
/**
|
||
* 上一次成功上传的壁纸(data URL),**按账号分桶**。
|
||
*
|
||
* ★ 为什么必须带账号维度:这是本模块唯一没做账号隔离的状态,而它恰好是最不能
|
||
* 漏的那一个。A 账号设壁纸 X(上传成功 ⇒ 记下 "A"→X)→ 切到 B 账号,而 B 的
|
||
* 服务端记录是 `saved:false` ⇒ `pull()` 走"把本地这份推上去"的分支 ⇒ 但
|
||
* `push()` 的跳过判据被 **A** 留下的标记满足 ⇒ **B 的壁纸永远传不上去**,
|
||
* 而状态仍被置为 `'synced'`。现象是"B 的壁纸在其它所有设备上都缺,
|
||
* 界面上却显示已同步"。
|
||
*
|
||
* 本来这个文件把 localStorage 键按账号隔离了(见 `lib/appearance.ts`),
|
||
* 漏的只有这个内存变量。用 `Map` 而不是单串,是因为它的生命周期就等于
|
||
* 一次运行期,而账号数很小,不需要额外持久化。
|
||
*/
|
||
const lastUploadedImage = new Map<string, string>();
|
||
|
||
/** 当前身份的稳定键。聚合视图下用固定值(聚合本来就不该单独上传)。 */
|
||
function currentAccountKey(): string {
|
||
const s = useAccountStore.getState();
|
||
return isAggregate(s) ? '__aggregate__' : s.activeId;
|
||
}
|
||
let pushTimer: ReturnType<typeof setTimeout> | null = null;
|
||
|
||
function currentAuth(): AppearanceAuth | null {
|
||
const a = activeAuth();
|
||
if (!a?.base) return null;
|
||
// 令牌可以为空:**浏览器密码登录走 cookie 会话**(桌面端粘贴用户密钥才设 Bearer)。
|
||
// 以前这里要求 token 非空,于是 WebUI 里这套同步从来不跑 —— 用户看到的就是
|
||
// "服务端存了外观、界面却毫无变化"。
|
||
return { base: a.base, token: a.token ?? '' };
|
||
}
|
||
|
||
export const useAppearanceSync = create<AppearanceSyncState>((set) => ({
|
||
status: 'idle',
|
||
lastSyncedAt: 0,
|
||
applyingRemote: false,
|
||
|
||
pull: async () => {
|
||
const auth = currentAuth();
|
||
if (!auth) {
|
||
// 没登录(或还没选账号):本地就是全部,且要让用户知道。
|
||
set({ status: 'local-only' });
|
||
return;
|
||
}
|
||
const remote = await pullAppearance(auth);
|
||
if (!remote) {
|
||
set({ status: 'local-only' });
|
||
return;
|
||
}
|
||
if (!remote.saved) {
|
||
// 服务端还没这份记录(第一次用这套同步,或换了新账号):
|
||
// **不能拿默认值覆盖本地** —— 反过来把本地这份推上去,作为账号的初始外观。
|
||
// 漏了这一条,每个老用户升级后第一次登录都会发现主题/壁纸被重置。
|
||
set({ status: 'pending' });
|
||
await useAppearanceSync.getState().push();
|
||
return;
|
||
}
|
||
|
||
set({ applyingRemote: true });
|
||
try {
|
||
useThemeStore.getState().setPref(remote.snapshot.theme);
|
||
const bg = useBackgroundStore.getState();
|
||
// 背景档与参数以服务端为准;图片只在服务端有图时才覆盖本地
|
||
// (服务端没图而本地有 = 本地还没推上去,别把用户的图擦掉)。
|
||
if (remote.snapshot.bgKind === 'preset') bg.setPreset(remote.snapshot.bgPresetId);
|
||
else if (remote.snapshot.bgKind === 'none') bg.setKind('none');
|
||
else if (remote.snapshot.bgKind === 'image' && remote.imageDataUrl) bg.setImage(remote.imageDataUrl);
|
||
if (remote.imageDataUrl) lastUploadedImage.set(currentAccountKey(), remote.imageDataUrl);
|
||
bg.setDim(remote.snapshot.bgDim);
|
||
bg.setBlur(remote.snapshot.bgBlur);
|
||
} finally {
|
||
set({ applyingRemote: false });
|
||
}
|
||
set({ status: 'synced', lastSyncedAt: Date.now() });
|
||
},
|
||
|
||
push: async () => {
|
||
const auth = currentAuth();
|
||
if (!auth) {
|
||
set({ status: 'local-only' });
|
||
return;
|
||
}
|
||
const theme = useThemeStore.getState().pref;
|
||
const bg = useBackgroundStore.getState();
|
||
const accountKey = currentAccountKey();
|
||
const payload = payloadFromLocal({
|
||
theme,
|
||
kind: bg.kind,
|
||
presetId: bg.presetId,
|
||
imageDataUrl: bg.imageDataUrl,
|
||
dim: bg.dim,
|
||
blur: bg.blur
|
||
});
|
||
|
||
let ok = await pushAppearance(auth, payload);
|
||
// 壁纸本体单独传:几 MB 的图不该每次都跟着 PUT 走,只在换图时传一次。
|
||
if (ok && bg.kind === 'image' && bg.imageDataUrl && bg.imageDataUrl !== lastUploadedImage.get(accountKey)) {
|
||
const uploaded = await uploadAppearanceImage(auth, bg.imageDataUrl);
|
||
if (uploaded) lastUploadedImage.set(accountKey, bg.imageDataUrl);
|
||
else ok = false;
|
||
}
|
||
set(ok ? { status: 'synced', lastSyncedAt: Date.now() } : { status: 'pending' });
|
||
}
|
||
}));
|
||
|
||
/** 本地一改就推(去抖),但**拉取期间不推**(见文件头第 3 条)。 */
|
||
function schedulePush(): void {
|
||
if (useAppearanceSync.getState().applyingRemote) return;
|
||
if (pushTimer) clearTimeout(pushTimer);
|
||
pushTimer = setTimeout(() => {
|
||
pushTimer = null;
|
||
void useAppearanceSync.getState().push();
|
||
}, 600);
|
||
}
|
||
|
||
/**
|
||
* 接线:订阅两个 store 的改动,并在账号切换时重新拉取。
|
||
*
|
||
* 账号切换必须重新拉 —— 外观是**账号级**的,而 localStorage 里的缓存键是全局的
|
||
* (这正是"放本地"时最直接的缺陷:同一台机器换账号背景不跟着走)。
|
||
*
|
||
* @returns 取消订阅(React 的 effect 清理用)
|
||
*/
|
||
export function initAppearanceSync(): () => void {
|
||
const unsubBg = useBackgroundStore.subscribe(schedulePush);
|
||
const unsubTheme = useThemeStore.subscribe(schedulePush);
|
||
const unsubAccount = useAccountStore.subscribe((state, prev) => {
|
||
if (state.activeId !== prev.activeId) {
|
||
/*
|
||
* 顺序:**先按新账号重读本地缓存,再拉服务端**。
|
||
*
|
||
* 为什么不能省这一步:服务端"没有记录"(`saved === false`)时 `pull()` 会
|
||
* 走"以本地为准推上去"——那时"本地"必须已经是**这个账号**的值。
|
||
* 全局键时代这里是上一个账号的值,于是新账号凭空继承了别人的外观,
|
||
* 而且被写进了服务端(pi 2026-09-14 复核时点出的那条缺陷)。
|
||
*/
|
||
useBackgroundStore.getState().reloadForAccount();
|
||
void useAppearanceSync.getState().pull();
|
||
}
|
||
});
|
||
return () => {
|
||
unsubBg();
|
||
unsubTheme();
|
||
unsubAccount();
|
||
if (pushTimer) clearTimeout(pushTimer);
|
||
};
|
||
}
|