新增案例库浏览器登录态
This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
DELETE as deleteSession,
|
||||
GET as getSession,
|
||||
POST as createSession,
|
||||
} from "../auth/session/route";
|
||||
|
||||
describe("auth session API", () => {
|
||||
const originalApiKey = process.env.API_ACCESS_KEY;
|
||||
const originalAuthDisabled = process.env.API_AUTH_DISABLED;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.API_ACCESS_KEY = "test-key";
|
||||
process.env.API_AUTH_DISABLED = "false";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env.API_ACCESS_KEY = originalApiKey;
|
||||
process.env.API_AUTH_DISABLED = originalAuthDisabled;
|
||||
});
|
||||
|
||||
it("creates an HttpOnly browser session after validating the access key", async () => {
|
||||
const response = await createSession(request({ api_access_key: "test-key" }));
|
||||
const body = (await response.json()) as { authenticated: boolean };
|
||||
const setCookie = response.headers.get("set-cookie") ?? "";
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(body.authenticated).toBe(true);
|
||||
expect(setCookie).toContain("geo_api_session=");
|
||||
expect(setCookie).toContain("HttpOnly");
|
||||
expect(setCookie).toMatch(/SameSite=Lax/i);
|
||||
expect(setCookie).toContain("Path=/");
|
||||
expect(setCookie).not.toContain("test-key");
|
||||
});
|
||||
|
||||
it("rejects invalid access keys without setting a session cookie", async () => {
|
||||
const response = await createSession(request({ api_access_key: "wrong" }));
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.headers.get("set-cookie")).toBeNull();
|
||||
});
|
||||
|
||||
it("reports the current browser session state", async () => {
|
||||
const login = await createSession(request({ api_access_key: "test-key" }));
|
||||
const cookie = login.headers.get("set-cookie")?.split(";")[0] ?? "";
|
||||
|
||||
const anonymous = await getSession(new Request("http://localhost/api/auth/session"));
|
||||
const authenticated = await getSession(
|
||||
new Request("http://localhost/api/auth/session", {
|
||||
headers: { cookie },
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(anonymous.json()).resolves.toEqual({ authenticated: false });
|
||||
await expect(authenticated.json()).resolves.toEqual({ authenticated: true });
|
||||
});
|
||||
|
||||
it("clears the browser session cookie", async () => {
|
||||
const response = await deleteSession(
|
||||
new Request("http://localhost/api/auth/session", { method: "DELETE" }),
|
||||
);
|
||||
const setCookie = response.headers.get("set-cookie") ?? "";
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
await expect(response.json()).resolves.toEqual({ authenticated: false });
|
||||
expect(setCookie).toContain("geo_api_session=");
|
||||
expect(setCookie).toContain("Max-Age=0");
|
||||
expect(setCookie).toContain("HttpOnly");
|
||||
});
|
||||
});
|
||||
|
||||
function request(body: unknown) {
|
||||
return new Request("http://localhost/api/auth/session", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import {
|
||||
apiSessionCookieName,
|
||||
createApiSessionToken,
|
||||
getConfiguredApiAccessKey,
|
||||
hasValidApiSession,
|
||||
} from "../../../../lib/api/auth";
|
||||
|
||||
const sessionMaxAgeSeconds = 60 * 60 * 24 * 30;
|
||||
|
||||
export async function GET(request: Request) {
|
||||
if (process.env.API_AUTH_DISABLED === "true") {
|
||||
return NextResponse.json({ authenticated: true });
|
||||
}
|
||||
|
||||
const apiAccessKey = getConfiguredApiAccessKey();
|
||||
return NextResponse.json({
|
||||
authenticated: apiAccessKey
|
||||
? hasValidApiSession(request, apiAccessKey)
|
||||
: false,
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (process.env.API_AUTH_DISABLED === "true") {
|
||||
return NextResponse.json({ authenticated: true });
|
||||
}
|
||||
|
||||
const apiAccessKey = getConfiguredApiAccessKey();
|
||||
if (!apiAccessKey) {
|
||||
return NextResponse.json(
|
||||
{ error: "API access key is not configured" },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
const body = (await request.json().catch(() => ({}))) as {
|
||||
api_access_key?: unknown;
|
||||
apiAccessKey?: unknown;
|
||||
};
|
||||
const provided =
|
||||
typeof body.api_access_key === "string"
|
||||
? body.api_access_key
|
||||
: body.apiAccessKey;
|
||||
|
||||
if (provided !== apiAccessKey) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const response = NextResponse.json({ authenticated: true });
|
||||
response.cookies.set({
|
||||
name: apiSessionCookieName,
|
||||
value: createApiSessionToken(apiAccessKey),
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: new URL(request.url).protocol === "https:",
|
||||
path: "/",
|
||||
maxAge: sessionMaxAgeSeconds,
|
||||
});
|
||||
return response;
|
||||
}
|
||||
|
||||
export async function DELETE(request: Request) {
|
||||
const response = NextResponse.json({ authenticated: false });
|
||||
response.cookies.set({
|
||||
name: apiSessionCookieName,
|
||||
value: "",
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: new URL(request.url).protocol === "https:",
|
||||
path: "/",
|
||||
maxAge: 0,
|
||||
});
|
||||
return response;
|
||||
}
|
||||
@@ -128,6 +128,22 @@ h3 {
|
||||
min-width: min(16rem, 100%);
|
||||
}
|
||||
|
||||
.api-session-control {
|
||||
align-items: end;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
justify-content: flex-end;
|
||||
max-width: 28rem;
|
||||
}
|
||||
|
||||
.api-session-control small {
|
||||
color: #586174;
|
||||
flex-basis: 100%;
|
||||
font-size: 0.78rem;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.text-link {
|
||||
color: #2f6fdd;
|
||||
font-weight: 800;
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
apiFetch,
|
||||
getApiSession,
|
||||
loginApiSession,
|
||||
logoutApiSession,
|
||||
} from "../api-client";
|
||||
|
||||
describe("case API session client", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("checks browser session state with same-origin credentials", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => jsonResponse({ authenticated: true })),
|
||||
);
|
||||
|
||||
await expect(getApiSession()).resolves.toEqual({ authenticated: true });
|
||||
expect(fetch).toHaveBeenCalledWith("/api/auth/session", {
|
||||
credentials: "same-origin",
|
||||
});
|
||||
});
|
||||
|
||||
it("creates a browser session with the provided access key", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => jsonResponse({ authenticated: true })),
|
||||
);
|
||||
|
||||
await expect(loginApiSession("local-dev-key")).resolves.toEqual({
|
||||
authenticated: true,
|
||||
});
|
||||
expect(fetch).toHaveBeenCalledWith("/api/auth/session", {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ api_access_key: "local-dev-key" }),
|
||||
});
|
||||
});
|
||||
|
||||
it("surfaces login errors from the session endpoint", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => jsonResponse({ error: "Unauthorized" }, 401)),
|
||||
);
|
||||
|
||||
await expect(loginApiSession("wrong")).resolves.toEqual({
|
||||
authenticated: false,
|
||||
error: "Unauthorized",
|
||||
});
|
||||
});
|
||||
|
||||
it("clears the browser session", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => jsonResponse({ authenticated: false })),
|
||||
);
|
||||
|
||||
await expect(logoutApiSession()).resolves.toEqual({ authenticated: false });
|
||||
expect(fetch).toHaveBeenCalledWith("/api/auth/session", {
|
||||
method: "DELETE",
|
||||
credentials: "same-origin",
|
||||
});
|
||||
});
|
||||
|
||||
it("adds same-origin credentials to protected API requests", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn(async () => jsonResponse({ ok: true })));
|
||||
|
||||
await apiFetch("/api/cases", { headers: { accept: "application/json" } });
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith("/api/cases", {
|
||||
credentials: "same-origin",
|
||||
headers: { accept: "application/json" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function jsonResponse(body: unknown, status = 200) {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
export const apiAccessStorageKey = "geo-api-access-key";
|
||||
export interface ApiSessionResult {
|
||||
authenticated: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function apiHeaders(apiAccessKey: string) {
|
||||
const headers: Record<string, string> = { "content-type": "application/json" };
|
||||
@@ -8,17 +11,43 @@ export function apiHeaders(apiAccessKey: string) {
|
||||
return headers;
|
||||
}
|
||||
|
||||
export function readStoredApiAccessKey() {
|
||||
if (typeof window === "undefined") return "";
|
||||
return window.sessionStorage.getItem(apiAccessStorageKey) ?? "";
|
||||
export function apiFetch(input: RequestInfo | URL, init: RequestInit = {}) {
|
||||
return fetch(input, {
|
||||
...init,
|
||||
credentials: init.credentials ?? "same-origin",
|
||||
});
|
||||
}
|
||||
|
||||
export function storeApiAccessKey(value: string) {
|
||||
if (typeof window === "undefined") return;
|
||||
if (value) {
|
||||
window.sessionStorage.setItem(apiAccessStorageKey, value);
|
||||
} else {
|
||||
window.sessionStorage.removeItem(apiAccessStorageKey);
|
||||
}
|
||||
export async function getApiSession(): Promise<ApiSessionResult> {
|
||||
const response = await apiFetch("/api/auth/session");
|
||||
return readSessionResponse(response);
|
||||
}
|
||||
|
||||
export async function loginApiSession(
|
||||
apiAccessKey: string,
|
||||
): Promise<ApiSessionResult> {
|
||||
const response = await apiFetch("/api/auth/session", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ api_access_key: apiAccessKey }),
|
||||
});
|
||||
return readSessionResponse(response);
|
||||
}
|
||||
|
||||
export async function logoutApiSession(): Promise<ApiSessionResult> {
|
||||
const response = await apiFetch("/api/auth/session", {
|
||||
method: "DELETE",
|
||||
});
|
||||
return readSessionResponse(response);
|
||||
}
|
||||
|
||||
async function readSessionResponse(response: Response): Promise<ApiSessionResult> {
|
||||
const body = (await response.json().catch(() => ({}))) as {
|
||||
authenticated?: unknown;
|
||||
error?: unknown;
|
||||
};
|
||||
return {
|
||||
authenticated: response.ok && body.authenticated === true,
|
||||
error: typeof body.error === "string" ? body.error : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, type FormEvent } from "react";
|
||||
|
||||
import {
|
||||
getApiSession,
|
||||
loginApiSession,
|
||||
logoutApiSession,
|
||||
} from "./api-client";
|
||||
|
||||
interface CaseAuthControlProps {
|
||||
authenticated: boolean;
|
||||
onAuthenticatedChange: (authenticated: boolean) => void;
|
||||
}
|
||||
|
||||
export function CaseAuthControl({
|
||||
authenticated,
|
||||
onAuthenticatedChange,
|
||||
}: CaseAuthControlProps) {
|
||||
const [apiAccessKey, setApiAccessKey] = useState("");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [isChecking, setIsChecking] = useState(true);
|
||||
const [message, setMessage] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
let isCurrent = true;
|
||||
getApiSession()
|
||||
.then((session) => {
|
||||
if (!isCurrent) return;
|
||||
onAuthenticatedChange(session.authenticated);
|
||||
setMessage(session.authenticated ? "已登录" : "");
|
||||
})
|
||||
.catch(() => {
|
||||
if (!isCurrent) return;
|
||||
onAuthenticatedChange(false);
|
||||
setMessage("登录状态检查失败");
|
||||
})
|
||||
.finally(() => {
|
||||
if (isCurrent) setIsChecking(false);
|
||||
});
|
||||
return () => {
|
||||
isCurrent = false;
|
||||
};
|
||||
}, [onAuthenticatedChange]);
|
||||
|
||||
async function submit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
const trimmed = apiAccessKey.trim();
|
||||
if (!trimmed) {
|
||||
setMessage("请输入访问密钥。");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
setMessage("");
|
||||
try {
|
||||
const session = await loginApiSession(trimmed);
|
||||
onAuthenticatedChange(session.authenticated);
|
||||
if (session.authenticated) {
|
||||
setApiAccessKey("");
|
||||
setMessage("已登录");
|
||||
} else {
|
||||
setMessage(session.error ?? "访问密钥无效或已失效");
|
||||
}
|
||||
} catch (error) {
|
||||
onAuthenticatedChange(false);
|
||||
setMessage(error instanceof Error ? error.message : "登录失败");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
setIsSubmitting(true);
|
||||
setMessage("");
|
||||
try {
|
||||
await logoutApiSession();
|
||||
onAuthenticatedChange(false);
|
||||
setMessage("已退出登录。");
|
||||
} catch (error) {
|
||||
setMessage(error instanceof Error ? error.message : "退出登录失败");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (authenticated) {
|
||||
return (
|
||||
<div className="api-session-control" aria-label="访问密钥登录状态">
|
||||
<span className="status-pill pass">已登录</span>
|
||||
<button
|
||||
className="secondary-button"
|
||||
disabled={isSubmitting}
|
||||
onClick={logout}
|
||||
type="button"
|
||||
>
|
||||
退出
|
||||
</button>
|
||||
{message && message !== "已登录" ? <small>{message}</small> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="api-session-control" onSubmit={submit}>
|
||||
<label className="api-key-field">
|
||||
<span>访问密钥</span>
|
||||
<input
|
||||
autoComplete="current-password"
|
||||
disabled={isChecking || isSubmitting}
|
||||
onChange={(event) => setApiAccessKey(event.target.value)}
|
||||
type="password"
|
||||
value={apiAccessKey}
|
||||
/>
|
||||
</label>
|
||||
<button disabled={isChecking || isSubmitting} type="submit">
|
||||
{isSubmitting ? "登录中..." : "确认登录"}
|
||||
</button>
|
||||
{message ? <small>{message}</small> : null}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -1,18 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import type {
|
||||
OptimizationCase,
|
||||
OptimizationCaseDetail,
|
||||
} from "../../lib/cases/types";
|
||||
import {
|
||||
apiHeaders,
|
||||
readStoredApiAccessKey,
|
||||
storeApiAccessKey,
|
||||
} from "./api-client";
|
||||
import { apiFetch } from "./api-client";
|
||||
import { ArticleCaseDetail } from "./article-case-detail";
|
||||
import { CaseAuthControl } from "./case-auth-control";
|
||||
import { CasePublicationPanel } from "./case-publication-panel";
|
||||
import { formatDate } from "./case-format";
|
||||
import { CaseResultVersionList } from "./case-result-version-list";
|
||||
@@ -41,12 +38,15 @@ const statusLabels: Record<OptimizationCase["status"], string> = {
|
||||
export function CaseDetail({ caseId }: CaseDetailProps) {
|
||||
const [detail, setDetail] = useState<OptimizationCaseDetail | null>(null);
|
||||
const [selectedVersionId, setSelectedVersionId] = useState<string | null>(null);
|
||||
const [apiAccessKey, setApiAccessKey] = useState(readStoredApiAccessKey);
|
||||
const [authenticated, setAuthenticated] = useState(false);
|
||||
const [sessionChecked, setSessionChecked] = useState(false);
|
||||
const [message, setMessage] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionChecked || !authenticated) return;
|
||||
|
||||
let isCurrent = true;
|
||||
fetch(`/api/cases/${caseId}`, { headers: apiHeaders(apiAccessKey) })
|
||||
apiFetch(`/api/cases/${caseId}`)
|
||||
.then((response) => response.json() as Promise<CaseDetailResponse>)
|
||||
.then((body: CaseDetailResponse) => {
|
||||
if (!isCurrent) return;
|
||||
@@ -76,7 +76,7 @@ export function CaseDetail({ caseId }: CaseDetailProps) {
|
||||
return () => {
|
||||
isCurrent = false;
|
||||
};
|
||||
}, [apiAccessKey, caseId]);
|
||||
}, [authenticated, caseId, sessionChecked]);
|
||||
|
||||
const selectedVersion = useMemo(() => {
|
||||
if (!detail) return null;
|
||||
@@ -87,31 +87,39 @@ export function CaseDetail({ caseId }: CaseDetailProps) {
|
||||
);
|
||||
}, [detail, selectedVersionId]);
|
||||
|
||||
function updateApiAccessKey(value: string) {
|
||||
setApiAccessKey(value);
|
||||
storeApiAccessKey(value);
|
||||
}
|
||||
const updateAuthenticated = useCallback((nextAuthenticated: boolean) => {
|
||||
setAuthenticated(nextAuthenticated);
|
||||
setSessionChecked(true);
|
||||
setMessage("");
|
||||
if (!nextAuthenticated) {
|
||||
setDetail(null);
|
||||
setSelectedVersionId(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const visibleMessage =
|
||||
message ||
|
||||
(sessionChecked && !authenticated ? "请先登录后查看案例详情。" : "");
|
||||
|
||||
return (
|
||||
<main className="app-shell">
|
||||
<header className="topbar">
|
||||
<div>
|
||||
<h1>{detail?.case.title ?? "案例详情"}</h1>
|
||||
{message ? <p>{message}</p> : detail ? <p>{detail.case.summary}</p> : null}
|
||||
{visibleMessage ? (
|
||||
<p>{visibleMessage}</p>
|
||||
) : detail ? (
|
||||
<p>{detail.case.summary}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="topbar-actions">
|
||||
<Link className="text-link" href="/cases">
|
||||
返回案例库
|
||||
</Link>
|
||||
<label className="api-key-field">
|
||||
<span>访问密钥</span>
|
||||
<input
|
||||
autoComplete="off"
|
||||
onChange={(event) => updateApiAccessKey(event.target.value)}
|
||||
type="password"
|
||||
value={apiAccessKey}
|
||||
/>
|
||||
</label>
|
||||
<CaseAuthControl
|
||||
authenticated={authenticated}
|
||||
onAuthenticatedChange={updateAuthenticated}
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -168,7 +176,6 @@ export function CaseDetail({ caseId }: CaseDetailProps) {
|
||||
/>
|
||||
)}
|
||||
<CasePublicationPanel
|
||||
apiAccessKey={apiAccessKey}
|
||||
caseId={detail.case.id}
|
||||
publishTarget={detail.case.publish_target}
|
||||
versionId={selectedVersion?.id ?? null}
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import type { OptimizationCase } from "../../lib/cases/types";
|
||||
import {
|
||||
apiHeaders,
|
||||
readStoredApiAccessKey,
|
||||
storeApiAccessKey,
|
||||
} from "./api-client";
|
||||
import { apiFetch } from "./api-client";
|
||||
import { CaseAuthControl } from "./case-auth-control";
|
||||
|
||||
interface CaseListResponse {
|
||||
cases?: OptimizationCase[];
|
||||
@@ -33,7 +30,8 @@ export function CaseList() {
|
||||
const [status, setStatus] = useState("");
|
||||
const [query, setQuery] = useState("");
|
||||
const [message, setMessage] = useState("");
|
||||
const [apiAccessKey, setApiAccessKey] = useState(readStoredApiAccessKey);
|
||||
const [authenticated, setAuthenticated] = useState(false);
|
||||
const [sessionChecked, setSessionChecked] = useState(false);
|
||||
|
||||
const search = useMemo(() => {
|
||||
const params = new URLSearchParams();
|
||||
@@ -44,10 +42,10 @@ export function CaseList() {
|
||||
}, [caseType, status, query]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionChecked || !authenticated) return;
|
||||
|
||||
let isCurrent = true;
|
||||
fetch(`/api/cases${search ? `?${search}` : ""}`, {
|
||||
headers: apiHeaders(apiAccessKey),
|
||||
})
|
||||
apiFetch(`/api/cases${search ? `?${search}` : ""}`)
|
||||
.then((response) => response.json() as Promise<CaseListResponse>)
|
||||
.then((body: CaseListResponse) => {
|
||||
if (!isCurrent) return;
|
||||
@@ -61,33 +59,36 @@ export function CaseList() {
|
||||
return () => {
|
||||
isCurrent = false;
|
||||
};
|
||||
}, [apiAccessKey, search]);
|
||||
}, [authenticated, search, sessionChecked]);
|
||||
|
||||
function updateApiAccessKey(value: string) {
|
||||
setApiAccessKey(value);
|
||||
storeApiAccessKey(value);
|
||||
}
|
||||
const updateAuthenticated = useCallback((nextAuthenticated: boolean) => {
|
||||
setAuthenticated(nextAuthenticated);
|
||||
setSessionChecked(true);
|
||||
setMessage("");
|
||||
if (!nextAuthenticated) {
|
||||
setCases([]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const visibleMessage =
|
||||
message ||
|
||||
(sessionChecked && !authenticated ? "请先登录后查看案例库。" : "");
|
||||
|
||||
return (
|
||||
<main className="app-shell">
|
||||
<header className="topbar">
|
||||
<div>
|
||||
<h1>案例库</h1>
|
||||
{message ? <p>{message}</p> : null}
|
||||
{visibleMessage ? <p>{visibleMessage}</p> : null}
|
||||
</div>
|
||||
<div className="topbar-actions">
|
||||
<Link className="text-link" href="/">
|
||||
返回优化台
|
||||
</Link>
|
||||
<label className="api-key-field">
|
||||
<span>访问密钥</span>
|
||||
<input
|
||||
autoComplete="off"
|
||||
onChange={(event) => updateApiAccessKey(event.target.value)}
|
||||
type="password"
|
||||
value={apiAccessKey}
|
||||
/>
|
||||
</label>
|
||||
<CaseAuthControl
|
||||
authenticated={authenticated}
|
||||
onAuthenticatedChange={updateAuthenticated}
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
<section className="case-toolbar" aria-label="案例筛选">
|
||||
@@ -152,7 +153,7 @@ export function CaseList() {
|
||||
<span>{formatDate(item.updated_at)}</span>
|
||||
</Link>
|
||||
))}
|
||||
{cases.length === 0 ? (
|
||||
{authenticated && cases.length === 0 ? (
|
||||
<p className="empty-panel">还没有可显示的案例。</p>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
@@ -3,11 +3,10 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import type { PublicationRecord } from "../../lib/calibration/types";
|
||||
import { apiHeaders } from "./api-client";
|
||||
import { apiFetch } from "./api-client";
|
||||
import { formatDate } from "./case-format";
|
||||
|
||||
interface CasePublicationPanelProps {
|
||||
apiAccessKey: string;
|
||||
caseId: string;
|
||||
versionId: string | null;
|
||||
publishTarget: string;
|
||||
@@ -30,7 +29,6 @@ interface PerformanceResponse {
|
||||
}
|
||||
|
||||
export function CasePublicationPanel({
|
||||
apiAccessKey,
|
||||
caseId,
|
||||
versionId,
|
||||
publishTarget,
|
||||
@@ -54,9 +52,7 @@ export function CasePublicationPanel({
|
||||
return;
|
||||
}
|
||||
let isCurrent = true;
|
||||
fetch(`/api/cases/${caseId}/versions/${versionId}/publications`, {
|
||||
headers: apiHeaders(apiAccessKey),
|
||||
})
|
||||
apiFetch(`/api/cases/${caseId}/versions/${versionId}/publications`)
|
||||
.then((response) => response.json() as Promise<PublicationListResponse>)
|
||||
.then((body: PublicationListResponse) => {
|
||||
if (!isCurrent) return;
|
||||
@@ -70,16 +66,16 @@ export function CasePublicationPanel({
|
||||
return () => {
|
||||
isCurrent = false;
|
||||
};
|
||||
}, [apiAccessKey, caseId, versionId]);
|
||||
}, [caseId, versionId]);
|
||||
|
||||
async function registerPublication() {
|
||||
if (!versionId || !url || !publishedAt) return;
|
||||
setMessage("");
|
||||
const response = await fetch(
|
||||
const response = await apiFetch(
|
||||
`/api/cases/${caseId}/versions/${versionId}/publications`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: apiHeaders(apiAccessKey),
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
publish_target: publishTarget || "未指定",
|
||||
url,
|
||||
@@ -101,9 +97,9 @@ export function CasePublicationPanel({
|
||||
async function recordPerformance() {
|
||||
if (!publicationId) return;
|
||||
setMessage("");
|
||||
const response = await fetch(`/api/publications/${publicationId}/performance`, {
|
||||
const response = await apiFetch(`/api/publications/${publicationId}/performance`, {
|
||||
method: "POST",
|
||||
headers: apiHeaders(apiAccessKey),
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
window_label: "T+7d",
|
||||
views,
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { requireApiAccess } from "../auth";
|
||||
import {
|
||||
apiSessionCookieName,
|
||||
createApiSessionToken,
|
||||
requireApiAccess,
|
||||
} from "../auth";
|
||||
|
||||
describe("requireApiAccess", () => {
|
||||
test("allows local test requests when auth is explicitly disabled", () => {
|
||||
@@ -53,4 +57,32 @@ describe("requireApiAccess", () => {
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
test("allows requests with a valid HttpOnly session cookie", () => {
|
||||
const request = new Request("http://localhost/api/jobs", {
|
||||
headers: {
|
||||
cookie: `${apiSessionCookieName}=${createApiSessionToken("secret")}`,
|
||||
},
|
||||
});
|
||||
|
||||
const result = requireApiAccess(request, {
|
||||
apiAccessKey: "secret",
|
||||
authDisabled: false,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
test("rejects requests with an invalid session cookie", () => {
|
||||
const request = new Request("http://localhost/api/jobs", {
|
||||
headers: { cookie: `${apiSessionCookieName}=wrong` },
|
||||
});
|
||||
|
||||
const result = requireApiAccess(request, {
|
||||
apiAccessKey: "secret",
|
||||
authDisabled: false,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
+47
-2
@@ -1,7 +1,13 @@
|
||||
import { createHmac, timingSafeEqual } from "node:crypto";
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { getAppCloudflareEnv } from "../runtime/cloudflare";
|
||||
|
||||
export const apiSessionCookieName = "geo_api_session";
|
||||
|
||||
const sessionTokenMessage = "geo-agent-api-session:v1";
|
||||
|
||||
interface ApiAccessOptions {
|
||||
apiAccessKey?: string;
|
||||
authDisabled?: boolean;
|
||||
@@ -33,7 +39,10 @@ export function requireApiAccess(
|
||||
}
|
||||
|
||||
const provided = request.headers.get("x-api-key");
|
||||
if (provided !== options.apiAccessKey) {
|
||||
if (
|
||||
provided !== options.apiAccessKey &&
|
||||
!hasValidApiSession(request, options.apiAccessKey)
|
||||
) {
|
||||
return {
|
||||
ok: false,
|
||||
response: NextResponse.json({ error: "Unauthorized" }, { status: 401 }),
|
||||
@@ -43,6 +52,42 @@ export function requireApiAccess(
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
function getConfiguredApiAccessKey() {
|
||||
export function hasValidApiSession(request: Request, apiAccessKey: string) {
|
||||
const cookieValue = parseCookieHeader(request.headers.get("cookie"))[
|
||||
apiSessionCookieName
|
||||
];
|
||||
if (!cookieValue) return false;
|
||||
return safeEqual(cookieValue, createApiSessionToken(apiAccessKey));
|
||||
}
|
||||
|
||||
export function createApiSessionToken(apiAccessKey: string) {
|
||||
return createHmac("sha256", apiAccessKey)
|
||||
.update(sessionTokenMessage)
|
||||
.digest("hex");
|
||||
}
|
||||
|
||||
export function getConfiguredApiAccessKey() {
|
||||
return getAppCloudflareEnv()?.API_ACCESS_KEY ?? process.env.API_ACCESS_KEY;
|
||||
}
|
||||
|
||||
function parseCookieHeader(value: string | null) {
|
||||
const cookies: Record<string, string> = {};
|
||||
if (!value) return cookies;
|
||||
|
||||
for (const part of value.split(";")) {
|
||||
const separator = part.indexOf("=");
|
||||
if (separator < 0) continue;
|
||||
const name = part.slice(0, separator).trim();
|
||||
const cookieValue = part.slice(separator + 1).trim();
|
||||
if (name) cookies[name] = decodeURIComponent(cookieValue);
|
||||
}
|
||||
|
||||
return cookies;
|
||||
}
|
||||
|
||||
function safeEqual(left: string, right: string) {
|
||||
const leftBuffer = Buffer.from(left);
|
||||
const rightBuffer = Buffer.from(right);
|
||||
if (leftBuffer.length !== rightBuffer.length) return false;
|
||||
return timingSafeEqual(leftBuffer, rightBuffer);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test("案例库列表和人味文案详情可查看", async ({ page }) => {
|
||||
await mockAuthenticatedSession(page, true);
|
||||
await page.route("**/api/cases", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
@@ -134,3 +135,84 @@ test("案例库列表和人味文案详情可查看", async ({ page }) => {
|
||||
await expect(page.getByText("AI 味检查")).toBeVisible();
|
||||
await expect(page.getByText("发布表现")).toBeVisible();
|
||||
});
|
||||
|
||||
test("案例库可以通过访问密钥建立浏览器登录态", async ({ page }) => {
|
||||
let authenticated = false;
|
||||
await page.route("**/api/auth/session", async (route) => {
|
||||
const request = route.request();
|
||||
if (request.method() === "GET") {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ authenticated }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (request.method() === "POST") {
|
||||
const payload = request.postDataJSON() as { api_access_key?: string };
|
||||
authenticated = payload.api_access_key === "local-dev-key";
|
||||
await route.fulfill({
|
||||
status: authenticated ? 200 : 401,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(
|
||||
authenticated
|
||||
? { authenticated: true }
|
||||
: { error: "Unauthorized" },
|
||||
),
|
||||
});
|
||||
return;
|
||||
}
|
||||
await route.fulfill({ status: 405 });
|
||||
});
|
||||
await page.route("**/api/cases", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
cases: [
|
||||
{
|
||||
id: "case_article_1",
|
||||
case_type: "article",
|
||||
title: "伟思德鲁官网文章优化案例",
|
||||
summary: "优化后摘要",
|
||||
status: "optimized",
|
||||
customer_name: "",
|
||||
brand_name: "",
|
||||
project_tags: [],
|
||||
notes: "",
|
||||
publish_target: "official_site",
|
||||
source_excerpt: "原文摘要",
|
||||
result_excerpt: "优化后摘要",
|
||||
latest_result_version_id: "ver_article_1",
|
||||
latest_version_number: 1,
|
||||
last_error_stage: null,
|
||||
last_error_summary: null,
|
||||
archived_at: null,
|
||||
created_at: "2026-07-08T10:00:00.000Z",
|
||||
updated_at: "2026-07-08T10:05:00.000Z",
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto("/cases");
|
||||
await expect(page.getByText("请先登录后查看案例库。")).toBeVisible();
|
||||
await page.getByLabel("访问密钥").fill("local-dev-key");
|
||||
await page.getByRole("button", { name: "确认登录" }).click();
|
||||
await expect(page.getByText("已登录")).toBeVisible();
|
||||
await expect(page.getByText("伟思德鲁官网文章优化案例")).toBeVisible();
|
||||
});
|
||||
|
||||
async function mockAuthenticatedSession(
|
||||
page: Parameters<Parameters<typeof test>[1]>[0]["page"],
|
||||
authenticated: boolean,
|
||||
) {
|
||||
await page.route("**/api/auth/session", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ authenticated }),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user