refactor: migrate pages and API routes to App Router

This commit is contained in:
2026-11-05 23:20:34 +08:00
parent 982fa188e2
commit 6c4d6100b7
18 changed files with 287 additions and 174 deletions
+122
View File
@@ -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 };
+22
View File
@@ -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" } },
);
}
+1
View File
@@ -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));
}
+16
View File
@@ -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>
);
}
+16
View File
@@ -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 {
+2
View File
@@ -1,3 +1,5 @@
"use client";
import Link from "next/link";
import { useState } from "react";
import { ArrowUpRight, ArrowRight, Plus, Minus } from "lucide-react";
-18
View File
@@ -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} />
</>
);
}
-12
View File
@@ -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>
);
}
-99
View File
@@ -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" } } };
-26
View File
@@ -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
View File
@@ -1 +0,0 @@
export { default } from "../login";