fix(web): logout-path parity, CSP header, epub display fallback + small review nits

This commit is contained in:
2026-09-07 17:17:33 +08:00
parent 67c95e7bdc
commit cc66a648b0
6 changed files with 35 additions and 16 deletions
+3
View File
@@ -5,6 +5,9 @@ server {
# /etc/resolv.conf 的首个 nameserver 重写本行,兼容 podman aardvark-dns。 # /etc/resolv.conf 的首个 nameserver 重写本行,兼容 podman aardvark-dns。
resolver 127.0.0.11 valid=10s; resolver 127.0.0.11 valid=10s;
# spec §7 风险接受所假设的 CSP:全部同源,blob:/data: 供 SW/reader 用
add_header Content-Security-Policy "default-src 'self'; img-src 'self' blob: data:; worker-src 'self' blob:; style-src 'self' 'unsafe-inline'; connect-src 'self' blob: data:; object-src 'none'; frame-src 'self' blob:" always;
location /api/ { location /api/ {
set $api_upstream http://api:8080; # 变量式 → 每次按 DNS 解析,scale 后轮询到新副本(spec §11) set $api_upstream http://api:8080; # 变量式 → 每次按 DNS 解析,scale 后轮询到新副本(spec §11)
proxy_pass $api_upstream; # 无 URI 部分:保留 /api 前缀转发 proxy_pass $api_upstream; # 无 URI 部分:保留 /api 前缀转发
+14 -6
View File
@@ -18,8 +18,20 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const qc = useQueryClient(); const qc = useQueryClient();
const [token, setTok] = useState<string | null>(() => getToken()); const [token, setTok] = useState<string | null>(() => getToken());
// 401 到期登出与显式登出同样清理:跨用户数据不得残留在 query 缓存 / SW CacheStorage
const clearSession = () => {
qc.clear();
if (typeof caches !== "undefined") {
void caches.delete("booklib-api");
void caches.delete("booklib-immutable");
}
};
useEffect(() => { useEffect(() => {
const off = () => setTok(null); // client 的 401 拦截在这里回收 React 状态 const off = () => {
setTok(null); // client 的 401 拦截在这里回收 React 状态(token 已由 client.ts 清除)
clearSession();
};
window.addEventListener(LOGOUT_EVENT, off); window.addEventListener(LOGOUT_EVENT, off);
return () => window.removeEventListener(LOGOUT_EVENT, off); return () => window.removeEventListener(LOGOUT_EVENT, off);
}, []); }, []);
@@ -46,11 +58,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
logout: () => { logout: () => {
setToken(null); setToken(null);
setTok(null); setTok(null);
qc.clear(); clearSession();
if (typeof caches !== "undefined") {
void caches.delete("booklib-api");
void caches.delete("booklib-immutable");
}
}, },
}), }),
[meQ.data, meQ.isPending, meQ.isError, token, qc], [meQ.data, meQ.isPending, meQ.isError, token, qc],
+1 -1
View File
@@ -45,7 +45,7 @@ export default function AdminUsers() {
<h1 className="mb-4 text-lg font-semibold">用户管理</h1> <h1 className="mb-4 text-lg font-semibold">用户管理</h1>
<form onSubmit={submit} className={`${card} mb-6 flex max-w-2xl flex-wrap items-center gap-3 p-4`}> <form onSubmit={submit} className={`${card} mb-6 flex max-w-2xl flex-wrap items-center gap-3 p-4`}>
<input className={input} placeholder="用户名" value={u} onChange={(e) => setU(e.target.value)} /> <input className={input} placeholder="用户名" value={u} onChange={(e) => setU(e.target.value)} />
<input className={input} placeholder="密码(≥8 位)" value={p} onChange={(e) => setP(e.target.value)} /> <input className={input} type="password" placeholder="密码(≥8 位)" value={p} onChange={(e) => setP(e.target.value)} />
<select className={input} value={role} onChange={(e) => setRole(e.target.value as "member" | "admin")}> <select className={input} value={role} onChange={(e) => setRole(e.target.value as "member" | "admin")}>
<option value="member">member</option> <option value="member">member</option>
<option value="admin">admin</option> <option value="admin">admin</option>
+3 -1
View File
@@ -100,7 +100,9 @@ export default function CbzReader({ book, initialLocator }: ReaderProps) {
} }
} }
if (countQ.isPending) return <div className="grid h-full place-items-center text-zinc-500">页索引加载中…</div>; // pages_url 缺失时 query 被 disabled 永久 pending → 只有真正发起了请求才显示加载态
if (countQ.isPending && !!book.pages_url)
return <div className="grid h-full place-items-center text-zinc-500">页索引加载中…</div>;
if (countQ.isError || !book.page_url_fmt) if (countQ.isError || !book.page_url_fmt)
return ( return (
<div className="grid h-full place-items-center p-8 text-center text-zinc-500"> <div className="grid h-full place-items-center p-8 text-center text-zinc-500">
+13 -7
View File
@@ -8,7 +8,7 @@ interface EpubLocation {
start?: { cfi?: string; sectionIndex?: number }; start?: { cfi?: string; sectionIndex?: number };
} }
interface EpubRendition { interface EpubRendition {
display(target?: string): unknown; display(target?: string): Promise<unknown> | unknown;
next(): unknown; next(): unknown;
prev(): unknown; prev(): unknown;
on(ev: "relocated", cb: (loc: EpubLocation) => void): void; on(ev: "relocated", cb: (loc: EpubLocation) => void): void;
@@ -56,12 +56,18 @@ export default function EpubReader({ book, initialLocator }: ReaderProps) {
saver.report(cfi ? { cfi } : {}, Math.min(1, pct)); saver.report(cfi ? { cfi } : {}, Math.min(1, pct));
}); });
const cfi = typeof initialLocator?.cfi === "string" ? (initialLocator.cfi as string) : undefined; const cfi = typeof initialLocator?.cfi === "string" ? (initialLocator.cfi as string) : undefined;
try { // epubjs 对失效 cfi 以 reject Promise signalling,而非同步 throw → 走 then/catch 回退
r.display(cfi); // cfi 失效(书被替换等)时 epubjs 会抛 Promise.resolve(cfi ? r.display(cfi) : r.display())
} catch { .then(() => setReady(true))
r.display(); .catch(() => {
} if (!cfi) {
setReady(true); setErr("EPUB 无法打开");
return;
}
Promise.resolve(r.display())
.then(() => setReady(true))
.catch(() => setErr("EPUB 无法打开"));
});
})().catch((e) => !dead && setErr(e instanceof Error ? e.message : "EPUB 加载失败")); })().catch((e) => !dead && setErr(e instanceof Error ? e.message : "EPUB 加载失败"));
return () => { return () => {
dead = true; dead = true;
+1 -1
View File
@@ -27,7 +27,7 @@ export default defineConfig({
// ?v={hash} 的封面/CBZ 页/原文件 = 不可变 → cache-first(spec §6.1/§6.3) // ?v={hash} 的封面/CBZ 页/原文件 = 不可变 → cache-first(spec §6.1/§6.3)
urlPattern: ({ url }) => urlPattern: ({ url }) =>
url.pathname.startsWith("/api/books/") && url.pathname.startsWith("/api/books/") &&
url.search.includes("v=") && url.searchParams.has("v") &&
(/\/cover$/.test(url.pathname) || /\/pages\/\d+$/.test(url.pathname) || /\/file$/.test(url.pathname)), (/\/cover$/.test(url.pathname) || /\/pages\/\d+$/.test(url.pathname) || /\/file$/.test(url.pathname)),
handler: "CacheFirst", handler: "CacheFirst",
options: { cacheName: "booklib-immutable", expiration: { maxEntries: 3000, purgeOnQuotaError: true } }, options: { cacheName: "booklib-immutable", expiration: { maxEntries: 3000, purgeOnQuotaError: true } },