refactor: migrate pages and API routes to App Router
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# 啡跃 Endoleap
|
||||
|
||||
本地 Next.js Pages Router + TypeScript + Tailwind CSS + shadcn/ui 项目。首页和账户页参考 qyof 的 PROMPT 文件,工作台结合双侧栏画布、Notion 的留白与 Miro 的空间组织方式。Other Designs 用于首页叙事与版式参考。
|
||||
本地 Next.js App Router + TypeScript + Tailwind CSS + shadcn/ui 项目。首页和账户页参考 qyof 的 PROMPT 文件,工作台结合双侧栏画布、Notion 的留白与 Miro 的空间组织方式。Other Designs 用于首页叙事与版式参考。
|
||||
|
||||
项目目录:`/Users/zzyang/endoleap`。
|
||||
|
||||
@@ -19,6 +19,14 @@ npm run dev
|
||||
- 真实工作台:/dashboard
|
||||
- 本地演示:/dashboard?demo=1
|
||||
|
||||
## 路由结构
|
||||
|
||||
- `src/app/layout.tsx`:根布局、中文语言标记、全局样式与页面元信息。
|
||||
- `src/app/page.tsx`:首页。
|
||||
- `src/app/login/page.tsx`、`src/app/auth/callback/page.tsx`:账户页与登录回调。
|
||||
- `src/app/dashboard/page.tsx`:工作台入口,以 Suspense 包裹读取查询参数的客户端工作台。
|
||||
- `src/app/api/**/route.ts`:Foundry 代理与 OAuth 跳转,使用 App Router Route Handlers。
|
||||
|
||||
## 配置
|
||||
|
||||
复制 .env.example 为 .env.local,按实际部署填写:
|
||||
@@ -48,9 +56,11 @@ npm run build
|
||||
node tests/api-smoke.mjs
|
||||
```
|
||||
|
||||
API 测试使用本机临时后端和独立前端进程,检查会话 Cookie、令牌不泄漏、同源校验和路径白名单,不访问线上账号。构建需先完成。浏览器验收覆盖登录/注册切换、演示画布创建、搜索空状态、笔记保存、桌面与 390px 手机布局。
|
||||
API 测试使用本机临时后端和独立前端进程,检查会话 Cookie、令牌不泄漏、同源校验和路径白名单,不访问线上账号。构建需先完成。冒烟测试还覆盖所有页面地址、元信息、OAuth 跳转、请求体限制及退出会话。此前的浏览器验收覆盖登录/注册切换、演示画布创建、搜索空状态、笔记保存、桌面与 390px 手机布局;本次迁移的验证结果见 VALIDATION.md。
|
||||
|
||||
项目尚未创建远程仓库或部署。
|
||||
若执行环境限制 Turbopack 的 CSS 工作进程绑定本地端口,可使用官方 Webpack 模式:`npm run build -- --webpack`。
|
||||
|
||||
项目已同步至 `ssh://git@gitea.entroia.com:22222/Zayden-Jung/endoleap.git`;尚未部署。
|
||||
|
||||
|
||||
性能分析、实测耗时和修复说明见 [PERFORMANCE.md](PERFORMANCE.md)。
|
||||
|
||||
@@ -16,3 +16,15 @@
|
||||
## 真实账号创建验收
|
||||
|
||||
已在用户已登录的 Chrome 中打开 http://localhost:17321/dashboard。初始空间列表为空;使用新建画布流程提交用户原先填写的「周末的灵感」后,创建了「个人空间」和该画布。完整刷新页面后,侧栏和主列表仍显示该画布,确认读取到后端持久化记录。此项为真实账号验收,补充此前只完成隔离模拟测试的限制。
|
||||
|
||||
## App Router 迁移验收(2026-11-05)
|
||||
|
||||
- 全部页面迁入 `src/app`;根布局提供全局样式、`zh-CN`、标题及描述。工作台通过 `next/navigation` 读取演示参数,入口提供 Suspense 边界。
|
||||
- 两个 API 改为 Route Handlers,保留会话 Cookie、Origin 校验、路径和查询参数白名单、15 秒超时、请求取消及 Server-Timing;显式保留 1 MiB 请求体限制。
|
||||
- `npm run build -- --webpack`:通过,构建结果全部为 App Router 路由。
|
||||
- 默认 `npm run build`:当前环境禁止 Turbopack CSS 工作进程绑定本地端口而失败;没有更改项目默认构建器。清理迁移前 `.next` 缓存后,Webpack 编译、类型检查和静态生成均通过。
|
||||
- `npm run typecheck`:通过。
|
||||
- `node --experimental-strip-types --test tests/workspace.test.mjs`:4 项通过。
|
||||
- `node tests/api-smoke.mjs`:通过。覆盖首页、登录、登录回调、真实及演示工作台的 HTTP 响应、语言/标题/描述/viewport、404、Cookie 与令牌隔离、HTTPS Secure Cookie、登录态代理、跨站及非法 Origin、路径及查询白名单、请求方法、非法 JSON、超限请求体、Google/Slack 302、无效 challenge 和 204 退出清除 Cookie。
|
||||
- 本次未重跑浏览器交互与响应式验收:浏览器连接不可用;未进行真人账户登录或部署验证。
|
||||
- 迁移前 Pages Router 快照已先推送到现有仓库 main;本次迁移按用户明确授权直接更新 main,不创建 PR。
|
||||
|
||||
Vendored
+1
-1
@@ -4,4 +4,4 @@ import "./.next/types/routes.d.ts";
|
||||
import "./.next/types/root-params.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/pages/api-reference/config/typescript for more information.
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
const allowed =
|
||||
/^(profile|auth\/(login|register|verify-email|exchange|me|logout)|workspaces(?:\/[a-zA-Z0-9_-]+(?:\/(?:materials|search)(?:\/[a-zA-Z0-9_-]+)?)?)?)$/;
|
||||
const bodyLimit = 1024 * 1024;
|
||||
|
||||
// Route Handlers do not have the Pages Router bodyParser size limit.
|
||||
async function readBody(req: NextRequest) {
|
||||
const reader = req.body?.getReader();
|
||||
if (!reader) return {};
|
||||
const chunks: Uint8Array[] = [];
|
||||
let size = 0;
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
size += value.byteLength;
|
||||
if (size > bodyLimit) {
|
||||
await reader.cancel();
|
||||
throw new Error("body_too_large");
|
||||
}
|
||||
chunks.push(value);
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
if (!size) return {};
|
||||
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
||||
}
|
||||
|
||||
async function handler(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ path: string[] }> },
|
||||
) {
|
||||
const responseHeaders = new Headers({ "Cache-Control": "no-store" });
|
||||
const json = (body: unknown, status: number) =>
|
||||
NextResponse.json(body, { status, headers: responseHeaders });
|
||||
const requestOrigin = req.headers.get("origin");
|
||||
if (req.method !== "GET" && requestOrigin) {
|
||||
try {
|
||||
if (new URL(requestOrigin).host !== req.headers.get("host"))
|
||||
return json({ error: "invalid_origin" }, 403);
|
||||
} catch {
|
||||
return json({ error: "invalid_origin" }, 403);
|
||||
}
|
||||
}
|
||||
const path = (await params).path.join("/");
|
||||
if (!allowed.test(path)) return json({ error: "not_found" }, 404);
|
||||
let requestBody: unknown;
|
||||
if (req.method !== "GET") {
|
||||
try {
|
||||
requestBody = await readBody(req);
|
||||
} catch (error) {
|
||||
const tooLarge = error instanceof Error && error.message === "body_too_large";
|
||||
return json({ error: tooLarge ? "body_too_large" : "invalid_json" }, tooLarge ? 413 : 400);
|
||||
}
|
||||
}
|
||||
const origin = process.env.FOUNDRY_API_ORIGIN || "https://api.foundry.entroia.com";
|
||||
const project = process.env.NEXT_PUBLIC_FOUNDRY_PROJECT || "endoleap";
|
||||
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
||||
const session = req.cookies.get("foundry_session")?.value;
|
||||
if (session) headers.Authorization = "Bearer " + session;
|
||||
for (const h of ["x-foundry-code", "x-foundry-verifier", "if-match"]) {
|
||||
const value = req.headers.get(h);
|
||||
if (value !== null) headers[h] = value;
|
||||
}
|
||||
const query = new URLSearchParams();
|
||||
for (const key of ["kind", "sort", "q", "limit", "offset"]) {
|
||||
const values = req.nextUrl.searchParams.getAll(key);
|
||||
if (values.length === 1 && values[0].length < 300) query.set(key, values[0]);
|
||||
}
|
||||
const started = performance.now();
|
||||
try {
|
||||
const upstream = await fetch(
|
||||
origin + "/projects/" + encodeURIComponent(project) + "/" + path +
|
||||
(query.size ? "?" + query.toString() : ""),
|
||||
{
|
||||
method: req.method,
|
||||
headers,
|
||||
body: req.method === "GET" ? undefined : JSON.stringify(requestBody || {}),
|
||||
signal: AbortSignal.any([AbortSignal.timeout(15000), req.signal]),
|
||||
redirect: "manual",
|
||||
cache: "no-store",
|
||||
},
|
||||
);
|
||||
const headersReceived = performance.now();
|
||||
const body = upstream.status === 204 ? null : await upstream.json();
|
||||
responseHeaders.set(
|
||||
"Server-Timing",
|
||||
`upstream;dur=${(headersReceived - started).toFixed(1)}, total;dur=${(performance.now() - started).toFixed(1)}`,
|
||||
);
|
||||
if (body?.access_token) {
|
||||
responseHeaders.set(
|
||||
"Set-Cookie",
|
||||
`foundry_session=${encodeURIComponent(body.access_token)}; HttpOnly; SameSite=Lax; Path=/; Max-Age=${Math.min(body.expires_in || 28800, 28800)}${process.env.NODE_ENV === "production" && req.headers.get("x-forwarded-proto") === "https" ? "; Secure" : ""}`,
|
||||
);
|
||||
delete body.access_token;
|
||||
}
|
||||
if (path === "auth/logout" && upstream.ok)
|
||||
responseHeaders.set(
|
||||
"Set-Cookie",
|
||||
"foundry_session=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0",
|
||||
);
|
||||
if (upstream.status === 204)
|
||||
return new NextResponse(null, { status: 204, headers: responseHeaders });
|
||||
return json(body || { error: "upstream_unavailable" }, upstream.status);
|
||||
} catch (error) {
|
||||
responseHeaders.set("Server-Timing", `total;dur=${(performance.now() - started).toFixed(1)}`);
|
||||
const timeout = error instanceof Error && error.name === "TimeoutError";
|
||||
return json({ error: timeout ? "upstream_timeout" : "upstream_unavailable" }, timeout ? 504 : 502);
|
||||
}
|
||||
}
|
||||
|
||||
function methodNotAllowed() {
|
||||
return NextResponse.json({ error: "method_not_allowed" }, {
|
||||
status: 405,
|
||||
headers: { "Cache-Control": "no-store", Allow: "GET, POST, PATCH, DELETE" },
|
||||
});
|
||||
}
|
||||
|
||||
export { handler as GET, handler as POST, handler as PATCH, handler as DELETE };
|
||||
export { methodNotAllowed as PUT, methodNotAllowed as HEAD, methodNotAllowed as OPTIONS };
|
||||
@@ -0,0 +1,22 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ provider: string }> },
|
||||
) {
|
||||
const { provider } = await params;
|
||||
const challenges = req.nextUrl.searchParams.getAll("challenge");
|
||||
const challenge = challenges[0];
|
||||
if (
|
||||
!["google", "slack"].includes(provider) ||
|
||||
challenges.length !== 1 ||
|
||||
!/^[-_a-zA-Z0-9]{43}$/.test(challenge)
|
||||
)
|
||||
return NextResponse.json({ error: "invalid_request" }, { status: 400 });
|
||||
const origin = process.env.FOUNDRY_API_ORIGIN || "https://api.foundry.entroia.com";
|
||||
const project = process.env.NEXT_PUBLIC_FOUNDRY_PROJECT || "endoleap";
|
||||
return NextResponse.redirect(
|
||||
origin + "/projects/" + encodeURIComponent(project) + "/auth/" + provider + "?challenge=" + challenge,
|
||||
{ status: 302, headers: { "Cache-Control": "no-store" } },
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from "../../login/page";
|
||||
@@ -1,5 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState, type FormEvent } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
ArrowUpRight,
|
||||
@@ -68,7 +70,7 @@ const initialBoards: Board[] = [
|
||||
];
|
||||
export default function Dashboard() {
|
||||
const router = useRouter();
|
||||
const demo = router.query.demo === "1";
|
||||
const demo = useSearchParams().get("demo") === "1";
|
||||
const mutationVersion = useRef(0);
|
||||
const [boardsLoading, setBoardsLoading] = useState(false);
|
||||
const [spacesLoaded, setSpacesLoaded] = useState(false);
|
||||
@@ -93,7 +95,6 @@ export default function Dashboard() {
|
||||
[nodeText, setNodeText] = useState(""),
|
||||
[note, setNote] = useState("");
|
||||
useEffect(() => {
|
||||
if (!router.isReady) return;
|
||||
let active = true;
|
||||
if (demo) {
|
||||
setProfile({
|
||||
@@ -137,7 +138,7 @@ export default function Dashboard() {
|
||||
active = false;
|
||||
controller.abort();
|
||||
};
|
||||
}, [router.isReady, demo, router]);
|
||||
}, [demo, router]);
|
||||
useEffect(() => {
|
||||
if (demo && ready)
|
||||
localStorage.setItem(
|
||||
@@ -245,7 +246,7 @@ export default function Dashboard() {
|
||||
async function logout() {
|
||||
try {
|
||||
if (!demo) await api("auth/logout", { method: "POST" });
|
||||
await router.push("/login");
|
||||
router.push("/login");
|
||||
} catch (e) {
|
||||
setError(errorText(e));
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Suspense } from "react";
|
||||
import { Brand } from "@/components/brand";
|
||||
import Dashboard from "./dashboard";
|
||||
|
||||
export default function DashboardPage() {
|
||||
return (
|
||||
<Suspense fallback={
|
||||
<div className="loading-page">
|
||||
<Brand />
|
||||
<p>正在打开你的创作空间…</p>
|
||||
</div>
|
||||
}>
|
||||
<Dashboard />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { Metadata } from "next";
|
||||
import type { ReactNode } from "react";
|
||||
import "@/styles/globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "啡跃 Endoleap · 让灵感有形",
|
||||
description: "收藏灵感、连接知识,在自己的创作空间让想法慢慢成形。",
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<html lang="zh-CN">
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, type FormEvent } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
ArrowRight,
|
||||
Eye,
|
||||
@@ -65,7 +67,7 @@ export default function Login() {
|
||||
});
|
||||
if (register)
|
||||
setMessage("验证邮件已请求发送,请检查邮箱并点击验证链接。");
|
||||
else await router.push("/dashboard");
|
||||
else router.push("/dashboard");
|
||||
} catch (e) {
|
||||
setError(errorText(e));
|
||||
} finally {
|
||||
@@ -1,3 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { ArrowUpRight, ArrowRight, Plus, Minus } from "lucide-react";
|
||||
@@ -1,18 +0,0 @@
|
||||
import type { AppProps } from "next/app";
|
||||
import Head from "next/head";
|
||||
import "@/styles/globals.css";
|
||||
export default function App({ Component, pageProps }: AppProps) {
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>啡跃 Endoleap · 让灵感有形</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta
|
||||
name="description"
|
||||
content="收藏灵感、连接知识,在自己的创作空间让想法慢慢成形。"
|
||||
/>
|
||||
</Head>
|
||||
<Component {...pageProps} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import { Html, Head, Main, NextScript } from "next/document";
|
||||
export default function Document() {
|
||||
return (
|
||||
<Html lang="zh-CN">
|
||||
<Head />
|
||||
<body>
|
||||
<Main />
|
||||
<NextScript />
|
||||
</body>
|
||||
</Html>
|
||||
);
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
const allowed =
|
||||
/^(profile|auth\/(login|register|verify-email|exchange|me|logout)|workspaces(?:\/[a-zA-Z0-9_-]+(?:\/(?:materials|search)(?:\/[a-zA-Z0-9_-]+)?)?)?)$/;
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse,
|
||||
) {
|
||||
res.setHeader("Cache-Control", "no-store");
|
||||
if (!["GET", "POST", "PATCH", "DELETE"].includes(req.method || ""))
|
||||
return res.status(405).json({ error: "method_not_allowed" });
|
||||
if (req.method !== "GET" && req.headers.origin) {
|
||||
try {
|
||||
if (new URL(req.headers.origin).host !== req.headers.host)
|
||||
return res.status(403).json({ error: "invalid_origin" });
|
||||
} catch {
|
||||
return res.status(403).json({ error: "invalid_origin" });
|
||||
}
|
||||
}
|
||||
const path = (Array.isArray(req.query.path) ? req.query.path : []).join("/");
|
||||
if (!allowed.test(path)) return res.status(404).json({ error: "not_found" });
|
||||
const origin =
|
||||
process.env.FOUNDRY_API_ORIGIN || "https://api.foundry.entroia.com";
|
||||
const project = process.env.NEXT_PUBLIC_FOUNDRY_PROJECT || "endoleap";
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
if (req.cookies.foundry_session)
|
||||
headers.Authorization = "Bearer " + req.cookies.foundry_session;
|
||||
for (const h of ["x-foundry-code", "x-foundry-verifier", "if-match"])
|
||||
if (typeof req.headers[h] === "string")
|
||||
headers[h] = req.headers[h] as string;
|
||||
const query = new URLSearchParams();
|
||||
for (const key of ["kind", "sort", "q", "limit", "offset"]) {
|
||||
const v = req.query[key];
|
||||
if (typeof v === "string" && v.length < 300) query.set(key, v);
|
||||
}
|
||||
const started = performance.now();
|
||||
const disconnected = new AbortController();
|
||||
const onClose = () => {
|
||||
if (!res.writableEnded) disconnected.abort();
|
||||
};
|
||||
res.once("close", onClose);
|
||||
try {
|
||||
const upstream = await fetch(
|
||||
origin +
|
||||
"/projects/" +
|
||||
encodeURIComponent(project) +
|
||||
"/" +
|
||||
path +
|
||||
(query.size ? "?" + query.toString() : ""),
|
||||
{
|
||||
method: req.method,
|
||||
headers,
|
||||
body: req.method === "GET" ? undefined : JSON.stringify(req.body || {}),
|
||||
signal: AbortSignal.any([
|
||||
AbortSignal.timeout(15000),
|
||||
disconnected.signal,
|
||||
]),
|
||||
redirect: "manual",
|
||||
},
|
||||
);
|
||||
const headersReceived = performance.now();
|
||||
const body = upstream.status === 204 ? null : await upstream.json();
|
||||
res.setHeader(
|
||||
"Server-Timing",
|
||||
`upstream;dur=${(headersReceived - started).toFixed(1)}, total;dur=${(performance.now() - started).toFixed(1)}`,
|
||||
);
|
||||
const session = body?.access_token;
|
||||
if (session) {
|
||||
res.setHeader(
|
||||
"Set-Cookie",
|
||||
`foundry_session=${encodeURIComponent(session)}; HttpOnly; SameSite=Lax; Path=/; Max-Age=${Math.min(body.expires_in || 28800, 28800)}${process.env.NODE_ENV === "production" && req.headers["x-forwarded-proto"] === "https" ? "; Secure" : ""}`,
|
||||
);
|
||||
delete body.access_token;
|
||||
}
|
||||
if (path === "auth/logout" && upstream.ok)
|
||||
res.setHeader(
|
||||
"Set-Cookie",
|
||||
"foundry_session=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0",
|
||||
);
|
||||
if (upstream.status === 204) return res.status(204).end();
|
||||
return res
|
||||
.status(upstream.status)
|
||||
.json(body || { error: "upstream_unavailable" });
|
||||
} catch (error) {
|
||||
if (res.destroyed) return;
|
||||
res.setHeader(
|
||||
"Server-Timing",
|
||||
`total;dur=${(performance.now() - started).toFixed(1)}`,
|
||||
);
|
||||
const timeout = error instanceof Error && error.name === "TimeoutError";
|
||||
return res
|
||||
.status(timeout ? 504 : 502)
|
||||
.json({ error: timeout ? "upstream_timeout" : "upstream_unavailable" });
|
||||
} finally {
|
||||
res.off("close", onClose);
|
||||
}
|
||||
}
|
||||
export const config = { api: { bodyParser: { sizeLimit: "1mb" } } };
|
||||
@@ -1,26 +0,0 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
export default function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const p = req.query.provider,
|
||||
c = req.query.challenge;
|
||||
if (
|
||||
req.method !== "GET" ||
|
||||
!["google", "slack"].includes(String(p)) ||
|
||||
typeof c !== "string" ||
|
||||
!/^[-_a-zA-Z0-9]{43}$/.test(c)
|
||||
)
|
||||
return res.status(400).json({ error: "invalid_request" });
|
||||
const origin =
|
||||
process.env.FOUNDRY_API_ORIGIN || "https://api.foundry.entroia.com";
|
||||
const project = process.env.NEXT_PUBLIC_FOUNDRY_PROJECT || "endoleap";
|
||||
res.setHeader("Cache-Control", "no-store");
|
||||
res.redirect(
|
||||
302,
|
||||
origin +
|
||||
"/projects/" +
|
||||
encodeURIComponent(project) +
|
||||
"/auth/" +
|
||||
p +
|
||||
"?challenge=" +
|
||||
c,
|
||||
);
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { default } from "../login";
|
||||
+48
-2
@@ -6,7 +6,12 @@ import { fileURLToPath } from "node:url";
|
||||
const host = "http://127.0.0.1:3005";
|
||||
const upstream = createServer((req, res) => {
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
if (req.url.endsWith("/auth/login")) {
|
||||
if (req.url.endsWith("/auth/logout")) {
|
||||
res.statusCode = 204;
|
||||
res.end();
|
||||
} else if (req.url.includes("/workspaces/space/materials")) {
|
||||
res.end(JSON.stringify({ url: req.url, method: req.method }));
|
||||
} else if (req.url.endsWith("/auth/login")) {
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
access_token: "test-only-session",
|
||||
@@ -64,6 +69,16 @@ try {
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
assert(ready, "Frontend did not start");
|
||||
for (const path of ["/", "/login", "/auth/callback", "/dashboard", "/dashboard?demo=1"]) {
|
||||
const page = await fetch(host + path);
|
||||
assert.equal(page.status, 200, path);
|
||||
const html = await page.text();
|
||||
assert.match(html, /<html lang="zh-CN"/);
|
||||
assert.match(html, /啡跃 Endoleap · 让灵感有形/);
|
||||
assert.match(html, /name="description"/);
|
||||
assert.match(html, /name="viewport"/);
|
||||
}
|
||||
assert.equal((await fetch(host + "/missing-page")).status, 404);
|
||||
const res = await fetch(host + "/api/foundry/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Origin: host },
|
||||
@@ -103,8 +118,39 @@ try {
|
||||
(await fetch(host + "/api/oauth/slack?challenge=bad")).status,
|
||||
400,
|
||||
);
|
||||
assert.equal(res.headers.get("cache-control"), "no-store");
|
||||
const secure = await fetch(host + "/api/foundry/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", "X-Forwarded-Proto": "https" },
|
||||
body: "{}",
|
||||
});
|
||||
assert.match(secure.headers.get("set-cookie"), /; Secure/);
|
||||
for (const [body, status] of [["{", 400], [JSON.stringify({ text: "x".repeat(1024 * 1024) }), 413]]) {
|
||||
const invalid = await fetch(host + "/api/foundry/auth/login", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" }, body,
|
||||
});
|
||||
assert.equal(invalid.status, status);
|
||||
}
|
||||
assert.equal((await fetch(host + "/api/foundry/profile", { method: "PUT" })).status, 405);
|
||||
const materials = await fetch(host + "/api/foundry/workspaces/space/materials?kind=board&limit=100&secret=drop", {
|
||||
method: "PATCH", headers: { "Content-Type": "application/json" }, body: "{}",
|
||||
});
|
||||
assert.deepEqual(await materials.json(), {
|
||||
url: "/projects/endoleap/workspaces/space/materials?kind=board&limit=100", method: "PATCH",
|
||||
});
|
||||
for (const provider of ["google", "slack"]) {
|
||||
const redirect = await fetch(host + "/api/oauth/" + provider + "?challenge=" + "a".repeat(43), { redirect: "manual" });
|
||||
assert.equal(redirect.status, 302);
|
||||
assert.equal(redirect.headers.get("location"), `http://127.0.0.1:${port}/projects/endoleap/auth/${provider}?challenge=${"a".repeat(43)}`);
|
||||
}
|
||||
const logout = await fetch(host + "/api/foundry/auth/logout", {
|
||||
method: "POST", headers: { "Content-Type": "application/json", Cookie: session }, body: "{}",
|
||||
});
|
||||
assert.equal(logout.status, 204);
|
||||
assert.equal(await logout.text(), "");
|
||||
assert.match(logout.headers.get("set-cookie"), /Max-Age=0/);
|
||||
console.log(
|
||||
"PASS: session cookie, token removal, authenticated proxy, invalid session, cross-origin rejection, path allowlist, OAuth validation",
|
||||
"PASS: App Router pages and metadata, body validation, method and query filtering, OAuth redirects, logout, session cookie, token removal, authenticated proxy, invalid session, cross-origin rejection, path allowlist, OAuth validation",
|
||||
);
|
||||
} finally {
|
||||
const exited = once(app, "exit");
|
||||
|
||||
+24
-5
@@ -1,7 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
@@ -14,9 +18,24 @@
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
]
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
]
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user