新增案例库浏览器登录态
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%);
|
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 {
|
.text-link {
|
||||||
color: #2f6fdd;
|
color: #2f6fdd;
|
||||||
font-weight: 800;
|
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) {
|
export function apiHeaders(apiAccessKey: string) {
|
||||||
const headers: Record<string, string> = { "content-type": "application/json" };
|
const headers: Record<string, string> = { "content-type": "application/json" };
|
||||||
@@ -8,17 +11,43 @@ export function apiHeaders(apiAccessKey: string) {
|
|||||||
return headers;
|
return headers;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function readStoredApiAccessKey() {
|
export function apiFetch(input: RequestInfo | URL, init: RequestInit = {}) {
|
||||||
if (typeof window === "undefined") return "";
|
return fetch(input, {
|
||||||
return window.sessionStorage.getItem(apiAccessStorageKey) ?? "";
|
...init,
|
||||||
|
credentials: init.credentials ?? "same-origin",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function storeApiAccessKey(value: string) {
|
export async function getApiSession(): Promise<ApiSessionResult> {
|
||||||
if (typeof window === "undefined") return;
|
const response = await apiFetch("/api/auth/session");
|
||||||
if (value) {
|
return readSessionResponse(response);
|
||||||
window.sessionStorage.setItem(apiAccessStorageKey, value);
|
|
||||||
} else {
|
|
||||||
window.sessionStorage.removeItem(apiAccessStorageKey);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
OptimizationCase,
|
OptimizationCase,
|
||||||
OptimizationCaseDetail,
|
OptimizationCaseDetail,
|
||||||
} from "../../lib/cases/types";
|
} from "../../lib/cases/types";
|
||||||
import {
|
import { apiFetch } from "./api-client";
|
||||||
apiHeaders,
|
|
||||||
readStoredApiAccessKey,
|
|
||||||
storeApiAccessKey,
|
|
||||||
} from "./api-client";
|
|
||||||
import { ArticleCaseDetail } from "./article-case-detail";
|
import { ArticleCaseDetail } from "./article-case-detail";
|
||||||
|
import { CaseAuthControl } from "./case-auth-control";
|
||||||
import { CasePublicationPanel } from "./case-publication-panel";
|
import { CasePublicationPanel } from "./case-publication-panel";
|
||||||
import { formatDate } from "./case-format";
|
import { formatDate } from "./case-format";
|
||||||
import { CaseResultVersionList } from "./case-result-version-list";
|
import { CaseResultVersionList } from "./case-result-version-list";
|
||||||
@@ -41,12 +38,15 @@ const statusLabels: Record<OptimizationCase["status"], string> = {
|
|||||||
export function CaseDetail({ caseId }: CaseDetailProps) {
|
export function CaseDetail({ caseId }: CaseDetailProps) {
|
||||||
const [detail, setDetail] = useState<OptimizationCaseDetail | null>(null);
|
const [detail, setDetail] = useState<OptimizationCaseDetail | null>(null);
|
||||||
const [selectedVersionId, setSelectedVersionId] = useState<string | 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("");
|
const [message, setMessage] = useState("");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!sessionChecked || !authenticated) return;
|
||||||
|
|
||||||
let isCurrent = true;
|
let isCurrent = true;
|
||||||
fetch(`/api/cases/${caseId}`, { headers: apiHeaders(apiAccessKey) })
|
apiFetch(`/api/cases/${caseId}`)
|
||||||
.then((response) => response.json() as Promise<CaseDetailResponse>)
|
.then((response) => response.json() as Promise<CaseDetailResponse>)
|
||||||
.then((body: CaseDetailResponse) => {
|
.then((body: CaseDetailResponse) => {
|
||||||
if (!isCurrent) return;
|
if (!isCurrent) return;
|
||||||
@@ -76,7 +76,7 @@ export function CaseDetail({ caseId }: CaseDetailProps) {
|
|||||||
return () => {
|
return () => {
|
||||||
isCurrent = false;
|
isCurrent = false;
|
||||||
};
|
};
|
||||||
}, [apiAccessKey, caseId]);
|
}, [authenticated, caseId, sessionChecked]);
|
||||||
|
|
||||||
const selectedVersion = useMemo(() => {
|
const selectedVersion = useMemo(() => {
|
||||||
if (!detail) return null;
|
if (!detail) return null;
|
||||||
@@ -87,31 +87,39 @@ export function CaseDetail({ caseId }: CaseDetailProps) {
|
|||||||
);
|
);
|
||||||
}, [detail, selectedVersionId]);
|
}, [detail, selectedVersionId]);
|
||||||
|
|
||||||
function updateApiAccessKey(value: string) {
|
const updateAuthenticated = useCallback((nextAuthenticated: boolean) => {
|
||||||
setApiAccessKey(value);
|
setAuthenticated(nextAuthenticated);
|
||||||
storeApiAccessKey(value);
|
setSessionChecked(true);
|
||||||
|
setMessage("");
|
||||||
|
if (!nextAuthenticated) {
|
||||||
|
setDetail(null);
|
||||||
|
setSelectedVersionId(null);
|
||||||
}
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const visibleMessage =
|
||||||
|
message ||
|
||||||
|
(sessionChecked && !authenticated ? "请先登录后查看案例详情。" : "");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="app-shell">
|
<main className="app-shell">
|
||||||
<header className="topbar">
|
<header className="topbar">
|
||||||
<div>
|
<div>
|
||||||
<h1>{detail?.case.title ?? "案例详情"}</h1>
|
<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>
|
||||||
<div className="topbar-actions">
|
<div className="topbar-actions">
|
||||||
<Link className="text-link" href="/cases">
|
<Link className="text-link" href="/cases">
|
||||||
返回案例库
|
返回案例库
|
||||||
</Link>
|
</Link>
|
||||||
<label className="api-key-field">
|
<CaseAuthControl
|
||||||
<span>访问密钥</span>
|
authenticated={authenticated}
|
||||||
<input
|
onAuthenticatedChange={updateAuthenticated}
|
||||||
autoComplete="off"
|
|
||||||
onChange={(event) => updateApiAccessKey(event.target.value)}
|
|
||||||
type="password"
|
|
||||||
value={apiAccessKey}
|
|
||||||
/>
|
/>
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -168,7 +176,6 @@ export function CaseDetail({ caseId }: CaseDetailProps) {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<CasePublicationPanel
|
<CasePublicationPanel
|
||||||
apiAccessKey={apiAccessKey}
|
|
||||||
caseId={detail.case.id}
|
caseId={detail.case.id}
|
||||||
publishTarget={detail.case.publish_target}
|
publishTarget={detail.case.publish_target}
|
||||||
versionId={selectedVersion?.id ?? null}
|
versionId={selectedVersion?.id ?? null}
|
||||||
|
|||||||
@@ -1,14 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
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 type { OptimizationCase } from "../../lib/cases/types";
|
||||||
import {
|
import { apiFetch } from "./api-client";
|
||||||
apiHeaders,
|
import { CaseAuthControl } from "./case-auth-control";
|
||||||
readStoredApiAccessKey,
|
|
||||||
storeApiAccessKey,
|
|
||||||
} from "./api-client";
|
|
||||||
|
|
||||||
interface CaseListResponse {
|
interface CaseListResponse {
|
||||||
cases?: OptimizationCase[];
|
cases?: OptimizationCase[];
|
||||||
@@ -33,7 +30,8 @@ export function CaseList() {
|
|||||||
const [status, setStatus] = useState("");
|
const [status, setStatus] = useState("");
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [message, setMessage] = useState("");
|
const [message, setMessage] = useState("");
|
||||||
const [apiAccessKey, setApiAccessKey] = useState(readStoredApiAccessKey);
|
const [authenticated, setAuthenticated] = useState(false);
|
||||||
|
const [sessionChecked, setSessionChecked] = useState(false);
|
||||||
|
|
||||||
const search = useMemo(() => {
|
const search = useMemo(() => {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
@@ -44,10 +42,10 @@ export function CaseList() {
|
|||||||
}, [caseType, status, query]);
|
}, [caseType, status, query]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!sessionChecked || !authenticated) return;
|
||||||
|
|
||||||
let isCurrent = true;
|
let isCurrent = true;
|
||||||
fetch(`/api/cases${search ? `?${search}` : ""}`, {
|
apiFetch(`/api/cases${search ? `?${search}` : ""}`)
|
||||||
headers: apiHeaders(apiAccessKey),
|
|
||||||
})
|
|
||||||
.then((response) => response.json() as Promise<CaseListResponse>)
|
.then((response) => response.json() as Promise<CaseListResponse>)
|
||||||
.then((body: CaseListResponse) => {
|
.then((body: CaseListResponse) => {
|
||||||
if (!isCurrent) return;
|
if (!isCurrent) return;
|
||||||
@@ -61,33 +59,36 @@ export function CaseList() {
|
|||||||
return () => {
|
return () => {
|
||||||
isCurrent = false;
|
isCurrent = false;
|
||||||
};
|
};
|
||||||
}, [apiAccessKey, search]);
|
}, [authenticated, search, sessionChecked]);
|
||||||
|
|
||||||
function updateApiAccessKey(value: string) {
|
const updateAuthenticated = useCallback((nextAuthenticated: boolean) => {
|
||||||
setApiAccessKey(value);
|
setAuthenticated(nextAuthenticated);
|
||||||
storeApiAccessKey(value);
|
setSessionChecked(true);
|
||||||
|
setMessage("");
|
||||||
|
if (!nextAuthenticated) {
|
||||||
|
setCases([]);
|
||||||
}
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const visibleMessage =
|
||||||
|
message ||
|
||||||
|
(sessionChecked && !authenticated ? "请先登录后查看案例库。" : "");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="app-shell">
|
<main className="app-shell">
|
||||||
<header className="topbar">
|
<header className="topbar">
|
||||||
<div>
|
<div>
|
||||||
<h1>案例库</h1>
|
<h1>案例库</h1>
|
||||||
{message ? <p>{message}</p> : null}
|
{visibleMessage ? <p>{visibleMessage}</p> : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="topbar-actions">
|
<div className="topbar-actions">
|
||||||
<Link className="text-link" href="/">
|
<Link className="text-link" href="/">
|
||||||
返回优化台
|
返回优化台
|
||||||
</Link>
|
</Link>
|
||||||
<label className="api-key-field">
|
<CaseAuthControl
|
||||||
<span>访问密钥</span>
|
authenticated={authenticated}
|
||||||
<input
|
onAuthenticatedChange={updateAuthenticated}
|
||||||
autoComplete="off"
|
|
||||||
onChange={(event) => updateApiAccessKey(event.target.value)}
|
|
||||||
type="password"
|
|
||||||
value={apiAccessKey}
|
|
||||||
/>
|
/>
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<section className="case-toolbar" aria-label="案例筛选">
|
<section className="case-toolbar" aria-label="案例筛选">
|
||||||
@@ -152,7 +153,7 @@ export function CaseList() {
|
|||||||
<span>{formatDate(item.updated_at)}</span>
|
<span>{formatDate(item.updated_at)}</span>
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
{cases.length === 0 ? (
|
{authenticated && cases.length === 0 ? (
|
||||||
<p className="empty-panel">还没有可显示的案例。</p>
|
<p className="empty-panel">还没有可显示的案例。</p>
|
||||||
) : null}
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -3,11 +3,10 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
import type { PublicationRecord } from "../../lib/calibration/types";
|
import type { PublicationRecord } from "../../lib/calibration/types";
|
||||||
import { apiHeaders } from "./api-client";
|
import { apiFetch } from "./api-client";
|
||||||
import { formatDate } from "./case-format";
|
import { formatDate } from "./case-format";
|
||||||
|
|
||||||
interface CasePublicationPanelProps {
|
interface CasePublicationPanelProps {
|
||||||
apiAccessKey: string;
|
|
||||||
caseId: string;
|
caseId: string;
|
||||||
versionId: string | null;
|
versionId: string | null;
|
||||||
publishTarget: string;
|
publishTarget: string;
|
||||||
@@ -30,7 +29,6 @@ interface PerformanceResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function CasePublicationPanel({
|
export function CasePublicationPanel({
|
||||||
apiAccessKey,
|
|
||||||
caseId,
|
caseId,
|
||||||
versionId,
|
versionId,
|
||||||
publishTarget,
|
publishTarget,
|
||||||
@@ -54,9 +52,7 @@ export function CasePublicationPanel({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let isCurrent = true;
|
let isCurrent = true;
|
||||||
fetch(`/api/cases/${caseId}/versions/${versionId}/publications`, {
|
apiFetch(`/api/cases/${caseId}/versions/${versionId}/publications`)
|
||||||
headers: apiHeaders(apiAccessKey),
|
|
||||||
})
|
|
||||||
.then((response) => response.json() as Promise<PublicationListResponse>)
|
.then((response) => response.json() as Promise<PublicationListResponse>)
|
||||||
.then((body: PublicationListResponse) => {
|
.then((body: PublicationListResponse) => {
|
||||||
if (!isCurrent) return;
|
if (!isCurrent) return;
|
||||||
@@ -70,16 +66,16 @@ export function CasePublicationPanel({
|
|||||||
return () => {
|
return () => {
|
||||||
isCurrent = false;
|
isCurrent = false;
|
||||||
};
|
};
|
||||||
}, [apiAccessKey, caseId, versionId]);
|
}, [caseId, versionId]);
|
||||||
|
|
||||||
async function registerPublication() {
|
async function registerPublication() {
|
||||||
if (!versionId || !url || !publishedAt) return;
|
if (!versionId || !url || !publishedAt) return;
|
||||||
setMessage("");
|
setMessage("");
|
||||||
const response = await fetch(
|
const response = await apiFetch(
|
||||||
`/api/cases/${caseId}/versions/${versionId}/publications`,
|
`/api/cases/${caseId}/versions/${versionId}/publications`,
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: apiHeaders(apiAccessKey),
|
headers: { "content-type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
publish_target: publishTarget || "未指定",
|
publish_target: publishTarget || "未指定",
|
||||||
url,
|
url,
|
||||||
@@ -101,9 +97,9 @@ export function CasePublicationPanel({
|
|||||||
async function recordPerformance() {
|
async function recordPerformance() {
|
||||||
if (!publicationId) return;
|
if (!publicationId) return;
|
||||||
setMessage("");
|
setMessage("");
|
||||||
const response = await fetch(`/api/publications/${publicationId}/performance`, {
|
const response = await apiFetch(`/api/publications/${publicationId}/performance`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: apiHeaders(apiAccessKey),
|
headers: { "content-type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
window_label: "T+7d",
|
window_label: "T+7d",
|
||||||
views,
|
views,
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { describe, expect, test } from "vitest";
|
import { describe, expect, test } from "vitest";
|
||||||
|
|
||||||
import { requireApiAccess } from "../auth";
|
import {
|
||||||
|
apiSessionCookieName,
|
||||||
|
createApiSessionToken,
|
||||||
|
requireApiAccess,
|
||||||
|
} from "../auth";
|
||||||
|
|
||||||
describe("requireApiAccess", () => {
|
describe("requireApiAccess", () => {
|
||||||
test("allows local test requests when auth is explicitly disabled", () => {
|
test("allows local test requests when auth is explicitly disabled", () => {
|
||||||
@@ -53,4 +57,32 @@ describe("requireApiAccess", () => {
|
|||||||
|
|
||||||
expect(result.ok).toBe(true);
|
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 { NextResponse } from "next/server";
|
||||||
|
|
||||||
import { getAppCloudflareEnv } from "../runtime/cloudflare";
|
import { getAppCloudflareEnv } from "../runtime/cloudflare";
|
||||||
|
|
||||||
|
export const apiSessionCookieName = "geo_api_session";
|
||||||
|
|
||||||
|
const sessionTokenMessage = "geo-agent-api-session:v1";
|
||||||
|
|
||||||
interface ApiAccessOptions {
|
interface ApiAccessOptions {
|
||||||
apiAccessKey?: string;
|
apiAccessKey?: string;
|
||||||
authDisabled?: boolean;
|
authDisabled?: boolean;
|
||||||
@@ -33,7 +39,10 @@ export function requireApiAccess(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const provided = request.headers.get("x-api-key");
|
const provided = request.headers.get("x-api-key");
|
||||||
if (provided !== options.apiAccessKey) {
|
if (
|
||||||
|
provided !== options.apiAccessKey &&
|
||||||
|
!hasValidApiSession(request, options.apiAccessKey)
|
||||||
|
) {
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
response: NextResponse.json({ error: "Unauthorized" }, { status: 401 }),
|
response: NextResponse.json({ error: "Unauthorized" }, { status: 401 }),
|
||||||
@@ -43,6 +52,42 @@ export function requireApiAccess(
|
|||||||
return { ok: true };
|
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;
|
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";
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
test("案例库列表和人味文案详情可查看", async ({ page }) => {
|
test("案例库列表和人味文案详情可查看", async ({ page }) => {
|
||||||
|
await mockAuthenticatedSession(page, true);
|
||||||
await page.route("**/api/cases", async (route) => {
|
await page.route("**/api/cases", async (route) => {
|
||||||
await route.fulfill({
|
await route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
@@ -134,3 +135,84 @@ test("案例库列表和人味文案详情可查看", async ({ page }) => {
|
|||||||
await expect(page.getByText("AI 味检查")).toBeVisible();
|
await expect(page.getByText("AI 味检查")).toBeVisible();
|
||||||
await expect(page.getByText("发布表现")).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