fix(web): logout-path parity, CSP header, epub display fallback + small review nits
This commit is contained in:
@@ -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 前缀转发
|
||||||
|
|||||||
@@ -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],
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
setErr("EPUB 无法打开");
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
setReady(true);
|
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
@@ -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 } },
|
||||||
|
|||||||
Reference in New Issue
Block a user