diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..862876f --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,176 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +NoteAI is a local knowledge note-taking application built with Next.js, Prisma (SQLite), and TipTap editor. The app provides a hierarchical page structure with rich text editing, markdown import/export, and password-protected access. + +## Development Commands + +```bash +# Development server (runs on port 3001) +npm run dev + +# Build for production +npm run build + +# Initialize environment variables +npm run env:init + +# Build with environment initialization +npm run build:init + +# Start production server (port 3001) +npm start + +# Linting +npm run lint + +# Type checking and linting together +npm run check + +# Run editor block operations tests +npm test +``` + +## Architecture + +### Database Layer (Prisma + SQLite) + +- **Schema**: `prisma/schema.prisma` +- **Client**: Singleton instance in `src/lib/prisma.ts` +- **Models**: + - `Page`: Hierarchical pages with self-referential parent-child relationships. Supports both "file" and "folder" types with ordering, locking, tags, and icons. + - `GlobalSettings`: Stores scrypt-hashed password for app-wide authentication. + +### State Management + +- **Zustand stores** with persistence: + - `src/lib/store.ts`: Main editor state (pages, activePageId, CRUD operations) + - `src/lib/search-store.ts`: Search functionality state + - `src/lib/settings-store.ts`: Application settings state + - `src/lib/page-history.ts`: Undo/redo history management with snapshots + +### Authentication & Session + +- **Session management**: `src/lib/session.ts` - Custom HMAC-based session tokens stored in cookies (24h TTL) +- **Auth utilities**: `src/lib/auth.ts` - Scrypt password hashing/verification +- **API auth**: `src/lib/api-auth.ts` - Middleware for protecting API routes +- **Rate limiting**: `src/lib/rate-limit.ts` - Token bucket rate limiter for auth endpoints + +### Editor System (TipTap) + +- **Extensions**: Custom TipTap extensions in `src/components/editor/extensions/` + - `callout.ts` & `callout-component.tsx`: Custom callout blocks + - `ai-mark.ts`: AI-generated content marking + - `task-item.tsx`: Custom task list items +- **Block operations**: `src/lib/editor-block-ops.ts` - Core logic for moving/reordering contiguous spans of blocks (drag-and-drop support) +- **Tests**: `src/lib/editor-block-ops.test.ts` run via `scripts/run-editor-block-ops-tests.mjs` + +**CRITICAL: Editor Initialization Configuration** + +When modifying `src/components/editor.tsx`, preserve these critical settings for proper markdown import functionality: + +```typescript +// In useEditor() configuration: +{ + extensions: [ + // ... other extensions + Markdown.configure({ + html: true, + transformPastedText: true, + transformCopiedText: true, // MUST be true for markdown conversion + }), + ], + content, // MUST pass content prop directly, NOT empty string + onUpdate: ({ editor }) => { + // ... update logic + } +} + +// In useEffect for content updates: +useEffect(() => { + if (editor && content !== editor.getHTML()) { + const normalizedContent = cleanupAccidentalStandaloneInlineCode(content); + suppressNextUpdateRef.current = true; + allowOnUpdateRef.current = false; + queueMicrotask(() => { + editor.commands.setContent(normalizedContent); // Simple call, no extra options + }); + if (normalizedContent !== content) { + onChange(normalizedContent); + } + } + if (editor && onEditorReady) { + onEditorReady(editor); + } +}, [content, editor, onEditorReady]); // Dependencies: do NOT include onChange +``` + +**Common mistakes to avoid:** +- Setting `content: ''` instead of `content` breaks markdown import display +- Setting `transformCopiedText: false` disables markdown conversion +- Adding unnecessary options to `setContent()` can cause whitespace issues +- Including `onChange` in useEffect dependencies causes infinite re-renders + +### Import/Export + +- **Markdown import**: `src/lib/markdown-import.ts` - Converts markdown to HTML with special handling for fenced code blocks +- **Export**: `src/lib/export.ts` - Handles exporting pages to various formats +- **File I/O**: `src/lib/file-io.ts` - File system operations for import/export +- **HTML sanitization**: `src/lib/sanitize-html.ts` - Sanitizes HTML content + +### API Routes + +All routes in `src/app/api/`: +- `/api/auth/login` & `/api/auth/logout`: Authentication +- `/api/settings/init`: First-time password setup (requires `INIT_SETUP_TOKEN` in production) +- `/api/settings/password`: Change password +- `/api/settings/status`: Check initialization status +- `/api/settings/restore`: Restore from backup +- `/api/pages`: CRUD operations for pages +- `/api/pages/[id]`: Individual page operations +- `/api/pages/reorder`: Batch reorder pages +- `/api/ai/chat`: AI chat integration + +### Special Features + +- **Wiki links**: `src/lib/wiki-links.ts` - Internal page linking system +- **Page utilities**: `src/lib/page-utils.ts` - Helper functions for page operations +- **Search**: `src/lib/search-query.ts` - Search query parsing and execution + +## Environment Variables + +Required in `.env`: +```env +DATABASE_URL="file:./dev.db" +SESSION_SECRET="<32+ character random string>" +INIT_DEFAULT_PASSWORD="" +INIT_SETUP_TOKEN="" +``` + +## Path Aliases + +- `@/*` maps to `src/*` (configured in `tsconfig.json`) + +## Testing + +Tests use Node's native test runner with `--experimental-strip-types` flag. Test files use `.test.ts` extension and are run via custom scripts in `scripts/` directory. + +## Database Migrations + +```bash +# Generate Prisma client after schema changes +npx prisma generate + +# Create and apply migrations +npx prisma migrate dev + +# Apply migrations in production +npx prisma migrate deploy +``` + +## Build Targets + +Prisma is configured for both native and `linux-musl-openssl-3.0.x` targets to support Docker deployments. diff --git a/src/app/api/pages/route.ts b/src/app/api/pages/route.ts index 85bcc16..7649713 100644 --- a/src/app/api/pages/route.ts +++ b/src/app/api/pages/route.ts @@ -1,22 +1,61 @@ -import { NextResponse } from 'next/server'; +import { NextRequest, NextResponse } from 'next/server'; import { prisma } from '@/lib/prisma'; import { requireApiAuth } from '@/lib/api-auth'; import { cleanupAccidentalStandaloneInlineCode, markdownToImportHtml } from '@/lib/markdown-import'; import { MAX_TITLE_LENGTH, safeParseTags, normalizeTags } from '@/lib/page-utils'; -export async function GET() { +export async function GET(request: NextRequest) { const authError = await requireApiAuth(); if (authError) return authError; try { - const pages = await prisma.page.findMany({ - orderBy: [{ order: 'asc' }, { createdAt: 'desc' }], - }); + const searchParams = request.nextUrl.searchParams; + // lightweight 模式:不返回 content,用于侧边栏等场景 + const lightweight = searchParams.get('lightweight') === 'true'; + const page = parseInt(searchParams.get('page') || '0', 10); + const limit = parseInt(searchParams.get('limit') || '0', 10); + + // 默认行为:返回所有页面(向后兼容) + const usePagination = limit > 0 && limit <= 1000 && page > 0; + + if (!usePagination) { + const pages = await prisma.page.findMany({ + orderBy: [{ order: 'asc' }, { createdAt: 'desc' }], + }); + const parsedPages = pages.map((p) => ({ + ...p, + content: lightweight ? '' : p.content, + tags: safeParseTags(p.tags), + })); + return NextResponse.json(parsedPages); + } + + // 分页查询 + const skip = (page - 1) * limit; + const [pages, total] = await Promise.all([ + prisma.page.findMany({ + skip, + take: limit, + orderBy: [{ order: 'asc' }, { createdAt: 'desc' }], + }), + prisma.page.count(), + ]); + const parsedPages = pages.map((p) => ({ ...p, + content: lightweight ? '' : p.content, tags: safeParseTags(p.tags), })); - return NextResponse.json(parsedPages); + + return NextResponse.json({ + data: parsedPages, + pagination: { + page, + limit, + total, + totalPages: Math.ceil(total / limit), + }, + }); } catch { return NextResponse.json({ error: 'Error fetching pages' }, { status: 500 }); } diff --git a/src/app/api/settings/restore/route.ts b/src/app/api/settings/restore/route.ts index 8b7280d..da12d64 100644 --- a/src/app/api/settings/restore/route.ts +++ b/src/app/api/settings/restore/route.ts @@ -3,6 +3,10 @@ import JSZip from "jszip"; import { prisma } from "@/lib/prisma"; import { requireApiAuth } from "@/lib/api-auth"; import { markdownToImportHtml } from "@/lib/markdown-import"; +import { pageToMarkdown } from "@/lib/export"; +import { sanitizeFilename } from "@/lib/page-utils"; +import { writeFile, mkdir } from "fs/promises"; +import { join } from "path"; type ZipEntry = { path: string; @@ -93,6 +97,52 @@ function collectFolderPaths(entries: ZipEntry[]): string[] { return Array.from(folderSet).sort((a, b) => getDepth(a) - getDepth(b)); } +/** + * 创建当前数据库的备份 ZIP + */ +async function createBackupZip(): Promise { + const allPages = await prisma.page.findMany({ + orderBy: [{ parentId: "asc" }, { order: "asc" }], + }); + + const zip = new JSZip(); + + // 递归添加页面到 ZIP + function addPagesToZip(parentId: string | null, currentFolder: JSZip) { + const children = allPages.filter((p) => p.parentId === parentId); + + for (const page of children) { + const cleanTitle = sanitizeFilename(page.title); + + if (page.type === "folder") { + const subFolder = currentFolder.folder(cleanTitle); + if (subFolder) { + addPagesToZip(page.id, subFolder); + } + } else { + const markdown = pageToMarkdown({ + id: page.id, + title: page.title, + content: page.content, + parentId: page.parentId, + type: page.type as "file" | "folder", + tags: JSON.parse(page.tags), + icon: page.icon, + order: page.order, + isLocked: page.isLocked, + createdAt: page.createdAt.toISOString(), + updatedAt: page.updatedAt.toISOString(), + }); + currentFolder.file(`${cleanTitle}.md`, markdown); + } + } + } + + addPagesToZip(null, zip); + + return await zip.generateAsync({ type: "nodebuffer" }); +} + export async function POST(req: NextRequest) { const authError = await requireApiAuth(); if (authError) return authError; @@ -152,10 +202,31 @@ export async function POST(req: NextRequest) { }) ); + // 在删除数据前创建自动备份 + let backupBuffer: Buffer | null = null; + let backupPath: string | null = null; + try { + backupBuffer = await createBackupZip(); + + // 保存备份到 backups 目录 + const backupsDir = join(process.cwd(), "backups"); + await mkdir(backupsDir, { recursive: true }); + + const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19); + backupPath = join(backupsDir, `auto-backup-before-restore-${timestamp}.zip`); + await writeFile(backupPath, backupBuffer); + + console.log(`Auto backup created: ${backupPath}`); + } catch (backupError) { + console.error("Failed to create backup before restore:", backupError); + return NextResponse.json( + { error: "Failed to create backup before restore. Restore aborted for safety." }, + { status: 500 } + ); + } + await prisma.$transaction(async (tx) => { - // ⚠️ 注意:这是一个破坏性操作,会删除所有现有页面后重新导入。 - // TODO: 后续可在此添加自动备份逻辑(导出当前数据到临时文件), - // 或在前端恢复前强制用户先手动备份。 + // 删除所有现有页面(已在上面创建备份) await tx.page.deleteMany(); const folderIdMap = new Map(); @@ -195,6 +266,7 @@ export async function POST(req: NextRequest) { count: entries.length, files: parsedFiles.length, folders: folderPaths.length, + backupPath: backupPath ? backupPath.replace(process.cwd(), "") : null, }); } catch (error) { console.error("Restore failed:", error); diff --git a/src/lib/api-auth.ts b/src/lib/api-auth.ts index 3f30e3b..ec086bd 100644 --- a/src/lib/api-auth.ts +++ b/src/lib/api-auth.ts @@ -5,9 +5,9 @@ import { getSessionCookieName, verifySessionToken } from "@/lib/session"; export async function requireApiAuth(): Promise { const cookieStore = await cookies(); const authCookie = cookieStore.get(getSessionCookieName()); - const isAuthenticated = authCookie ? await verifySessionToken(authCookie.value) : false; + const session = authCookie ? await verifySessionToken(authCookie.value) : null; - if (!isAuthenticated) { + if (!session) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } diff --git a/src/lib/export.ts b/src/lib/export.ts index d67b762..f4df256 100644 --- a/src/lib/export.ts +++ b/src/lib/export.ts @@ -3,6 +3,7 @@ import JSZip from "jszip"; import { saveAs } from "file-saver"; import { Page } from "./store"; import { useSettingsStore } from "./settings-store"; +import { sanitizeFilename } from "./page-utils"; import { gfm } from "turndown-plugin-gfm"; @@ -43,11 +44,6 @@ export function htmlToMarkdown(html: string): string { return turndownService.turndown(html); } -// Helper to sanitize filenames -function sanitizeFilename(name: string): string { - return name.replace(/[<>:"/\\|?*]/g, '_').trim(); -} - // Helper to escape YAML strings function escapeYamlString(str: string): string { return str.replace(/"/g, '\\"'); diff --git a/src/lib/page-utils.ts b/src/lib/page-utils.ts index 56858b0..3864d35 100644 --- a/src/lib/page-utils.ts +++ b/src/lib/page-utils.ts @@ -4,6 +4,13 @@ export const MAX_TITLE_LENGTH = 200; export const MAX_TAGS = 20; export const MAX_TAG_LENGTH = 50; +/** + * 清理文件名中的非法字符 + */ +export function sanitizeFilename(name: string): string { + return name.replace(/[<>:"/\\|?*]/g, '_').trim(); +} + /** * 安全解析 JSON 格式的标签字符串 */ diff --git a/src/lib/session.ts b/src/lib/session.ts index 123908c..a1d17ab 100644 --- a/src/lib/session.ts +++ b/src/lib/session.ts @@ -2,6 +2,8 @@ const SESSION_COOKIE_NAME = "auth"; const SESSION_TTL_SECONDS = 24 * 60 * 60; type SessionPayload = { + userId: string; + tokenId: string; exp: number; }; @@ -88,6 +90,8 @@ export async function createSessionToken(days?: number): Promise { const now = Math.floor(Date.now() / 1000); const ttl = getSessionTtlSeconds(days); const payload: SessionPayload = { + userId: "default", // 单用户系统,使用固定 ID + tokenId: crypto.randomUUID(), exp: now + ttl, }; const encodedPayload = toBase64Url(new TextEncoder().encode(JSON.stringify(payload))); @@ -95,26 +99,32 @@ export async function createSessionToken(days?: number): Promise { return `${encodedPayload}.${signature}`; } -export async function verifySessionToken(token: string): Promise { +export async function verifySessionToken(token: string): Promise { const [payloadPart, signaturePart] = token.split("."); if (!payloadPart || !signaturePart) { - return false; + return null; } const isValidSignature = await verify(payloadPart, signaturePart); if (!isValidSignature) { - return false; + return null; } try { const payloadText = new TextDecoder().decode(fromBase64Url(payloadPart)); const payload = JSON.parse(payloadText) as SessionPayload; if (!payload.exp || typeof payload.exp !== "number") { - return false; + return null; + } + if (!payload.userId || !payload.tokenId) { + return null; } const now = Math.floor(Date.now() / 1000); - return payload.exp > now; + if (payload.exp <= now) { + return null; + } + return payload; } catch { - return false; + return null; } } diff --git a/src/proxy.ts b/src/proxy.ts index 58bbbf3..56f7d6f 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -10,7 +10,8 @@ export default async function proxy(request: NextRequest) { const isLoginPage = request.nextUrl.pathname === '/login'; const isPublicPath = PUBLIC_PATHS.has(request.nextUrl.pathname); const isPublicApiPath = PUBLIC_API_PATHS.has(request.nextUrl.pathname); - const isAuthenticated = authCookie ? await verifySessionToken(authCookie.value) : false; + const session = authCookie ? await verifySessionToken(authCookie.value) : null; + const isAuthenticated = session !== null; if (!isAuthenticated && !isPublicPath && !isPublicApiPath) { if (request.nextUrl.pathname.startsWith('/api')) {