Apply tech debt fixes: middleware rename, React.cache, file size limits, remove dead deps

- Rename proxy.ts → middleware.ts, export proxy() → middleware() so Next.js edge
  protection actually activates (F001)
- Add PUBLIC_ROUTES entries for /forgot-password and /reset-password
- Wrap getSettings() in React.cache() to eliminate duplicate DB call in root layout (F003)
- Remove 4 console.log calls from saveLesson Server Action, keep console.error (F005)
- Add 50 MB file size guard to all 6 upload routes before arrayBuffer() read (F004)
- Remove unused deps: @tailwindcss/typography, shadcn, tw-animate-css (F008)
- Update CLAUDE.md: Prisma version 6.x → 7.x
- Add TECH_DEBT_AUDIT.md with 14 findings across 9 dimensions

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-09 19:35:05 +05:00
parent 5547b427bb
commit 444b9c0faf
13 changed files with 273 additions and 3052 deletions
+1 -1
View File
@@ -11,7 +11,7 @@
| TypeScript | 5.x | Язык | | TypeScript | 5.x | Язык |
| React | 19 | UI | | React | 19 | UI |
| PostgreSQL | 16 | База данных | | PostgreSQL | 16 | База данных |
| Prisma | 6.x | ORM + миграции | | Prisma | 7.x | ORM + миграции |
| Better Auth | latest | Аутентификация и сессии | | Better Auth | latest | Аутентификация и сессии |
| Tailwind CSS | 4.x (CSS-based config) | Стили (нет tailwind.config.ts) | | Tailwind CSS | 4.x (CSS-based config) | Стили (нет tailwind.config.ts) |
| shadcn/ui | latest | UI-компоненты | | shadcn/ui | latest | UI-компоненты |
+153
View File
@@ -0,0 +1,153 @@
# Tech Debt Audit — lms-sb
Generated: 2026-05-09
---
## Executive Summary
- **1 Critical**: middleware не работает — файл называется `proxy.ts` вместо `middleware.ts`, защита маршрутов на уровне Next.js отсутствует
- **3 High**: двойная загрузка полной структуры курса на каждый урок; `getSettings()` вызывается дважды в root layout; нет ограничения размера загружаемых файлов
- **0 тестов** — ни одного test-файла во всём проекте
- **4 отладочных `console.log`** в production-коде Server Action
- Zod установлен как зависимость, но нигде не используется — Server Actions принимают `FormData` без валидации схемы
- 3 уязвимости npm **high** severity (next, fast-uri, fast-xml-builder)
- `settings-form.tsx` — 506 строк, единственный god-файл, но внутренняя структура оправданна
- Самые горячие файлы совпадают с самыми крупными: student lesson page (12 правок, 270 строк) и lesson-editor (8 правок, 408 строк) — концентрация долга
---
## Architectural Mental Model
LMS построена на Next.js 16 App Router с тремя зонами доступа: `(auth)`, `(student)`, `admin`/`curator`. Мутации идут через Server Actions, данные читаются в RSC. Better Auth отвечает за сессии и роли. Prisma 7 с PostgreSQL через собственный PrismaPg адаптер (обходит ограничения Turbopack).
Главная аномалия: middleware объявлен в `src/proxy.ts` с функцией `proxy()`, а не в `src/middleware.ts` с функцией `middleware()`. Next.js его не подхватывает. Защита работает только за счёт явных проверок сессии в каждой странице и action — что само по себе достаточно, но заявленный в CLAUDE.md "Auth middleware (защита маршрутов)" фактически не существует.
Второй структурный факт: при открытии страницы урока студентом происходит двойная загрузка полной структуры курса — один раз в `layout.tsx` (для sidebar), второй в `page.tsx` (для prev/next навигации). Это N+1 на уровне layout/page.
---
## Findings
| ID | Category | File:Line | Severity | Effort | Description | Recommendation |
|----|----------|-----------|----------|--------|-------------|----------------|
| F001 | Security | src/proxy.ts:1 | Critical | S | Файл `proxy.ts` не является Next.js middleware. Next.js ищет `src/middleware.ts` с экспортом `middleware`. Маршруты не защищены на уровне edge. | Переименовать файл в `src/middleware.ts`, переименовать экспорт `proxy``middleware` |
| F002 | Performance | src/app/(student)/courses/[slug]/lessons/[lessonId]/page.tsx:37 | High | M | `page.tsx` загружает `lesson.module.course.modules` с вложенными уроками для prev/next nav — та же структура уже загружена в `layout.tsx:20`. Двойной DB-запрос на каждый pageview урока. | Вынести prev/next навигацию в layout или передавать через `searchParams`/context вместо повторной загрузки |
| F003 | Performance | src/app/layout.tsx:14,27 | High | S | `getSettings()` вызывается дважды в одном компоненте — в `generateMetadata()` и в `RootLayout()`. Два одинаковых DB-запроса на каждый запрос. | Объединить в один вызов или обернуть `getSettings` в `React.cache()` |
| F004 | Security | src/app/api/admin/upload/route.ts:1, src/app/api/student/homework-upload/route.ts:1, src/app/api/curator/audio-upload/route.ts:1 | High | S | Ни один upload endpoint не проверяет размер файла перед `file.arrayBuffer()`. Загрузка 1 ГБ файла ляжет в память Node.js. | Добавить проверку `file.size` до 50 МБ (или другого лимита) сразу после `form.get("file")` |
| F005 | Observability | src/lib/actions/lesson-actions.ts:24,27,42,48 | Medium | S | 4 `console.log` в production Server Action. Логируют `lessonId` и статус каждого сохранения в prod-консоль. | Убрать все 4. Оставить только `console.error` в catch-блоках. |
| F006 | Type & Contract | src/app/admin/courses/actions.ts:28-47 | Medium | M | Server Actions принимают `FormData` и читают поля через `as string` без валидации. Zod установлен, но не используется нигде в проекте. | Добавить Zod-схему на входе `createCourse` и `updateCourse`; повторить паттерн в остальных actions |
| F007 | Dependencies | package.json | Medium | S | `npm audit` показывает 3 high severity уязвимости: `next` (сам фреймворк), `fast-uri`, `fast-xml-builder`. | `npm update next` до последнего патча; проверить влияние на остальные зависимости |
| F008 | Dependencies | package.json | Low | S | `depcheck` находит 4 неиспользуемые зависимости: `@tailwindcss/typography`, `shadcn`, `tw-animate-css`, `zod`. Если Zod будет использован (F006), убрать оставшиеся три. | `npm remove @tailwindcss/typography shadcn tw-animate-css` |
| F009 | Architecture | src/app/(student)/courses/[slug]/lessons/[lessonId]/page.tsx:102-106 | Low | S | Функция `formatSize` определена внутри Server Component — при каждом рендере пересоздаётся. Не ошибка, но засоряет файл. | Вынести в `src/lib/utils.ts` |
| F010 | Consistency | src/app/admin/courses/[courseId]/actions.ts:12, src/app/admin/categories/actions.ts:11, src/app/admin/settings/actions.ts:11 | Low | S | Разные строки ошибки авторизации: `"Forbidden"` (EN), `"Нет доступа"` (RU), `"Unauthorized"` (EN). Нет единого паттерна. | Выбрать один формат и применить везде; ошибки авторизации не должны уходить клиенту как читаемый текст |
| F011 | Security | src/app/layout.tsx:32,37 | Low | — | `dangerouslySetInnerHTML` с `headCode`/`bodyCode` из БД — admin может вставить произвольный JS. | Намеренная функция (code injection для аналитики). Задокументировать явно, что это admin-only привилегия. Добавить проверку роли на странице настроек — уже есть. |
| F012 | Testing | — | High | L | Ни одного теста во всём проекте. Нет `*.test.*`, `*.spec.*`, нет `__tests__/`. Горячие файлы (lesson page, lesson-editor) не прикрыты ничем. | Начать с unit-тестов `src/lib/md-to-tiptap.ts` (чистая функция, высокий риск регрессии) и `src/lib/settings.ts`. Для UI — Playwright E2E на login + lesson complete flow. |
| F013 | Consistency | src/app/(student)/courses/[slug]/layout.tsx:54, src/app/(student)/courses/[slug]/lessons/[lessonId]/page.tsx:53 | Low | S | Оба файла самостоятельно проверяют `isAdmin` для conditional DB queries с одинаковой логикой. Паттерн не вынесен. | Не критично при текущем размере, но при росте числа маршрутов станет проблемой |
| F014 | Documentation | src/proxy.ts:1, CLAUDE.md | Medium | S | CLAUDE.md: `src/middleware.ts — Auth middleware (защита маршрутов)`. Файла не существует, существует `src/proxy.ts`. Документация врёт. | После исправления F001 — обновить CLAUDE.md |
---
## Top 5 "fix these first"
### 1. F001 — Переименовать proxy.ts в middleware.ts
```bash
git mv src/proxy.ts src/middleware.ts
```
В `src/middleware.ts`:
```ts
// было:
export function proxy(request: NextRequest) { ... }
// стало:
export function middleware(request: NextRequest) { ... }
```
`config` экспорт уже правильный — оставить как есть. Это однострочный фикс с нулевым риском регрессии.
---
### 2. F003 — Двойной `getSettings()` в root layout
```ts
// src/app/layout.tsx — было: два вызова
const settings = await getSettings(); // в generateMetadata
const settings = await getSettings(); // в RootLayout
// стало: обернуть в React.cache
// src/lib/settings.ts
import { cache } from "react";
export const getSettings = cache(async (): Promise<Settings> => { ... });
```
Один `cache()` снимает оба дублированных запроса в рамках одного render pass.
---
### 3. F004 — Лимит размера файлов в upload endpoints
В каждом из 5 upload routes добавить сразу после получения файла:
```ts
const MAX_BYTES = 50 * 1024 * 1024; // 50 МБ
if (file.size > MAX_BYTES) {
return NextResponse.json({ error: "Файл слишком большой" }, { status: 413 });
}
```
---
### 4. F005 — Убрать console.log из lesson-actions.ts
```ts
// src/lib/actions/lesson-actions.ts — удалить строки 24, 27, 42, 48
console.log("[saveLesson] start", lessonId); // удалить
console.log("[saveLesson] auth ok"); // удалить
console.log("[saveLesson] db update ok"); // удалить
console.log("[saveLesson] done"); // удалить
```
---
### 5. F002 — Устранить двойную загрузку структуры курса
`layout.tsx` уже загружает все модули/уроки курса для sidebar. `page.tsx` загружает ту же структуру ещё раз для prev/next навигации. Самое чистое решение — передавать `allLessons` через `searchParams` или вычислять в layout и передавать через `slot`:
Альтернатива проще: убрать из `page.tsx` `include: { modules: { include: { lessons } } }` и принять `prevLessonId`/`nextLessonId` как query params, которые layout прописывает в ссылки sidebar-а.
---
## Quick Wins
- [ ] **F001**`git mv src/proxy.ts src/middleware.ts` + переименовать экспорт (5 минут)
- [ ] **F003**`import { cache } from "react"` в settings.ts, обернуть `getSettings` (10 минут)
- [ ] **F005** — Удалить 4 строки `console.log` в lesson-actions.ts (2 минуты)
- [ ] **F007**`npm update next` — закрыть CVE в самом фреймворке
- [ ] **F008**`npm remove @tailwindcss/typography shadcn tw-animate-css` — убрать мёртвый вес
- [ ] **F004** — Добавить `file.size` проверку в 5 upload routes (15 минут)
---
## Things that look bad but are actually fine
**`src/generated/prisma/`** — 20+ сгенерированных файлов в `src/`. Выглядит как мусор, но это намеренно: Prisma 7 с Turbopack требует TypeScript-клиента в src для корректной работы RSC. Объяснено в коммите `af8644e` и в memory-файле `project_lms_prisma_config.md`. Не трогать.
**`src/app/(student)/courses/[slug]/layout.tsx:54`** — `prisma.lessonProgress.findMany({ where: { lessonId: { in: allLessonIds } } })` загружает прогресс по всем урокам курса. Выглядит избыточно, но это единственный способ отрисовать sidebar с чекбоксами без N+1 запроса на каждый урок. Правильный паттерн.
**`src/lib/auth.ts:37`** — Хардкод `"https://school.second-brain.ru"` в `trustedOrigins`. Выглядит как нарушение правила "нет захардкоженных URL". На самом деле это security-критичный список и он должен быть явным, не конфигурируемым через переменные (иначе можно было бы переопределить в .env). Оставить.
**`catch (() => {})` в трёх местах** (email в import, S3 delete) — выглядит как проглатывание ошибок. В контексте это правильно: ошибка отправки welcome-email или удаления старого файла из S3 не должна ронять основную операцию (импорт/апгрейд файла).
**506 строк в `settings-form.tsx`** — формально god-файл, но это один большой конфиг-экран с однородной структурой (Section + Field + Toggle). Файл читается линейно, нет запутанной логики. Декомпозиция не добавит ясности.
---
## Open Questions
1. **`forgot-password` и `reset-password` маршруты** не входят в `PUBLIC_ROUTES` в `proxy.ts:4`. Это намеренно — эти страницы требуют cookie для валидации токена? Или просто забыто?
2. **`src/app/api/admin/import-md/route.ts`** — существует, но нет UI для вызова. Мёртвый endpoint или WIP?
3. **QuizOption** — схема Prisma содержит `QuizOption` с `isCorrect`, но в `page.tsx` урока quiz загружается без `options` (`include: { questions: { orderBy: { order: "asc" } } }`). Тест работает только с open-ended вопросами или options загружаются где-то ещё?
4. **`load-test.js`** в корне репо — `k6` отмечен как missing dependency в depcheck. Это намеренно отдельный инструмент или планируется CI-интеграция?
+96 -3040
View File
File diff suppressed because it is too large Load Diff
-3
View File
@@ -22,7 +22,6 @@
"@kinescope/react-kinescope-player": "^0.5.4", "@kinescope/react-kinescope-player": "^0.5.4",
"@prisma/adapter-pg": "^7.6.0", "@prisma/adapter-pg": "^7.6.0",
"@prisma/client": "^7.6.0", "@prisma/client": "^7.6.0",
"@tailwindcss/typography": "^0.5.19",
"@tiptap/extension-image": "^3.22.2", "@tiptap/extension-image": "^3.22.2",
"@tiptap/extension-link": "^3.22.2", "@tiptap/extension-link": "^3.22.2",
"@tiptap/extension-placeholder": "^3.22.2", "@tiptap/extension-placeholder": "^3.22.2",
@@ -44,10 +43,8 @@
"react-dom": "19.2.4", "react-dom": "19.2.4",
"remark-parse": "^11.0.0", "remark-parse": "^11.0.0",
"resend": "^6.10.0", "resend": "^6.10.0",
"shadcn": "^4.1.2",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwind-merge": "^3.5.0", "tailwind-merge": "^3.5.0",
"tw-animate-css": "^1.4.0",
"unified": "^11.0.5", "unified": "^11.0.5",
"zod": "^4.3.6" "zod": "^4.3.6"
}, },
+3
View File
@@ -22,6 +22,9 @@ export async function POST(req: NextRequest) {
const label = (form.get("label") as string | null)?.trim() || null; const label = (form.get("label") as string | null)?.trim() || null;
if (!file || !lessonId) return NextResponse.json({ error: "Missing fields" }, { status: 400 }); if (!file || !lessonId) return NextResponse.json({ error: "Missing fields" }, { status: 400 });
const MAX_BYTES = 50 * 1024 * 1024;
if (file.size > MAX_BYTES) return NextResponse.json({ error: "Файл слишком большой" }, { status: 413 });
const name = label ?? file.name; const name = label ?? file.name;
const existing = await prisma.lessonFile.findFirst({ where: { lessonId, name } }); const existing = await prisma.lessonFile.findFirst({ where: { lessonId, name } });
+3
View File
@@ -14,6 +14,9 @@ export async function POST(req: NextRequest) {
const file = form.get("file") as File | null; const file = form.get("file") as File | null;
if (!file) return NextResponse.json({ error: "No file" }, { status: 400 }); if (!file) return NextResponse.json({ error: "No file" }, { status: 400 });
const MAX_BYTES = 50 * 1024 * 1024;
if (file.size > MAX_BYTES) return NextResponse.json({ error: "Файл слишком большой" }, { status: 413 });
const ext = file.name.split(".").pop() ?? "bin"; const ext = file.name.split(".").pop() ?? "bin";
const key = `uploads/${randomUUID()}.${ext}`; const key = `uploads/${randomUUID()}.${ext}`;
const buffer = Buffer.from(await file.arrayBuffer()); const buffer = Buffer.from(await file.arrayBuffer());
@@ -14,6 +14,9 @@ export async function POST(req: NextRequest) {
const file = form.get("file") as File | null; const file = form.get("file") as File | null;
if (!file) return NextResponse.json({ error: "Missing file" }, { status: 400 }); if (!file) return NextResponse.json({ error: "Missing file" }, { status: 400 });
const MAX_BYTES = 50 * 1024 * 1024;
if (file.size > MAX_BYTES) return NextResponse.json({ error: "Файл слишком большой" }, { status: 413 });
const ext = file.type.includes("ogg") ? "ogg" : file.type.includes("wav") ? "wav" : "webm"; const ext = file.type.includes("ogg") ? "ogg" : file.type.includes("wav") ? "wav" : "webm";
const key = `feedback-audio/${session.user.id}/${randomUUID()}.${ext}`; const key = `feedback-audio/${session.user.id}/${randomUUID()}.${ext}`;
const buffer = Buffer.from(await file.arrayBuffer()); const buffer = Buffer.from(await file.arrayBuffer());
+3
View File
@@ -14,6 +14,9 @@ export async function POST(req: NextRequest) {
const file = form.get("file") as File | null; const file = form.get("file") as File | null;
if (!file) return NextResponse.json({ error: "Missing file" }, { status: 400 }); if (!file) return NextResponse.json({ error: "Missing file" }, { status: 400 });
const MAX_BYTES = 50 * 1024 * 1024;
if (file.size > MAX_BYTES) return NextResponse.json({ error: "Файл слишком большой" }, { status: 413 });
const ext = file.name.split(".").pop() ?? "bin"; const ext = file.name.split(".").pop() ?? "bin";
const key = `feedback/${session.user.id}/${randomUUID()}.${ext}`; const key = `feedback/${session.user.id}/${randomUUID()}.${ext}`;
const buffer = Buffer.from(await file.arrayBuffer()); const buffer = Buffer.from(await file.arrayBuffer());
@@ -12,6 +12,9 @@ export async function POST(req: NextRequest) {
const file = form.get("file") as File | null; const file = form.get("file") as File | null;
if (!file) return NextResponse.json({ error: "Missing file" }, { status: 400 }); if (!file) return NextResponse.json({ error: "Missing file" }, { status: 400 });
const MAX_BYTES = 50 * 1024 * 1024;
if (file.size > MAX_BYTES) return NextResponse.json({ error: "Файл слишком большой" }, { status: 413 });
const ext = file.type.includes("ogg") ? "ogg" : file.type.includes("wav") ? "wav" : "webm"; const ext = file.type.includes("ogg") ? "ogg" : file.type.includes("wav") ? "wav" : "webm";
const key = `homework-audio/${session.user.id}/${randomUUID()}.${ext}`; const key = `homework-audio/${session.user.id}/${randomUUID()}.${ext}`;
const buffer = Buffer.from(await file.arrayBuffer()); const buffer = Buffer.from(await file.arrayBuffer());
@@ -12,6 +12,9 @@ export async function POST(req: NextRequest) {
const file = form.get("file") as File | null; const file = form.get("file") as File | null;
if (!file) return NextResponse.json({ error: "Missing file" }, { status: 400 }); if (!file) return NextResponse.json({ error: "Missing file" }, { status: 400 });
const MAX_BYTES = 50 * 1024 * 1024;
if (file.size > MAX_BYTES) return NextResponse.json({ error: "Файл слишком большой" }, { status: 413 });
const ext = file.name.split(".").pop() ?? "bin"; const ext = file.name.split(".").pop() ?? "bin";
const key = `homework/${session.user.id}/${randomUUID()}.${ext}`; const key = `homework/${session.user.id}/${randomUUID()}.${ext}`;
const buffer = Buffer.from(await file.arrayBuffer()); const buffer = Buffer.from(await file.arrayBuffer());
-4
View File
@@ -21,10 +21,8 @@ export async function saveLesson(
published: boolean; published: boolean;
} }
) { ) {
console.log("[saveLesson] start", lessonId);
try { try {
await requireAdmin(); await requireAdmin();
console.log("[saveLesson] auth ok");
} catch (e) { } catch (e) {
console.error("[saveLesson] auth failed:", e); console.error("[saveLesson] auth failed:", e);
throw e; throw e;
@@ -39,11 +37,9 @@ export async function saveLesson(
published: data.published, published: data.published,
}, },
}); });
console.log("[saveLesson] db update ok");
} catch (e) { } catch (e) {
console.error("[saveLesson] db update failed:", e); console.error("[saveLesson] db update failed:", e);
throw e; throw e;
} }
revalidatePath(`/admin/courses/${courseId}/modules/${moduleId}`); revalidatePath(`/admin/courses/${courseId}/modules/${moduleId}`);
console.log("[saveLesson] done");
} }
+3 -2
View File
@@ -1,3 +1,4 @@
import { cache } from "react";
import { prisma } from "./prisma"; import { prisma } from "./prisma";
// ── Defaults ────────────────────────────────────────────────────────────────── // ── Defaults ──────────────────────────────────────────────────────────────────
@@ -52,7 +53,7 @@ export type Settings = Record<SettingsKey, string>;
// ── Getters ─────────────────────────────────────────────────────────────────── // ── Getters ───────────────────────────────────────────────────────────────────
export async function getSettings(): Promise<Settings> { export const getSettings = cache(async (): Promise<Settings> => {
try { try {
const rows = await prisma.settings.findMany(); const rows = await prisma.settings.findMany();
const stored: Record<string, string> = {}; const stored: Record<string, string> = {};
@@ -64,7 +65,7 @@ export async function getSettings(): Promise<Settings> {
// DB unavailable at build time — return defaults // DB unavailable at build time — return defaults
return { ...SETTINGS_DEFAULTS } as Settings; return { ...SETTINGS_DEFAULTS } as Settings;
} }
} });
export async function getSetting(key: SettingsKey): Promise<string> { export async function getSetting(key: SettingsKey): Promise<string> {
try { try {
+2 -2
View File
@@ -1,9 +1,9 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { getSessionCookie } from "better-auth/cookies"; import { getSessionCookie } from "better-auth/cookies";
const PUBLIC_ROUTES = ["/login", "/register", "/verify-email", "/api/auth", "/maintenance"]; const PUBLIC_ROUTES = ["/login", "/register", "/verify-email", "/forgot-password", "/reset-password", "/api/auth", "/maintenance"];
export function proxy(request: NextRequest) { export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl; const { pathname } = request.nextUrl;
if ( if (