Skip to content

Commit b2b6462

Browse files
committed
feat(sync): verify LatestNews API key before saving
1 parent 54e268d commit b2b6462

3 files changed

Lines changed: 78 additions & 4 deletions

File tree

shared/synchub-contract.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,29 @@ export function syncHubHeaders(apiKey: string) {
1818
"X-API-Key": apiKey.trim(),
1919
};
2020
}
21+
22+
export type SyncHubConnectionResult = { ok: true; message: string } | { ok: false; message: string };
23+
24+
export async function verifyLatestNewsSyncHubConnection(
25+
endpoint: string,
26+
apiKey: string,
27+
fetcher: typeof fetch = fetch
28+
): Promise<SyncHubConnectionResult> {
29+
const normalizedKey = apiKey.trim();
30+
if (!normalizedKey.startsWith("shk_")) {
31+
return { ok: false, message: "请输入有效的 LatestNews API Key" };
32+
}
33+
34+
try {
35+
const response = await fetcher(latestNewsSyncHubURL(endpoint, "favorites"), {
36+
headers: syncHubHeaders(normalizedKey),
37+
});
38+
const result = (await response.json()) as { code?: number | string; message?: string };
39+
if (response.ok || (response.status === 404 && result.code === "NOT_FOUND")) {
40+
return { ok: true, message: "连接成功,API Key 可用于 LatestNews 同步" };
41+
}
42+
return { ok: false, message: result.message || `连接失败(HTTP ${response.status})` };
43+
} catch {
44+
return { ok: false, message: "无法连接 SyncHub,请检查服务地址和网络" };
45+
}
46+
}

src/routes/history.tsx

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { useRelativeTime } from "~/hooks/useRelativeTime";
1313
import { useReadingState } from "~/hooks/useReadingState";
1414
import { getReadingStateList } from "@shared/reading-state";
1515
import { formatReadingHistoryExport } from "@shared/history-export";
16+
import { verifyLatestNewsSyncHubConnection } from "@shared/synchub-contract";
1617
import { filterReadingHistory, hasReadingHistoryFilters } from "@shared/history-filter";
1718
import {
1819
getSyncHubConfig,
@@ -78,6 +79,7 @@ function HistoryPage() {
7879
const [stateTab, setStateTab] = useState<HistoryTab>("history");
7980
const [syncHubEndpoint, setSyncHubEndpoint] = useState(() => getSyncHubConfig().endpoint);
8081
const [syncHubApiKey, setSyncHubApiKey] = useState(() => getSyncHubConfig().apiKey);
82+
const [syncHubChecking, setSyncHubChecking] = useState(false);
8183

8284
const sourceOptions = useMemo(() => {
8385
const map = new Map<string, { count: number; name: string }>();
@@ -182,20 +184,28 @@ function HistoryPage() {
182184
className="min-w-0 rounded-xl border border-zinc-200 bg-white px-3 py-2 text-sm outline-none focus:border-cyan-500 dark:border-zinc-700 dark:bg-zinc-800"
183185
/>
184186
<input
187+
type="password"
185188
value={syncHubApiKey}
186189
onChange={(event) => setSyncHubApiKey(event.target.value)}
187190
placeholder="LatestNews API Key (shk_...)"
188191
className="min-w-0 rounded-xl border border-zinc-200 bg-white px-3 py-2 text-sm outline-none focus:border-cyan-500 dark:border-zinc-700 dark:bg-zinc-800"
189192
/>
190193
<button
191194
type="button"
195+
disabled={syncHubChecking}
192196
onClick={() => {
193-
saveSyncHubConfig({ endpoint: syncHubEndpoint, apiKey: syncHubApiKey });
194-
window.location.reload();
197+
setSyncHubChecking(true);
198+
void verifyLatestNewsSyncHubConnection(syncHubEndpoint, syncHubApiKey).then((result) => {
199+
setSyncHubChecking(false);
200+
toaster(result.message, { type: result.ok ? "success" : "error" });
201+
if (!result.ok) return;
202+
saveSyncHubConfig({ endpoint: syncHubEndpoint, apiKey: syncHubApiKey });
203+
window.location.reload();
204+
});
195205
}}
196-
className="rounded-xl bg-cyan-500 px-3 py-2 text-sm font-medium text-zinc-900"
206+
className="rounded-xl bg-cyan-500 px-3 py-2 text-sm font-medium text-zinc-900 disabled:cursor-wait disabled:opacity-60"
197207
>
198-
保存同步
208+
{syncHubChecking ? "正在验证" : "验证并保存"}
199209
</button>
200210
{(syncHubEndpoint !== defaultSyncHubEndpoint || syncHubApiKey) && (
201211
<button

test/synchub-service.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
defaultSyncHubEndpoint,
77
normalizeSyncHubEndpoint,
88
latestNewsSyncHubCollections,
9+
verifyLatestNewsSyncHubConnection,
910
} from "../shared/synchub-contract";
1011

1112
describe("SyncHub service contract", () => {
@@ -15,6 +16,43 @@ describe("SyncHub service contract", () => {
1516
expect(normalizeSyncHubEndpoint(" https://self-hosted.example/ ")).toBe("https://self-hosted.example");
1617
});
1718

19+
it("verifies an application-bound API key before saving", async () => {
20+
const fetcher = async (input: string | URL | Request, init?: RequestInit) => {
21+
expect(input).toBe("https://sync.likanug.app/api/v1/metadata/latestnews/favorites");
22+
expect(init?.headers).toEqual({
23+
"Content-Type": "application/json",
24+
"X-API-Key": "shk_latestnews",
25+
});
26+
return new Response(JSON.stringify({ code: 0, message: "ok", data: { payload: [] } }), {
27+
status: 200,
28+
});
29+
};
30+
31+
await expect(verifyLatestNewsSyncHubConnection("", " shk_latestnews ", fetcher)).resolves.toEqual({
32+
ok: true,
33+
message: "连接成功,API Key 可用于 LatestNews 同步",
34+
});
35+
});
36+
37+
it("accepts an authenticated empty collection and reports rejected keys", async () => {
38+
const emptyFetcher = async () =>
39+
new Response(JSON.stringify({ code: "NOT_FOUND", message: "metadata document not found" }), {
40+
status: 404,
41+
});
42+
await expect(
43+
verifyLatestNewsSyncHubConnection("https://sync.example/", "shk_valid", emptyFetcher)
44+
).resolves.toMatchObject({ ok: true });
45+
46+
const rejectedFetcher = async () =>
47+
new Response(JSON.stringify({ code: "UNAUTHENTICATED", message: "invalid api key" }), {
48+
status: 401,
49+
});
50+
await expect(verifyLatestNewsSyncHubConnection("", "shk_rejected", rejectedFetcher)).resolves.toEqual({
51+
ok: false,
52+
message: "invalid api key",
53+
});
54+
});
55+
1856
it("uses the LatestNews application route and API key header", async () => {
1957
expect(latestNewsSyncHubCollections).toEqual(["reading-history", "favorites", "preferences"]);
2058
expect(latestNewsSyncHubURL("https://sync.likanug.app/", "favorites")).toBe(

0 commit comments

Comments
 (0)