Files
leaudit-platform-frontend/app/root.tsx
T

297 lines
9.8 KiB
TypeScript

// import React from 'react';
import {
Links,
// LiveReload, // 不再需要,使用Vite时会与内置HMR冲突
Meta,
Outlet,
Scripts,
ScrollRestoration,
isRouteErrorResponse,
useRouteError,
type MetaFunction,
useLoaderData
} from "@remix-run/react";
import {
LoaderFunctionArgs,
redirect,
ActionFunctionArgs
} from "@remix-run/node";
import { Layout } from "~/components/layout/Layout";
import { ErrorBoundary as AppErrorBoundary } from "~/components/error/ErrorBoundary";
import { MessageModalProvider } from "~/components/ui/MessageModal";
import { ToastProvider } from "~/components/ui/Toast";
import "remixicon/fonts/remixicon.css";
// 导入样式
import styles from "~/styles/main.css?url";
import messageModalStyles from "~/styles/components/message-modal.css?url";
import toastStyles from "~/styles/components/toast.css?url";
import LoadingBarContainer from "~/components/ui/LoadingBar";
import RouteChangeLoader from "~/components/ui/RouteChangeLoader";
// import { useState, useEffect } from "react";
// 导入认证相关的服务器端功能(仅在服务器端使用)
import {
getUserSession,
getSession,
sessionStorage,
logout,
type UserRole
} from "~/api/login/auth.server";
import {
validateRequest,
isProtectedRoute,
logSecurityEvent
} from "~/middleware/host-validation";
// 定义需要高级权限的路径
export const developerOnlyPaths = [
'/settings',
'/config-lists',
'/document-types',
'/prompts',
];
// 导出类型供客户端使用
export type { UserRole };
// 添加action处理登录/登出请求
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData();
const intent = formData.get("intent");
if (intent === "logout") {
return logout(request);
}
return null;
}
// 添加loader函数进行全局认证检查并传递环境变量给客户端
export async function loader({ request }: LoaderFunctionArgs) {
// 获取当前路径
const url = new URL(request.url);
const pathname = url.pathname;
// ==================== Host头验证 ====================
// 1. 首先进行Host头验证,防止Host Header注入攻击
const hostValidation = validateRequest(request);
if (!hostValidation.valid) {
// 记录安全事件
logSecurityEvent('host_validation_failed', hostValidation.error || 'Unknown validation error', request);
// 对于受保护的路由,直接返回403错误
if (isProtectedRoute(pathname)) {
throw new Response("Forbidden: Invalid Host header", {
status: 403,
statusText: "Forbidden"
});
}
// 对于普通路由,重定向到错误页面
console.error('❌ Host验证失败:', hostValidation.error);
throw new Response("Forbidden: Invalid request headers", {
status: 403,
statusText: "Forbidden"
});
}
// console.log('✅ Host验证通过,继续处理请求');
// ==================== Host头验证结束 ====================
// 排除不需要登录验证的路径
const publicPaths = ['/login', '/favicon.ico', '/callback'];
const isPublicPath = publicPaths.some(path => pathname.startsWith(path));
// 获取用户会话(可能包含刷新后的token)
const { isAuthenticated, userRole, refreshedSession } = await getUserSession(request);
// console.log("是否公开路径:", isPublicPath, "是否已认证:", isAuthenticated);
// 如果访问需要认证的路径但未登录,重定向到登录页
if (!isPublicPath && !isAuthenticated) {
console.log("未认证,需要重定向到登录页");
// 保存请求的URL,以便登录后重定向回来
const session = await getSession(request);
// 如果路径是/home,则将重定向目标设置为/
const redirectTarget = pathname !== "/" ? "/" : pathname;
// const redirectTarget = pathname === "home" ? "/" : pathname;
// 保存重定向目标
session.set("redirectTo", redirectTarget);
return redirect("/login", {
headers: {
"Set-Cookie": await sessionStorage.commitSession(session),
},
});
}
// 如果已登录且访问登录页,重定向到首页
if (pathname === "/login" && isAuthenticated) {
console.log("已认证,重定向到首页");
return redirect("/");
}
// 检查访问权限 - 如果是common用户访问了开发者专属页面,重定向到首页
if (userRole === 'common' && developerOnlyPaths.some(path => pathname.startsWith(path))) {
console.log("用户没有访问权限,重定向到首页");
return redirect("/");
}
// 检查5178端口访问控制
// 由于应用直接运行在5178端口,我们需要从环境变量或运行时获取端口
const currentPort = process.env.PORT || process.env.API_PORT_CONFIG;
// console.log("currentPort-----------",currentPort)
// 获取运行时端口(从请求URL或环境变量)
const runtimePort = url.port || currentPort;
// console.log("runtimePort-----------",runtimePort)
const isPort51708 = currentPort === '5178' || runtimePort === '5178';
if (isPort51708 && !isPublicPath) {
// 51708端口只允许访问交叉评查相关路径和首页
const allowedPaths = ['/', '/cross-checking','/chat-with-llm'];
const isAllowedPath = allowedPaths.some(path => pathname === path) ||
pathname.startsWith('/cross-checking/') ||
pathname.startsWith('/chat-with-llm/');
if (!isAllowedPath) {
// console.log("5178端口访问受限,重定向到交叉评查页面");
return redirect("/cross-checking");
}
}
// 如果token被刷新了,需要在响应中设置更新后的cookie
const responseHeaders: Record<string, string> = {};
if (refreshedSession) {
responseHeaders["Set-Cookie"] = await sessionStorage.commitSession(refreshedSession);
}
// 向组件传递认证状态、当前路径和环境变量
return Response.json({
isAuthenticated,
userRole,
pathname,
ENV: {
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL,
NEXT_PUBLIC_APP_ID: process.env.NEXT_PUBLIC_APP_ID,
NEXT_PUBLIC_APP_KEY: process.env.NEXT_PUBLIC_APP_KEY,
},
}, {
headers: responseHeaders
});
}
export const meta: MetaFunction = () => {
return [
{ charSet: "utf-8" },
{ name: "viewport", content: "width=device-width,initial-scale=1" },
{ title: "中国烟草AI合同及卷宗审核系统" },
{ name: "description", content: "专业的AI合同及卷宗评查系统,提供智能审核、风险评估和规范化建议" },
{ name: "robots", content: "noindex,nofollow" } // 内部系统,防止被搜索引擎索引
];
};
// 使用links函数为应用加载CSS和其他资源
export function links() {
return [
{ rel: "stylesheet", href: styles },
{ rel: "stylesheet", href: messageModalStyles },
{ rel: "stylesheet", href: toastStyles },
// 添加 Antd 样式
// { rel: "stylesheet", href: "https://cdn.jsdelivr.net/npm/antd@5/dist/reset.css" },
{ rel: "icon", type: "image/svg+xml", href: "/logo.svg" },
// { rel: "preconnect", href: "https://fonts.googleapis.com" },
// { rel: "preconnect", href: "https://fonts.gstatic.com", crossOrigin: "anonymous" },
// { rel: "stylesheet", href: "https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;500;700&display=swap" }
];
}
export default function App() {
const { userRole, ENV } = useLoaderData<typeof loader>();
return (
<html lang="zh-CN">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<style dangerouslySetInnerHTML={{
__html: `
:root {
--color-primary: #00684a;
--color-primary-hover: #005a3f;
--color-primary-light: rgba(0, 104, 74, 0.1);
--primary-color: #00684a;
/* 成功、警告、错误颜色 */
--color-success: #52c41a;
--color-warning: #faad14;
--color-error: #f5222d;
}
` }} />
<Meta />
<Links />
<script
dangerouslySetInnerHTML={{
__html: `window.__ENV = ${JSON.stringify(ENV)}`,
}}
/>
</head>
<body className="font-sans">
<MessageModalProvider>
<ToastProvider>
<Layout userRole={userRole}>
<Outlet />
</Layout>
<RouteChangeLoader />
</ToastProvider>
</MessageModalProvider>
<ScrollRestoration />
<Scripts />
<LoadingBarContainer />
</body>
</html>
);
}
export function ErrorBoundary() {
const error = useRouteError();
// 为错误页面设置标题和描述
let title = "发生错误";
let message = "发生了一个未知错误,请稍后重试";
if (isRouteErrorResponse(error)) {
title = `错误 ${error.status}`;
message = error.data?.message || "发生了一个错误,请稍后重试";
} else {
title = "意外错误";
message = "服务器发生了意外错误,请稍后重试";
}
return (
<html lang="zh-CN">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<meta name="description" content={message} />
<title>{title}</title>
<Links />
</head>
<body>
<AppErrorBoundary
status={isRouteErrorResponse(error) ? error.status : 500}
statusText={isRouteErrorResponse(error) ? error.statusText : "服务器错误"}
message={message}
/>
<Scripts />
</body>
</html>
);
}