修复md导入问题

This commit is contained in:
2026-03-03 14:07:56 +08:00
parent 911c669d81
commit 7ea9bd7599
23 changed files with 896 additions and 526 deletions
+22 -3
View File
@@ -1,7 +1,21 @@
import { NextRequest, NextResponse } from "next/server";
import { requireApiAuth } from "@/lib/api-auth";
export const runtime = "edge"; // Optional: Use edge runtime for lower latency
// 禁止向内网地址发起请求,防止 SSRF 攻击
const PRIVATE_HOST_PATTERNS = [
/^localhost$/i,
/^127\.\d+\.\d+\.\d+$/,
/^10\.\d+\.\d+\.\d+$/,
/^172\.(1[6-9]|2\d|3[01])\.\d+\.\d+$/,
/^192\.168\.\d+\.\d+$/,
/^0\.0\.0\.0$/,
/^\[::1?\]$/,
/^169\.254\.\d+\.\d+$/, // Link-local
];
function isPrivateHost(hostname: string): boolean {
return PRIVATE_HOST_PATTERNS.some((pattern) => pattern.test(hostname));
}
export async function POST(req: NextRequest) {
const authError = await requireApiAuth();
@@ -41,6 +55,10 @@ export async function POST(req: NextRequest) {
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
return NextResponse.json({ error: "Unsupported API base URL protocol" }, { status: 400 });
}
// SSRF 防护:禁止向内网地址发起请求
if (isPrivateHost(parsed.hostname)) {
return NextResponse.json({ error: "API base URL must not point to a private/internal address" }, { status: 400 });
}
normalizedBaseUrl = parsed.origin + parsed.pathname.replace(/\/$/, "");
} catch {
return NextResponse.json({ error: "Invalid API base URL" }, { status: 400 });
@@ -57,7 +75,7 @@ export async function POST(req: NextRequest) {
body: JSON.stringify({
model: typeof model === "string" && model.trim() ? model.trim() : "gpt-3.5-turbo",
messages,
stream: true, // Force streaming
stream: true, // 强制使用流式
}),
});
@@ -66,7 +84,7 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: `Upstream Error: ${res.statusText}`, details: errorText }, { status: res.status });
}
// Return the stream directly
// 直接返回上游 stream
return new Response(res.body, {
headers: {
"Content-Type": "text/event-stream",
@@ -80,3 +98,4 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: "Internal Server Error" }, { status: 500 });
}
}
+13
View File
@@ -2,8 +2,21 @@ import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { verifyPassword } from "@/lib/auth";
import { createSessionToken, getSessionCookieName, getSessionTtlSeconds } from "@/lib/session";
import { isRateLimited, getClientIp } from "@/lib/rate-limit";
// 登录速率限制:每个 IP 60 秒内最多 5 次尝试
const LOGIN_MAX_ATTEMPTS = 5;
const LOGIN_WINDOW_MS = 60 * 1000;
export async function POST(req: Request) {
const clientIp = getClientIp(req);
if (isRateLimited(`login:${clientIp}`, LOGIN_MAX_ATTEMPTS, LOGIN_WINDOW_MS)) {
return NextResponse.json(
{ error: "Too many login attempts, please try again later" },
{ status: 429 }
);
}
try {
const { password, rememberMe, durationDays } = await req.json().catch(() => ({}));
if (typeof password !== "string" || password.length === 0) {
+1 -27
View File
@@ -3,13 +3,10 @@ import { prisma } from '@/lib/prisma';
import type { Prisma } from '@prisma/client';
import { requireApiAuth } from '@/lib/api-auth';
import { cleanupAccidentalStandaloneInlineCode, markdownToImportHtml } from '@/lib/markdown-import';
import { MAX_TITLE_LENGTH, safeParseTags, normalizeTags } from '@/lib/page-utils';
type PageRef = { id: string; parentId: string | null };
const MAX_TITLE_LENGTH = 200;
const MAX_TAGS = 20;
const MAX_TAG_LENGTH = 50;
function buildChildrenMap(pages: PageRef[]): Map<string | null, string[]> {
const childrenMap = new Map<string | null, string[]>();
for (const page of pages) {
@@ -46,29 +43,6 @@ function collectDeleteOrder(rootId: string, pages: PageRef[]): string[] {
return order;
}
function safeParseTags(tags: string | null): string[] {
if (!tags) return [];
try {
const parsed = JSON.parse(tags);
return Array.isArray(parsed) ? parsed.filter((t): t is string => typeof t === 'string') : [];
} catch {
return [];
}
}
function normalizeTags(input: unknown): string[] {
if (!Array.isArray(input)) return [];
const unique = new Set<string>();
for (const raw of input) {
if (typeof raw !== 'string') continue;
const normalized = raw.trim();
if (!normalized || normalized.length > MAX_TAG_LENGTH) continue;
unique.add(normalized);
if (unique.size >= MAX_TAGS) break;
}
return Array.from(unique);
}
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
+1 -27
View File
@@ -2,33 +2,7 @@
import { prisma } from '@/lib/prisma';
import { requireApiAuth } from '@/lib/api-auth';
import { cleanupAccidentalStandaloneInlineCode, markdownToImportHtml } from '@/lib/markdown-import';
const MAX_TITLE_LENGTH = 200;
const MAX_TAGS = 20;
const MAX_TAG_LENGTH = 50;
function safeParseTags(tags: string | null): string[] {
if (!tags) return [];
try {
const parsed = JSON.parse(tags);
return Array.isArray(parsed) ? parsed.filter((t): t is string => typeof t === 'string') : [];
} catch {
return [];
}
}
function normalizeTags(input: unknown): string[] {
if (!Array.isArray(input)) return [];
const unique = new Set<string>();
for (const raw of input) {
if (typeof raw !== 'string') continue;
const normalized = raw.trim();
if (!normalized || normalized.length > MAX_TAG_LENGTH) continue;
unique.add(normalized);
if (unique.size >= MAX_TAGS) break;
}
return Array.from(unique);
}
import { MAX_TITLE_LENGTH, safeParseTags, normalizeTags } from '@/lib/page-utils';
export async function GET() {
const authError = await requireApiAuth();
+3
View File
@@ -153,6 +153,9 @@ export async function POST(req: NextRequest) {
);
await prisma.$transaction(async (tx) => {
// ⚠️ 注意:这是一个破坏性操作,会删除所有现有页面后重新导入。
// TODO: 后续可在此添加自动备份逻辑(导出当前数据到临时文件),
// 或在前端恢复前强制用户先手动备份。
await tx.page.deleteMany();
const folderIdMap = new Map<string, string>();
+7
View File
@@ -1,6 +1,13 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
/**
* 检查系统是否已初始化(是否已设置密码)。
*
* 注意:此接口**不需要认证**,因为登录页面需要在用户未认证时
* 调用此接口来判断是否需要显示"初始化密码"还是"登录"界面。
*/
export async function GET() {
try {
const settings = await prisma.globalSettings.findUnique({
+35
View File
@@ -0,0 +1,35 @@
"use client";
import { useEffect } from "react";
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
console.error("页面错误:", error);
}, [error]);
return (
<div className="flex h-[100dvh] flex-col items-center justify-center gap-4 p-8">
<div className="rounded-2xl border border-border/70 bg-muted/40 p-4">
<span className="text-4xl"></span>
</div>
<div className="space-y-1 text-center">
<h2 className="text-lg font-semibold text-foreground"></h2>
<p className="text-sm text-muted-foreground">
</p>
</div>
<button
onClick={reset}
className="rounded-lg border border-border bg-background px-4 py-2 text-sm font-medium text-foreground transition-colors hover:bg-accent"
>
</button>
</div>
);
}
+25 -10
View File
@@ -132,6 +132,7 @@
opacity: 0;
transform: translateY(6px) scale(0.99);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
@@ -139,16 +140,19 @@
}
@keyframes ui-float {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-4px);
}
}
@media (prefers-reduced-motion: reduce) {
.ui-enter,
.ui-enter-delayed,
.ui-float,
@@ -170,12 +174,19 @@
@media (max-width: 768px) {
.ProseMirror {
font-size: 1rem;
line-height: max(1.55, var(--editor-line-height, 1.5));
font-size: 1.05rem;
line-height: max(1.6, var(--editor-line-height, 1.5));
padding-bottom: 120px;
}
/* 移动端侧边栏文档树:增大字号和触摸目标 */
.sidebar-tree-item {
font-size: 0.875rem;
min-height: 2.75rem;
}
}
.ProseMirror > * + * {
.ProseMirror>*+* {
margin-top: 0.7em;
}
@@ -279,11 +290,15 @@
line-height: var(--table-line-height, 1.2);
}
/* 利用 TipTap 表格扩展的 .tableWrapper 包裹层实现横向滚动 */
.ProseMirror .tableWrapper {
overflow-x: auto;
margin: 1em 0;
}
.ProseMirror table {
border-collapse: collapse;
margin: 1em 0;
overflow: hidden;
table-layout: fixed;
table-layout: auto;
width: 100%;
border-radius: 0.5rem;
}
@@ -346,6 +361,7 @@ ul[data-type="taskList"],
border-radius: 0.5rem;
padding: 0.65rem 0.75rem;
margin: 0.95rem 0;
overflow-x: auto;
}
.hljs {
@@ -353,15 +369,14 @@ ul[data-type="taskList"],
/* Let pre handle the background */
}
.ProseMirror > .block-drag-active {
.ProseMirror>.block-drag-active {
border-radius: 0.5rem;
background: hsl(var(--accent) / 0.45);
box-shadow: inset 0 0 0 1px hsl(var(--border) / 0.7);
}
.ProseMirror > .block-selected {
.ProseMirror>.block-selected {
border-radius: 0.5rem;
background: linear-gradient(180deg, hsl(var(--accent) / 0.42), hsl(var(--accent) / 0.25));
box-shadow: inset 0 0 0 1px hsl(var(--ring) / 0.28);
}
}
+9 -1
View File
@@ -1,4 +1,4 @@
import type { Metadata } from "next";
import type { Metadata, Viewport } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
import { ThemeProvider } from "@/components/theme-provider";
@@ -6,6 +6,14 @@ import { ConfirmProvider } from "@/components/confirm-provider";
const inter = Inter({ subsets: ["latin"] });
// 移动端必须:确保按设备实际宽度渲染,防止文字和元素过小
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
maximumScale: 1,
userScalable: false,
};
export const metadata: Metadata = {
title: "NoteAI - 轻量级个人笔记应用",
description: "基于 Next.js 的 AI 驱动笔记工具",
+10
View File
@@ -0,0 +1,10 @@
export default function Loading() {
return (
<div className="flex h-[100dvh] items-center justify-center">
<div className="flex flex-col items-center gap-3">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-primary border-t-transparent" />
<p className="text-sm text-muted-foreground"></p>
</div>
</div>
);
}