开源前加固认证与部署契约:加密随机 JWT、改密事务吊销、登录 CSRF,并禁止生产回落 localhost。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -6,9 +6,14 @@
|
||||
NEXT_PUBLIC_API_URL=http://localhost:3001
|
||||
|
||||
# 仅服务端可见(middleware 与 SSR 优先使用)。
|
||||
# 部署到 Cloudflare Workers 等边缘环境时【必须】显式配置为后端 HTTPS 地址,
|
||||
# 边缘运行时不存在 localhost,留空会导致 SSR 登录态轮转直接放行失效。
|
||||
# BACKEND_URL=https://your-backend.example.com
|
||||
# 生产构建【必须】显式配置;缺省会直接失败,禁止烤进 localhost。
|
||||
# Docker 官方镜像构建期注入 http://api:3001。
|
||||
# BACKEND_URL=http://localhost:3001
|
||||
|
||||
# 与后端 DEV_MODE 对齐:true 时 cookie 无 __Host- 前缀。
|
||||
# 本地 next start 对着 DEV_MODE=true 的 Go 时请设为 true,避免 cookie 名分叉。
|
||||
# 官方 Docker 写死 false。
|
||||
DEV_MODE=true
|
||||
|
||||
# 可选。官方 Docker 构建会写入与仓库根 VERSION 相同的值;本地 dev 可不设。
|
||||
# NEXT_PUBLIC_APP_VERSION=0.1.0
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useLayoutEffect, useRef, type KeyboardEvent, type ReactNode } from "react";
|
||||
import { useRef, type KeyboardEvent, type ReactNode } from "react";
|
||||
import { Globe } from "lucide-react";
|
||||
import {
|
||||
AdminPageHeader,
|
||||
@@ -19,32 +19,11 @@ const NAV: { href: string; label: string }[] = [
|
||||
{ href: "/admin/settings/maintenance", label: "维护与诊断" },
|
||||
];
|
||||
|
||||
/** 旧 #hash / ?section= → 独立路由 */
|
||||
const LEGACY: Record<string, string> = {
|
||||
basic: "/admin/settings/basic",
|
||||
content: "/admin/settings/access",
|
||||
security: "/admin/settings/access",
|
||||
access: "/admin/settings/access",
|
||||
mail: "/admin/settings/mail",
|
||||
storage: "/admin/settings/storage",
|
||||
filter: "/admin/settings/filter",
|
||||
maintenance: "/admin/settings/maintenance",
|
||||
};
|
||||
|
||||
export default function SettingsShell({ children }: { children: ReactNode }) {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const navRefs = useRef<(HTMLAnchorElement | null)[]>([]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const hash = window.location.hash.replace(/^#/, "").trim();
|
||||
const section = new URLSearchParams(window.location.search).get("section") || hash;
|
||||
if (!section) return;
|
||||
const dest = LEGACY[section];
|
||||
if (!dest || dest === pathname) return;
|
||||
router.replace(dest);
|
||||
}, [pathname, router]);
|
||||
|
||||
const onNavKeyDown = (e: KeyboardEvent<HTMLAnchorElement>, i: number) => {
|
||||
if (
|
||||
e.key !== "ArrowDown" &&
|
||||
|
||||
@@ -5,24 +5,7 @@ export const metadata: Metadata = {
|
||||
title: "站点设置",
|
||||
};
|
||||
|
||||
interface PageProps {
|
||||
searchParams: Promise<{ section?: string }>;
|
||||
}
|
||||
|
||||
const LEGACY: Record<string, string> = {
|
||||
basic: "/admin/settings/basic",
|
||||
content: "/admin/settings/access",
|
||||
security: "/admin/settings/access",
|
||||
access: "/admin/settings/access",
|
||||
mail: "/admin/settings/mail",
|
||||
storage: "/admin/settings/storage",
|
||||
filter: "/admin/settings/filter",
|
||||
maintenance: "/admin/settings/maintenance",
|
||||
};
|
||||
|
||||
/** /admin/settings 与旧 ?section= 统一跳到子路由 */
|
||||
export default async function SettingsIndexPage({ searchParams }: PageProps) {
|
||||
const sp = await searchParams;
|
||||
const raw = typeof sp.section === "string" ? sp.section.trim() : "";
|
||||
redirect(LEGACY[raw] || "/admin/settings/basic");
|
||||
/** /admin/settings 统一进入基本信息子路由 */
|
||||
export default async function SettingsIndexPage() {
|
||||
redirect("/admin/settings/basic");
|
||||
}
|
||||
|
||||
@@ -1,17 +1,56 @@
|
||||
"use client";
|
||||
import {useEffect,useState} from "react";
|
||||
import {usePathname} from "next/navigation";
|
||||
import {apiOperations} from "@/lib/api";
|
||||
export default function OperationalBanner(){
|
||||
const path=usePathname();
|
||||
const [notice,setNotice]=useState("");
|
||||
useEffect(()=>{
|
||||
let active=true;
|
||||
const refresh=()=>{if(document.visibilityState!=="visible")return; void apiOperations<{maintenance:{mode:string;title:string;message:string}} >("/api/site-state").then(s=>{if(active)setNotice(s.maintenance.mode==="readonly" ? (s.maintenance.message || "站点暂时只读,可以浏览,暂不能提交或修改内容。") : "");}).catch(()=>{});};
|
||||
refresh();const timer=setInterval(refresh,30000);
|
||||
document.addEventListener("visibilitychange",refresh);
|
||||
return ()=>{active=false;clearInterval(timer);document.removeEventListener("visibilitychange",refresh);};
|
||||
},[path]);
|
||||
if(path.startsWith("/admin") || !notice)return null;
|
||||
return <div role="status" className="panel mx-auto my-3 w-full max-w-5xl p-4 text-sm"><strong>只读模式:</strong>{notice}</div>;
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { apiOperations } from "@/lib/api";
|
||||
import { realtime, RT_SETTINGS_CHANGED } from "@/lib/realtime";
|
||||
import {
|
||||
VISIBLE_RECONCILE_GAP_MS,
|
||||
onDocumentVisible,
|
||||
} from "@/lib/visibilityReconcile";
|
||||
|
||||
/** 只读维护条:WS settings:changed 优先,回前台再校准(无定时轮询) */
|
||||
export default function OperationalBanner() {
|
||||
const path = usePathname();
|
||||
const [notice, setNotice] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
|
||||
const apply = (mode: string | undefined, message: string | undefined) => {
|
||||
if (!active) return;
|
||||
setNotice(
|
||||
mode === "readonly"
|
||||
? message || "站点暂时只读,可以浏览,暂不能提交或修改内容。"
|
||||
: ""
|
||||
);
|
||||
};
|
||||
|
||||
const refresh = () => {
|
||||
if (document.visibilityState !== "visible") return;
|
||||
void apiOperations<{
|
||||
maintenance: { mode: string; title: string; message: string };
|
||||
}>("/api/site-state")
|
||||
.then((s) => apply(s.maintenance?.mode, s.maintenance?.message))
|
||||
.catch(() => {});
|
||||
};
|
||||
|
||||
refresh();
|
||||
const offPush = realtime.on(RT_SETTINGS_CHANGED, () => refresh());
|
||||
const offVisible = onDocumentVisible(refresh, VISIBLE_RECONCILE_GAP_MS);
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
offPush();
|
||||
offVisible();
|
||||
};
|
||||
}, [path]);
|
||||
|
||||
if (path.startsWith("/admin") || !notice) return null;
|
||||
return (
|
||||
<div role="status" className="panel mx-auto my-3 w-full max-w-5xl p-4 text-sm">
|
||||
<strong>只读模式:</strong>
|
||||
{notice}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -378,6 +378,7 @@ function ActiveUsers({ users, allowComments }: { users: ActiveUser[]; allowComme
|
||||
/* ---------- 友情链接 ---------- */
|
||||
|
||||
function FriendLinks() {
|
||||
if (FRIEND_LINKS.length === 0) return null;
|
||||
return (
|
||||
<section className="j13-module" aria-label="友情链接">
|
||||
<p className="j13-module-title mb-3">
|
||||
|
||||
@@ -21,10 +21,19 @@ export type { FooterLink, FooterLinksAlign };
|
||||
// API 基础配置
|
||||
// 注意:客户端请求使用相对路径 /api/*,走 Next.js rewrite 代理到后端,
|
||||
// 这样浏览器视为同源,Cookie 自动携带,无需处理 CORS。
|
||||
// SSR/middleware 直连后端:优先服务端专用 BACKEND_URL(边缘部署必须显式配置,
|
||||
// localhost 兜底只在本地开发有效),回退到 NEXT_PUBLIC_API_URL。
|
||||
const API_BASE =
|
||||
process.env.BACKEND_URL || process.env.NEXT_PUBLIC_API_URL || "http://localhost:3001";
|
||||
// SSR/middleware 直连后端:优先 BACKEND_URL;生产禁止静默回落 localhost。
|
||||
const API_BASE = (() => {
|
||||
const fromEnv =
|
||||
process.env.BACKEND_URL || process.env.NEXT_PUBLIC_API_URL || "";
|
||||
if (fromEnv) return fromEnv;
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
if (typeof window === "undefined") {
|
||||
throw new Error("生产环境必须设置 BACKEND_URL(SSR 直连后端)");
|
||||
}
|
||||
return "";
|
||||
}
|
||||
return "http://localhost:3001";
|
||||
})();
|
||||
|
||||
// SSR 请求超时:后端不可用时快速降级为游客视图,不阻塞页面渲染
|
||||
const SSR_TIMEOUT_MS = 8000;
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
// 认证 cookie 名(前后端必须严格一致):
|
||||
// 生产构建(HTTPS)启用 __Host- 前缀,浏览器强制 Secure + Path=/ + 无 Domain;
|
||||
// 后端按 !DevMode 同步启用(见 backend/service/auth.go ConfigureCookieNames)。
|
||||
const HOST_PREFIX = process.env.NODE_ENV === "production" ? "__Host-" : "";
|
||||
// 与后端 ConfigureCookieNames(!DevMode) 对齐——显式 DEV_MODE=true|1 禁用 __Host-;
|
||||
// 显式 DEV_MODE=false|0 启用;未设置时回退 NODE_ENV=production(官方镜像两者同时满足)。
|
||||
function hostCookiePrefix(): string {
|
||||
const dm = (process.env.DEV_MODE || "").trim().toLowerCase();
|
||||
if (dm === "true" || dm === "1") return "";
|
||||
if (dm === "false" || dm === "0") return "__Host-";
|
||||
return process.env.NODE_ENV === "production" ? "__Host-" : "";
|
||||
}
|
||||
|
||||
const HOST_PREFIX = hostCookiePrefix();
|
||||
|
||||
export const TOKEN_COOKIE = `${HOST_PREFIX}j13_token`;
|
||||
export const REFRESH_COOKIE = `${HOST_PREFIX}j13_refresh`;
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
// 友情链接(站点级静态配置)。
|
||||
// TODO: 替换为真实友链名称与地址;后续如需后台可配,可升级为接口/设置项。
|
||||
// 友情链接(站点级静态配置)。无条目时侧栏不渲染该区块。
|
||||
export interface FriendLink {
|
||||
name: string;
|
||||
href: string;
|
||||
}
|
||||
|
||||
export const FRIEND_LINKS: FriendLink[] = [
|
||||
{ name: "大佬论坛", href: "#" },
|
||||
{ name: "Leo论坛", href: "#" },
|
||||
];
|
||||
export const FRIEND_LINKS: FriendLink[] = [];
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
// 后端地址:开发环境兜底 localhost:3001,生产部署必须显式配置 BACKEND_URL
|
||||
// 后端地址:开发可兜底 localhost:3001;生产构建必须显式 BACKEND_URL(禁止静默烤进 localhost)
|
||||
const isProd = process.env.NODE_ENV === "production";
|
||||
const API_TARGET =
|
||||
process.env.BACKEND_URL || process.env.NEXT_PUBLIC_API_URL || "http://localhost:3001";
|
||||
process.env.BACKEND_URL ||
|
||||
process.env.NEXT_PUBLIC_API_URL ||
|
||||
(isProd ? "" : "http://localhost:3001");
|
||||
|
||||
if (!API_TARGET) {
|
||||
throw new Error(
|
||||
"生产构建必须设置 BACKEND_URL(例如 Docker 内 http://api:3001),禁止回落 localhost"
|
||||
);
|
||||
}
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
// 官方 Docker 运行时:最小产物,不含完整 node_modules。next dev 忽略此项。
|
||||
|
||||
Reference in New Issue
Block a user