444b9c0faf
- 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>
154 lines
15 KiB
Markdown
154 lines
15 KiB
Markdown
# 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-интеграция?
|