修复md导入问题
This commit is contained in:
@@ -46,3 +46,9 @@ yarn-error.log*
|
||||
next-env.d.ts
|
||||
|
||||
/src/generated/prisma
|
||||
|
||||
# prisma sqlite database
|
||||
prisma/*.db
|
||||
prisma/*.db-journal
|
||||
prisma/*.db-wal
|
||||
prisma/*.db-shm
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
# 订阅地址
|
||||
|
||||
订阅地址
|
||||
|
||||
```yaml
|
||||
https://ghub.z1.mk/https://raw.githubusercontent.com/Aethersailor/Custom_OpenClash_Rules/main/cfg/Custom_Clash.ini
|
||||
http://git.soao.net/lc/my/raw/branch/main/clash_rules.ini
|
||||
http://s.z1.mk/clash
|
||||
# 2025-11-29 自用
|
||||
https://raw.githubusercontent.com/cnliucheng/my/refs/heads/main/clash.ini
|
||||
# 覆写地址:
|
||||
https://gitee.soao.net/lc/my/raw/branch/main/clash.yaml
|
||||
https://raw.githubusercontent.com/cnliucheng/my/refs/heads/main/clash.js
|
||||
|
||||
0S9WNVW1VZ@yinhe.ac.cn / 809010
|
||||
|
||||
## 住宅IP
|
||||
https://equaldcdn.com/use/unsafe/sub.php?t=eyJ1dWlkIjoiRDJCMjY4NUQtRURGQi00RTkyLUJCN0QtRTkzQURCQUNGRURGIiwic2VydmVyIjoibG9zdGFuZGhvcGUuY29tIiwicG9ydCI6IjQ0MyIsInB1YmxpY19rZXkiOiIiLCJzaG9ydF9pZCI6IiIsInRvdGFsIjozMjIxMjI1NDcyMDAsImV4cGlyZSI6MTc3ODI0NzkxN30&s=419326266dcd38cfaed2e546014cf29e568fbc86f04e42b684680b9f9c2364a8
|
||||
|
||||
## vmiss-cmin2
|
||||
http://38.207.188.189:2096/sub/8wC8MUuU?format=clash
|
||||
http://38.207.188.189:2096/sub/8wC8MUuU
|
||||
https://api.v1.mk/sub?target=clash&url=http%3A%2F%2F38.207.188.189%3A2096%2Fsub%2F8wC8MUuU%3Fformat%3Dclash&insert=false&config=https%3A%2F%2Fraw.githubusercontent.com%2FACL4SSR%2FACL4SSR%2Fmaster%2FClash%2Fconfig%2FACL4SSR_Online_Mini.ini&filename=Vmiss-cmin2&emoji=true&list=false&xudp=true&udp=true&tfo=false&expand=true&scv=true&fdn=false&new_name=true
|
||||
|
||||
# 优速通 2026-03-01
|
||||
https://23.158.136.135/api/v1/client/subscribe?token=5e57574dc7286b8816ab485e86de49e7
|
||||
|
||||
# mcfy
|
||||
https://mcfby.com/
|
||||
https://api-salkjdhajs-cdm.mcwy.xyz/api/v1/helps/js?token=58930a66839a43d8db0c4d66974b569b
|
||||
|
||||
# 红杏加速器 #2025-12-19
|
||||
https://43.139.248.121:9000/hxyunvip?token=fb58ed375c418876b2319b75759e6696
|
||||
|
||||
# APTVPN机场
|
||||
https://s00.btfpi.cn/ss?token=8b15f01cd738854fbcce05d15ab27d9b
|
||||
|
||||
# BestVPN!
|
||||
https://3lWqjNQ9F.pronetwork.top:8443/api/v1/client/285396cad6a118273b3d865c25704b59?flag=clashmeta
|
||||
https://mbgee280rL.pronetwork.top:8443/api/v1/client/285396cad6a118273b3d865c25704b59?flag=clashmeta
|
||||
https://ZURZfcTO8.pronetwork.top:8443/api/v1/client/285396cad6a118273b3d865c25704b59
|
||||
https://v1.mk/bv
|
||||
|
||||
# 云洞
|
||||
https://taibai0201.xn--8stx8olrwkucjq3b.com/api/v1/client/subscribe?token=1f42e840f10369affa4fbe4d807c71f0
|
||||
|
||||
```
|
||||
|
||||
服务器列表
|
||||
|
||||
|厂商|配置|IP地址|管理控制台地址|年付金额|续费日期|备注|
|
||||
| -----------------------| -------------------------------| -------------------------------| ------------------------------------------------------| -------------------------: | ---------------------------| -------------------------|
|
||||
|野草云|4C - 8G - 90GB|83.229.126.204|https://83.229.126.204:15599/d62201f3|¥ 477.00|2026-10-18|axn5d8n6|
|
||||
|[Racknerd](https://www.racknerd.com/)|2C - 4G - 65GB|192.3.148.206|[http://192.3.148.206:8080/ll771030](http://192.3.148.206:8080/ll771030)|55.88USD|2026-03-20||
|
||||
|狗云 - 网站|4C - 8G - 60GB|43.255.158.40|http://43.255.158.40:1030/lindavid|¥ 130.00|2025-04-07|823/2578|
|
||||
|[雨云](https://app.rainyun.com/dashboard)|4C - 8G - 400GB|110.42.45.89|[https://110.42.45.89:32576/cb79271e](https://110.42.45.89:32576/cb79271e)|¥579.6|2025-02-26|emfxqg50|
|
||||
|
||||
|
||||
|
||||
|
||||
Generated
+567
-434
File diff suppressed because it is too large
Load Diff
+11
-6
@@ -16,6 +16,7 @@
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@prisma/client": "5.10.2",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
@@ -45,9 +46,6 @@
|
||||
"@tiptap/react": "^3.14.0",
|
||||
"@tiptap/starter-kit": "^3.14.0",
|
||||
"@tiptap/suggestion": "^3.14.0",
|
||||
"@types/file-saver": "^2.0.7",
|
||||
"@types/marked": "^5.0.2",
|
||||
"@types/turndown": "^5.0.6",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"file-saver": "^2.0.5",
|
||||
@@ -64,12 +62,19 @@
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"tippy.js": "^6.3.7",
|
||||
"tiptap-markdown": "^0.9.0",
|
||||
"turndown": "^7.2.2",
|
||||
"turndown-plugin-gfm": "^1.0.2",
|
||||
"zustand": "^5.0.9",
|
||||
"@prisma/client": "5.10.2",
|
||||
"prisma": "^5.10.2"
|
||||
"zustand": "^5.0.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/file-saver": "^2.0.7",
|
||||
"@types/marked": "^5.0.2",
|
||||
"@types/node": "^25.3.3",
|
||||
"@types/turndown": "^5.0.6",
|
||||
"autoprefixer": "^10.4.27",
|
||||
"eslint": "^9.39.3",
|
||||
"eslint-config-next": "^16.1.6",
|
||||
"prisma": "^5.10.2",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"typescript": "^5"
|
||||
}
|
||||
|
||||
Binary file not shown.
@@ -5,7 +5,7 @@ generator client {
|
||||
|
||||
datasource db {
|
||||
provider = "sqlite"
|
||||
url = "file:./dev.db"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
model Page {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 }> }
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+24
-9
@@ -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
@@ -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 驱动笔记工具",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1427,8 +1427,7 @@ export function Editor({ content, onChange, onEditorReady, onToggleAI, onExport,
|
||||
setShowAI(!showAI);
|
||||
}
|
||||
}}
|
||||
className={`fixed bottom-[max(1rem,env(safe-area-inset-bottom))] right-4 p-3 rounded-full shadow-lg hover:scale-110 transition-transform z-40 md:bottom-8 md:right-8 ${
|
||||
isGenerating ? "bg-red-500 text-white animate-pulse" : "bg-primary text-primary-foreground"
|
||||
className={`fixed bottom-[max(1rem,env(safe-area-inset-bottom))] right-4 p-3 rounded-full shadow-lg hover:scale-110 transition-transform z-40 md:bottom-8 md:right-8 ${isGenerating ? "bg-red-500 text-white animate-pulse" : "bg-primary text-primary-foreground"
|
||||
}`}
|
||||
title={isGenerating ? "停止生成" : "AI 助手"}
|
||||
>
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import React, { createContext, useContext, useRef, useState, useCallback } from "react";
|
||||
import { useEditorStore } from "@/lib/store";
|
||||
import JSZip from "jszip";
|
||||
import { markdownToImportHtml } from "@/lib/markdown-import";
|
||||
|
||||
interface ImportContextType {
|
||||
triggerImport: (parentId?: string | null) => void;
|
||||
@@ -130,7 +129,7 @@ export function ImportProvider({ children }: { children: React.ReactNode }) {
|
||||
const handleSingleFileImport = async (file: File) => {
|
||||
const text = await file.text();
|
||||
const title = file.name.replace(/\.md$/i, "");
|
||||
const html = markdownToImportHtml(text);
|
||||
// 直接存储原始 Markdown,tiptap-markdown 扩展会在编辑器加载时自动解析
|
||||
const parentId = targetParentIdRef.current;
|
||||
const existing = pages.find((p) => p.type === "file" && p.parentId === parentId && (p.title || "").trim() === title.trim());
|
||||
if (existing) {
|
||||
@@ -138,8 +137,7 @@ export function ImportProvider({ children }: { children: React.ReactNode }) {
|
||||
existing.id,
|
||||
{
|
||||
title,
|
||||
content: html,
|
||||
importMarkdown: text,
|
||||
content: text,
|
||||
parentId,
|
||||
type: "file",
|
||||
},
|
||||
@@ -151,8 +149,7 @@ export function ImportProvider({ children }: { children: React.ReactNode }) {
|
||||
await postPage(
|
||||
{
|
||||
title,
|
||||
content: html,
|
||||
importMarkdown: text,
|
||||
content: text,
|
||||
parentId,
|
||||
type: "file",
|
||||
},
|
||||
@@ -252,15 +249,13 @@ export function ImportProvider({ children }: { children: React.ReactNode }) {
|
||||
|
||||
const markdown = await zipFile.async("string");
|
||||
const title = fileName.replace(/\.md$/i, "").replace(/\.txt$/i, "");
|
||||
const html = markdownToImportHtml(markdown);
|
||||
const existing = pages.find((p) => p.type === "file" && p.parentId === parentId && (p.title || "").trim() === title.trim());
|
||||
if (existing) {
|
||||
await putPage(
|
||||
existing.id,
|
||||
{
|
||||
title,
|
||||
content: html,
|
||||
importMarkdown: markdown,
|
||||
content: markdown,
|
||||
parentId,
|
||||
type: "file",
|
||||
},
|
||||
@@ -270,8 +265,7 @@ export function ImportProvider({ children }: { children: React.ReactNode }) {
|
||||
await postPage(
|
||||
{
|
||||
title,
|
||||
content: html,
|
||||
importMarkdown: markdown,
|
||||
content: markdown,
|
||||
parentId,
|
||||
type: "file",
|
||||
},
|
||||
|
||||
@@ -107,7 +107,7 @@ function TreeNode({
|
||||
<div key={node.id} style={style} ref={setNodeRef} {...attributes} {...listeners}>
|
||||
<div
|
||||
className={cn(
|
||||
"group relative flex h-10 items-center justify-between rounded-lg border border-transparent px-1.5 text-[13px] transition-colors cursor-pointer select-none md:h-8",
|
||||
"sidebar-tree-item group relative flex h-10 items-center justify-between rounded-lg border border-transparent px-1.5 text-[13px] transition-colors cursor-pointer select-none md:h-8",
|
||||
isActive
|
||||
? "border-border/60 bg-accent/75 text-accent-foreground"
|
||||
: "text-muted-foreground hover:border-border/45 hover:bg-muted/55 hover:text-foreground"
|
||||
|
||||
@@ -112,9 +112,11 @@ export function cleanupAccidentalStandaloneInlineCode(html: string): string {
|
||||
const suspiciousCount = standaloneMatches.filter((block) => /https?:\/\/|@\w|\/\s*\d{3,}|[一-龥]/.test(block)).length;
|
||||
const hasPreBlock = /<pre>\s*<code(?:\s|>)/.test(html);
|
||||
|
||||
// 如果文档已包含正确的 <pre><code> 块,说明代码块解析正常
|
||||
// 此时只在 heading 中出现 <code> 时才触发清理,避免对正常文档误删
|
||||
const looksMalformed =
|
||||
headingCodeMatches.length > 0 ||
|
||||
(hasPreBlock && standaloneMatches.length >= 5 && suspiciousCount >= 3);
|
||||
(!hasPreBlock && standaloneMatches.length >= 5 && suspiciousCount >= 3);
|
||||
if (!looksMalformed) return html;
|
||||
|
||||
return html.replace(/<(p|h[1-6]|li)><code>([\s\S]*?)<\/code><\/\1>/g, "<$1>$2</$1>");
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
// 页面数据相关的共享工具函数
|
||||
|
||||
export const MAX_TITLE_LENGTH = 200;
|
||||
export const MAX_TAGS = 20;
|
||||
export const MAX_TAG_LENGTH = 50;
|
||||
|
||||
/**
|
||||
* 安全解析 JSON 格式的标签字符串
|
||||
*/
|
||||
export 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 [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 标准化标签数组:去重、去空、截断
|
||||
*/
|
||||
export 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);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// 简单的内存速率限制器(基于 IP)
|
||||
|
||||
interface RateLimitEntry {
|
||||
count: number;
|
||||
resetAt: number;
|
||||
}
|
||||
|
||||
const store = new Map<string, RateLimitEntry>();
|
||||
|
||||
// 定期清理过期条目,防止内存泄漏
|
||||
const CLEANUP_INTERVAL_MS = 5 * 60 * 1000; // 5 分钟
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [key, entry] of store) {
|
||||
if (now >= entry.resetAt) {
|
||||
store.delete(key);
|
||||
}
|
||||
}
|
||||
}, CLEANUP_INTERVAL_MS);
|
||||
|
||||
/**
|
||||
* 检查请求是否超过速率限制
|
||||
* @param key 限制键(通常是 IP 地址)
|
||||
* @param maxAttempts 窗口期内最大尝试次数
|
||||
* @param windowMs 窗口期(毫秒)
|
||||
* @returns 如果超限返回 true,否则返回 false
|
||||
*/
|
||||
export function isRateLimited(
|
||||
key: string,
|
||||
maxAttempts: number = 5,
|
||||
windowMs: number = 60 * 1000
|
||||
): boolean {
|
||||
const now = Date.now();
|
||||
const entry = store.get(key);
|
||||
|
||||
if (!entry || now >= entry.resetAt) {
|
||||
store.set(key, { count: 1, resetAt: now + windowMs });
|
||||
return false;
|
||||
}
|
||||
|
||||
entry.count += 1;
|
||||
return entry.count > maxAttempts;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 Request 对象中提取客户端 IP
|
||||
*/
|
||||
export function getClientIp(req: Request): string {
|
||||
// 常见的代理头
|
||||
const forwarded = req.headers.get("x-forwarded-for");
|
||||
if (forwarded) {
|
||||
return forwarded.split(",")[0].trim();
|
||||
}
|
||||
const realIp = req.headers.get("x-real-ip");
|
||||
if (realIp) {
|
||||
return realIp.trim();
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
@@ -118,8 +118,27 @@ function sanitizeNode(node: Node): void {
|
||||
}
|
||||
}
|
||||
|
||||
// 服务端降级:使用正则剥离危险标签(无 DOMParser 时)
|
||||
function serverSideSanitize(input: string): string {
|
||||
let result = input;
|
||||
// 移除 script、style、iframe 等危险标签及其内容
|
||||
for (const tag of DROP_CONTENT_TAGS) {
|
||||
const regex = new RegExp(`<${tag}[^>]*>[\\s\\S]*?<\\/${tag}>`, "gi");
|
||||
result = result.replace(regex, "");
|
||||
// 移除自闭合形式
|
||||
result = result.replace(new RegExp(`<${tag}[^>]*/?>`, "gi"), "");
|
||||
}
|
||||
// 移除事件处理器属性(on* 属性)
|
||||
result = result.replace(/\s+on\w+\s*=\s*"[^"]*"/gi, "");
|
||||
result = result.replace(/\s+on\w+\s*=\s*'[^']*'/gi, "");
|
||||
// 移除 javascript: 协议
|
||||
result = result.replace(/href\s*=\s*"javascript:[^"]*"/gi, 'href=""');
|
||||
result = result.replace(/href\s*=\s*'javascript:[^']*'/gi, "href=''");
|
||||
return result;
|
||||
}
|
||||
|
||||
export function sanitizeHtml(input: string): string {
|
||||
if (typeof window === "undefined") return input;
|
||||
if (typeof window === "undefined") return serverSideSanitize(input);
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(input, "text/html");
|
||||
const nodes = Array.from(doc.body.childNodes);
|
||||
@@ -128,3 +147,4 @@ export function sanitizeHtml(input: string): string {
|
||||
}
|
||||
return doc.body.innerHTML;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user