refactor: migrate pages and API routes to App Router
This commit is contained in:
@@ -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";
|
||||
Reference in New Issue
Block a user