124 lines
9.9 KiB
Markdown
124 lines
9.9 KiB
Markdown
# wojide (NoteAI) 项目健康度分析报告
|
||
|
||
> 分析日期:2026-07-31 | 分析角色:高级开发工程师(吴八哥)
|
||
> 技术栈:Next.js 16 (App Router) · React 19 · TypeScript strict · Prisma 5.10.2 + SQLite · Tailwind 3 · TipTap 3 · Zustand
|
||
> 说明:本文仅做分析与现状盘点,**未改动任何代码**。所有修复项需你确认后再动手。
|
||
|
||
---
|
||
|
||
## 0. 整体评价
|
||
|
||
代码质量高于平均水平:无 `any`、无 TODO/FIXME、无原始 SQL、主要 API 均有认证与输入校验、XSS 有 sanitize、登录有限速。**真正的短板不在业务逻辑,而在两处**:
|
||
1. **仓库卫生**(数据库入库 + 占位密钥)——会造成真实的数据泄露与越权风险;
|
||
2. **内容膨胀路径**(base64 图片 × 全量同步 × localStorage 快照)——笔记应用最典型的性能黑洞。
|
||
|
||
先把这两类按下,其余都是体验与健壮性层面的优化。
|
||
|
||
---
|
||
|
||
## 1. 🔴 高危问题(建议优先处理)
|
||
|
||
### 1.1 数据库文件被提交进 git(泄露密码哈希 + 全部笔记)
|
||
- **证据**:`git ls-files` 显示 `prisma/dev.db` 在跟踪列表;`.gitignore` 第 36 行虽有 `prisma/*.db`,但该规则是**后加**的,对已跟踪文件不生效。
|
||
- **风险**:任何能访问仓库的人都能拿到 scrypt 密码哈希 + 你的全部笔记正文。哈希被拖库后可离线爆破。
|
||
- **修复**:
|
||
```bash
|
||
git rm --cached prisma/dev.db
|
||
# 清理历史(破坏性,需评估):git filter-repo --path prisma/dev.db --invert-paths
|
||
# 随后在 .gitignore 已覆盖的前提下重新提交;已泄露的密码建议重置
|
||
```
|
||
- **注意**:若仓库已 push 到 GitHub(`origin` 已存在),清历史会影响协作者,需先沟通或确认是私有仓。
|
||
|
||
### 1.2 会话密钥为占位符 + 部署默认弱密码
|
||
- **证据**:`README.md:11` 与 `.env` 中 `SESSION_SECRET="replace-with-a-long-random-secret"`;`DEPLOY.md:162-176` 文档默认密码 `admin`。
|
||
- **风险**:若按文档部署而未替换,`SESSION_SECRET` 是公开值,任何人可用它**伪造 HMAC 会话 token 直接登录**。默认密码 `admin` 同样危险。
|
||
- **修复**:部署前 `npm run env:init` 生成随机值;在 `src/lib/session.ts` 启动时**显式拒绝**已知占位符(抛出启动错误而非静默运行)。
|
||
|
||
---
|
||
|
||
## 2. 🟠 性能问题
|
||
|
||
| 位置 | 问题 | 建议 |
|
||
|---|---|---|
|
||
| `src/components/editor.tsx:677` | `Image.configure({ allowBase64: true })`:粘贴图片以 base64 嵌进 content,单页可达数 MB;每次编辑防抖后整页 content PUT 回传 + 入 SQLite + 进 localStorage 历史快照 | 实现图片上传接口存文件,content 只存 URL(**最高优先级性能项**) |
|
||
| `src/components/editor.tsx:28` | `import { lowlight } from "lowlight"` 全量导出,把 >800KB 高亮语言打进客户端 bundle | 改用 `createLowlight(common)` 按需注册 |
|
||
| `src/app/page.tsx:87-90` | `localHistory` 的 `useMemo` 依赖 `[activePage]`,每次按键 `updatePage` 都替换 activePage 引用 → **每次按键**都执行 localStorage 读取 + 最多 30 个全文快照 JSON.parse | 依赖改为 `[activePage?.id, isHistoryOpen]`,仅面板打开时读取 |
|
||
| `src/components/sidebar/tree-view.tsx:284-288` | 每层重建 `buildFolderFileCount`/`buildHasChildrenSet`(无 memo),整树 O(n²);无虚拟化 | 上移到 Sidebar 用 `useMemo`;预构建 parentId→children Map;页面多时虚拟列表 |
|
||
| `src/app/api/pages/route.ts:35-46` | 不带参数时返回**全部页面含全部 content**,可能数十 MB 响应 | 默认强制 lightweight 或分页 |
|
||
| `src/app/api/pages/[id]/route.ts:185-196` | DELETE 先 `findMany` 全表再逐条 `delete`,N 个后代 N+1 条 SQL | schema 加 `onDelete: Cascade`,一条语句搞定 |
|
||
| `src/lib/layout.tsx:13-14` | `maximumScale:1, userScalable:false` 禁用缩放,可访问性硬伤 | 移除这两项 |
|
||
|
||
---
|
||
|
||
## 3. 🟠 安全性(中危,非紧急但需修)
|
||
|
||
| 位置 | 问题 | 建议 |
|
||
|---|---|---|
|
||
| `src/app/api/ai/chat/route.ts:5-18,69` | SSRF 黑名单可绕过:`2130706433`、`0x7f000001`、`[::ffff:127.0.0.1]`、解析到内网的域名、上游 302 均未防 | fetch 前 DNS 解析校验 IP;`redirect: "manual"`;或改白名单 |
|
||
| `src/lib/rate-limit.ts:48-58` | `getClientIp` 无条件信任 `x-forwarded-for`,直连部署下可换头绕过登录限速 | 仅可信反代时读该头,否则用 socket 地址;叠加全局失败计数 |
|
||
| `src/lib/session.ts:94` + `logout/route.ts:7` | 无会话吊销:`tokenId` 从未使用,改密码/登出后旧 token 直到过期(最长 30 天)仍有效 | 存 `sessionVersion`/`passwordChangedAt`,验证时比对 |
|
||
| `src/lib/sanitize-html.ts:132-136` | 服务端降级分支只匹配带引号 `on*` 属性、`javascript:` 仅处理 href | 无引号属性也纳入;或干脆服务端拒绝而非降级 |
|
||
| `src/app/api/settings/init/route.ts:27-29` | `providedToken !== initToken` 非常量时间比较 | 用 `crypto.timingSafeEqual` |
|
||
|
||
---
|
||
|
||
## 4. 🟡 代码质量 / 潜在 Bug(中低危)
|
||
|
||
| 位置 | 级别 | 问题 | 建议 |
|
||
|---|---|---|---|
|
||
| `ai-chat-panel.tsx:104-116` / `editor.tsx:1059-1078` | 中 | SSE 流解析无跨 chunk 缓冲,`decoder.decode` 未传 `{stream:true}`,JSON 帧跨 TCP 边界被截断 → 内容丢失 | 维护跨 chunk 字符串缓冲,只消费完整 `data:` 行 |
|
||
| `src/lib/search-query.ts:73` + `store.ts:160` | 中 | 全文搜索基于 `page.content`,但 `fetchPages` 默认 lightweight(content 空),**未打开过的页面正文搜索静默漏检** | 搜索改走服务端 API,或明确提示"仅搜索已加载页面" |
|
||
| `src/components/sidebar.tsx:65-73` | 中 | 侧边栏宽度/折叠只写不读 localStorage,初始 state 恒为 `280/false`,持久化形同虚设 | `useState(() => Number(localStorage.getItem("sidebar-width")) || 280)` |
|
||
| `src/lib/store.ts:409-415` | 中 | `reorderPages` 浅拷贝后 `page.order = order` **原地突变**,破坏 Zustand 不可变约定 | `newPages.map(p => ids.has(p.id) ? {...p, order} : p)` |
|
||
| `src/lib/auth.ts:26` | 低 | 哈希 key 长度异常时 `timingSafeEqual` 抛异常 → 登录 500 而非 401 | 先比长度,不等则 return false |
|
||
| `src/app/login/page.tsx:23,29` | 低 | 初始化接口失败时默认 `setIsInitialized(true)`,DB 未初始化+异常时卡登录页 | 失败显示错误+重试按钮 |
|
||
| `src/components/editor.tsx:775-889` | 低 | 用 `window.onpointermove =` 赋值会覆盖其他全局处理器 | 改用 `addEventListener` |
|
||
| `src/app/api/pages/reorder/route.ts:10` | 低 | `request.json()` 无 `.catch`,非 JSON body 返回 500 | 补 `.catch(() => null)` |
|
||
| `src/components/sidebar/tree-view.tsx:268` | 低 | `(isExpanded || hasChildren) && isExpanded` 恒等于 `isExpanded` | 简化为 `isExpanded &&` |
|
||
|
||
---
|
||
|
||
## 5. 🟡 架构 / Next.js
|
||
|
||
- **全量 Client 渲染**:`src/app/page.tsx`、`settings/page.tsx` 等全部 `"use client"`,零服务端预取,首屏白屏后客户端再 fetch 全量 pages。App Router 仅当路由用 → 至少把 pages 列表改为 Server Component 预取或 RSC + hydrate。
|
||
- **缺失的边界**:仅有根级 `loading.tsx`/`error.tsx`;缺 `not-found.tsx`,`/settings`、`/login` 无独立 error boundary。
|
||
- **proxy 匹配**:`src/proxy.ts:31` matcher 未排除 `public/` 下其他资源(如 `/file.svg`),会被 HMAC 校验拦截并跳登录。
|
||
- **editor 双 ref hack**:`allowOnUpdateRef`/`suppressNextUpdateRef` 规避 setContent 回环,脆弱,选项闭包过期会出怪 bug。
|
||
|
||
---
|
||
|
||
## 6. 🟡 依赖与配置
|
||
|
||
| 项 | 问题 | 建议 |
|
||
|---|---|---|
|
||
| 未使用依赖 | 源码零引用:`framer-motion`、`@radix-ui/react-tabs`、`@radix-ui/react-slot`、`@tiptap/extension-bubble-menu`、`@tiptap/extension-floating-menu`、`@tiptap/extension-gapcursor`、`@tiptap/extension-link`(StarterKit 已含)等 | `npx depcheck` 后移除 |
|
||
| 版本错位 | `next 16.1.1` vs `eslint-config-next ^16.1.6`;`marked v17` 却装 `@types/marked ^5`(旧类型包可能遮蔽正确类型);`lowlight ^2.9.0` 落后 TipTap v3 推荐的 v3 | 对齐版本,删除 `@types/marked` |
|
||
| Prisma 落后 | `@prisma/client 5.10.2` 锁定 2024-02 版,落后主线 6.x 两代 | 计划升级 Prisma 6 |
|
||
| 命名规范 | 包名 `note-ai` 与目录 `wojide` 不一致;`version 1.0.0304` 用日期当补丁号,非 semver | 统一命名 |
|
||
| ESLint 豁免 | `eslint.config.mjs:9-13` 对 editor 目录关闭 `no-explicit-any`/`ban-ts-comment`,给劣化开口子 | 收窄或移除豁免 |
|
||
|
||
---
|
||
|
||
## 7. ✅ Top 10 优先修复清单
|
||
|
||
1. **[高|安全]** `prisma/dev.db` 从 git 移除并清理历史(数据 + 密码哈希泄露)。
|
||
2. **[高|安全]** 替换占位 `SESSION_SECRET`,`session.ts` 启动拒绝占位值。
|
||
3. **[高|性能]** 关闭 `allowBase64`,改图片上传接口。
|
||
4. **[中|安全]** `session.ts` 增加会话吊销(改密码/登出后旧 token 失效)。
|
||
5. **[中|Bug]** SSE 解析加跨 chunk 缓冲(`ai-chat-panel.tsx:104` / `editor.tsx:1059`)。
|
||
6. **[中|Bug]** 修复 lightweight 模式正文搜索漏检(改服务端搜索)。
|
||
7. **[中|安全]** `ai/chat` SSRF 改 IP 校验 + 禁跟随重定向;`rate-limit` 停止无条件信任 `x-forwarded-for`。
|
||
8. **[中|DB]** `schema.prisma` Page 自关联加 `onDelete: Cascade`,简化 DELETE 路由。
|
||
9. **[中|Bug]** `sidebar.tsx:65` 补 localStorage 读取恢复;`store.ts:409` 修复 reorder 状态突变。
|
||
10. **[中|性能]** `page.tsx:87` 修复每次按键解析 localStorage 历史;`tree-view.tsx:284` 树计算加 memo;`editor.tsx:28` lowlight 按需加载。
|
||
|
||
---
|
||
|
||
## 8. 后续建议
|
||
|
||
- **先做第 1、2 项**(仓库卫生):投入小、风险降最大,且涉及 git 历史改写,越早处理越干净。
|
||
- **其次第 3 项**(图片上传):是长期性能与存储健康的根基,但改动面较大,建议单独排期。
|
||
- **其余按体验痛点优先级**逐步推进,不影响线上安全。
|
||
|
||
需要我从哪一项开始动手?按你的协作习惯,我会先给方案、你拍板后再改代码。
|