Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5667198c9b | ||
|
|
cd40260b68 | ||
|
|
678cb97bb9 | ||
|
|
d77df46092 | ||
|
|
acbbc280fc | ||
|
|
8fc6ea804a | ||
|
|
19be1cc9ef | ||
|
|
1d71e249e4 | ||
|
|
b5f377b1bf | ||
|
|
28fac8a7aa | ||
|
|
9fbd696c84 | ||
|
|
c994edbed7 | ||
|
|
001ff8a7b7 | ||
|
|
3c127b895e | ||
|
|
19b2365b4c | ||
|
|
76d36e5c35 | ||
|
|
32aabe9a80 | ||
|
|
2363d099d2 | ||
|
|
b931c0a760 | ||
|
|
a84f33307e | ||
|
|
0dc8ffdb87 | ||
|
|
2377707297 | ||
|
|
70b0c7ae9b | ||
|
|
104bdc4f49 | ||
|
|
449b86955e | ||
|
|
55c4b281c3 | ||
|
|
45033bffa8 | ||
|
|
884188e9ec | ||
|
|
4c63a46d24 | ||
|
|
7367d66ac3 | ||
|
|
e8c15cc425 | ||
|
|
88dbff0136 | ||
|
|
00fbf41efb | ||
|
|
1a25ce7170 | ||
|
|
403574fe79 | ||
|
|
31542de33b | ||
|
|
2afe676969 | ||
|
|
f31b8deea2 | ||
|
|
4d3df1de37 | ||
|
|
3ecf87146a | ||
|
|
eea42eb704 | ||
|
|
54cf5bbfa4 | ||
|
|
19676c9b64 | ||
|
|
87541a5ce8 | ||
|
|
4b620d3e59 | ||
|
|
cb0e68d04a | ||
|
|
0e38a280d7 | ||
|
|
e4d58d6525 | ||
|
|
ddd49707ae | ||
|
|
8bf54fb95d | ||
|
|
e6c69c8a78 | ||
|
|
e4704c2dc6 | ||
|
|
a72600af13 | ||
|
|
2e1d36bc4d | ||
|
|
d943b77f58 | ||
|
|
a95af7daa7 | ||
|
|
69d46a0e5f | ||
|
|
16aba3efb0 | ||
|
|
cb3f010cd9 | ||
|
|
30cf04b0cf | ||
|
|
f2d465e0c4 | ||
|
|
e435b4ad43 | ||
|
|
218c3f825d | ||
|
|
4623358f21 | ||
|
|
5e2533b876 | ||
|
|
1ef81f9d1c | ||
|
|
72eeb5cf9b | ||
|
|
d7d4cc1133 | ||
|
|
d3dd922e32 | ||
|
|
12a527b3ee | ||
|
|
e6291a0235 | ||
|
|
d434732f41 | ||
|
|
12c612eccd | ||
|
|
5874e2b619 | ||
|
|
881b5b4c67 | ||
|
|
1145be02f3 | ||
|
|
27a2238082 | ||
|
|
dec8790a3a | ||
|
|
3a345cbefd | ||
|
|
c4fd31cd53 | ||
|
|
049df61e55 | ||
|
|
57e48a9d4f | ||
|
|
a93e37a6b1 | ||
|
|
0b4243fe91 | ||
|
|
66f7fdc1a8 | ||
|
|
d7c793c67a | ||
|
|
efe2a42d1d | ||
|
|
bbfde902a5 | ||
|
|
cc9a9e6c2d | ||
|
|
2784084c68 | ||
|
|
e8337c2845 | ||
|
|
6cfb13310d | ||
|
|
2c8beedc4b | ||
|
|
0b763e5738 | ||
|
|
1fc17225b0 | ||
|
|
a7692b1c35 | ||
|
|
fac668567e | ||
|
|
3c40e94a57 | ||
|
|
dd4d306a4e | ||
|
|
9203eee5d2 | ||
|
|
b263a735ac | ||
|
|
854f2d4650 | ||
|
|
779270fb73 | ||
|
|
1d706f1356 | ||
|
|
a8e1aa743d | ||
|
|
1e38419f07 | ||
|
|
21b00a8e09 | ||
|
|
db684efe9e | ||
|
|
fd753c1318 | ||
|
|
192de5e690 | ||
|
|
809b978c4b | ||
|
|
c54a5b180c | ||
|
|
e69426882a | ||
|
|
5aee2ce3f5 | ||
|
|
7dd816cfce | ||
|
|
19b0c855a9 | ||
|
|
d5359dd31f | ||
|
|
2c7ec17c8b | ||
|
|
2bebfc071c | ||
|
|
fd9b470c4c | ||
|
|
9798872f20 | ||
|
|
f9d310c8ea | ||
|
|
f8036fae79 | ||
|
|
4db02d75bc | ||
|
|
ee19da7fa0 | ||
|
|
7044505915 | ||
|
|
ad81adfe71 | ||
|
|
275ed46dfb | ||
|
|
b7e798b867 | ||
|
|
3850fe35e4 | ||
|
|
7ccc9199e0 | ||
|
|
0d6d059f5b | ||
|
|
63494f322b | ||
|
|
89152295ea | ||
|
|
5ae476551d | ||
|
|
0681831aaf | ||
|
|
6197f57c43 | ||
|
|
0f267ca6a9 | ||
|
|
bc3487639d | ||
|
|
018c65adc5 | ||
|
|
d6e589d2f5 | ||
|
|
7a003e5df6 | ||
|
|
616dabb595 | ||
|
|
e0cec0f943 | ||
|
|
441bdfe0fe | ||
|
|
eef33e457f | ||
|
|
ea14fd654f | ||
|
|
8a50b46b7b | ||
|
|
57f2ea4f41 | ||
|
|
dd5a2ea288 | ||
|
|
6844d659fc |
@@ -1,4 +1,14 @@
|
||||
node_modules
|
||||
uploads
|
||||
.git
|
||||
uploads
|
||||
backups
|
||||
certs
|
||||
models
|
||||
wg
|
||||
*.log
|
||||
*.tar.gz
|
||||
.env
|
||||
.env.*
|
||||
.vscode
|
||||
.idea
|
||||
.DS_Store
|
||||
@@ -0,0 +1,115 @@
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=сложный-пароль-админки
|
||||
DB_PASSWORD=случайная-длинная-строка
|
||||
# Лимит размера загружаемого на восстановление бэкапа, МБ (по умолчанию 500)
|
||||
BACKUP_UPLOAD_LIMIT_MB=500
|
||||
|
||||
# === Лимиты загрузки файлов ===
|
||||
# Максимальный размер одного файла, МБ (по умолчанию 50). Применяется ко всем
|
||||
# загрузкам: файлы проекта и фото учеников, фото групп и учеников, логотип,
|
||||
# фото модулей. Нельзя делать меньше UPLOAD_TOTAL_LIMIT_MB.
|
||||
UPLOAD_FILE_LIMIT_MB=50
|
||||
# Максимальный суммарный размер вложений одной записи, МБ (по умолчанию 200).
|
||||
UPLOAD_TOTAL_LIMIT_MB=200
|
||||
# Таймаут приёма запроса, мс. Пусто — считается автоматически
|
||||
# (UPLOAD_TOTAL_LIMIT_MB * 7500, минимум 5 минут). Увеличьте, если ученики
|
||||
# жалуются на обрыв загрузки на медленной мобильной связи.
|
||||
UPLOAD_REQUEST_TIMEOUT_MS=
|
||||
|
||||
# === Redis (кэш, rate limit, баны IP, pub/sub) ===
|
||||
# Пароль Redis. Обязателен, если Redis включён в docker-compose.
|
||||
REDIS_PASSWORD=замените-на-длинный-секрет
|
||||
# Префикс ключей — позволяет держать несколько инстансов в одном Redis.
|
||||
REDIS_PREFIX=whatido
|
||||
# Лимит памяти Redis. При превышении вытесняются ключи с наименьшим TTL (allkeys-lru).
|
||||
REDIS_MAXMEMORY=256mb
|
||||
|
||||
# Hugging Face token (нужен для закрытых/приватных репозиториев моделей)
|
||||
HUGGINGFACE_TOKEN=
|
||||
# Имя GGUF-файла модели для text-corrector (скачивается с Hugging Face, если отсутствует)
|
||||
AI_MODEL=qwen2.5-1.5b-instruct-q4_k_m.gguf
|
||||
# Репозиторий Hugging Face, откуда скачивается модель
|
||||
AI_MODEL_REPO=Qwen/Qwen2.5-1.5B-Instruct-GGUF
|
||||
# Промпт по умолчанию для автоисправления текстов (переопределяет встроенный, если в настройках не сохранён свой)
|
||||
AI_PROMPT=Ты — редактор текстов. Исправь ТОЛЬКО грамматические, орфографические и пунктуационные ошибки в тексте. Приведи к правильному регистру буквы. НЕ меняй слова, структуру предложений, стиль или смысл текста. Верни ТОЛЬКО исправленный текст без пояснений.
|
||||
# Таймаут запроса к AI (мс), по умолчанию 120000 (2 минуты)
|
||||
AI_REQUEST_TIMEOUT_MS=120000
|
||||
|
||||
# === Cloudflare Tunnel (контейнер cloudflared) ===
|
||||
# По умолчанию — Quick Tunnel на trycloudflare.com: случайный публичный URL,
|
||||
# адрес печатается в логах: docker compose logs -f cloudflared.
|
||||
# Чтобы привязать свой домен, замените command сервиса cloudflared
|
||||
# в docker-compose.yml на: command: ["tunnel", "run", "--token", "${CLOUDFLARE_TUNNEL_TOKEN}"]
|
||||
# (токен вида "eyJ..." создаётся в Cloudflare Zero Trust -> Networks -> Tunnels).
|
||||
CLOUDFLARE_TUNNEL_TOKEN=
|
||||
# Внутренний адрес приложения, на который указывает Quick Tunnel.
|
||||
CLOUDFLARE_TUNNEL_URL=http://app:3003
|
||||
# Максимальное ожидание WireGuard handshake перед fallback на прямой запуск
|
||||
# туннеля без VPN (сек). Если VPN-провайдер не отвечает — сайт всё равно поднимется.
|
||||
WG_HANDSHAKE_TIMEOUT=60
|
||||
|
||||
# === ИИ-улучшение фото (контейнер photo-ai, Real-ESRGAN + GFPGAN) ===
|
||||
# По умолчанию фото-ИИ включено: сервис photo-ai поднимается вместе со стеком
|
||||
# и работает на CPU. Оставьте значение пустым, чтобы выключить сервис —
|
||||
# тогда кнопка «🤖 ИИ» скрыта, а /api/entries/:id/photo/enhance-ai отвечает 503.
|
||||
# Ручные проверки сервиса идут по loopback-порту 127.0.0.1:8081 (наружу не публикуется):
|
||||
# curl http://127.0.0.1:8081/health
|
||||
PHOTO_AI_URL=http://photo-ai:8080
|
||||
# Максимум пикселей входного изображения, более крупный вход уменьшается.
|
||||
PHOTO_AI_MAX_PIXELS=4000000
|
||||
# Устройство инференса: auto (CUDA, если контейнеру выдан GPU, иначе CPU), cuda, cpu.
|
||||
# Явный cuda без доступной CUDA не роняет сервис: WARN в лог и работа на CPU.
|
||||
# GPU-вариант собирается оверрайдом (отдельный образ whatido-photo-ai:cu126, GPU через `gpus: all`):
|
||||
# docker compose -f docker-compose.yml -f docker-compose.gpu.yml up -d --build photo-ai
|
||||
# Чтобы оверрайд применялся ко всем командам docker compose на этой машине, укажите
|
||||
# в локальном .env: COMPOSE_FILE=docker-compose.yml:docker-compose.gpu.yml
|
||||
# Подробности установки NVIDIA Container Toolkit — в README, раздел «Запуск на NVIDIA GPU».
|
||||
PHOTO_AI_DEVICE=auto
|
||||
# Размер тайла инференса (0 — без тайлов). Меньше тайл — меньше памяти, медленнее.
|
||||
# На GPU с 4 ГБ VRAM держите 256: при нехватке сервис сам пройдёт лестницу тайлов и уйдёт на CPU.
|
||||
PHOTO_AI_TILE=256
|
||||
# Модель восстановления лиц: gfpgan (по умолчанию) или codeformer.
|
||||
# Официальный модуль codeformer вендорен в photo-ai/vendor, доступен сразу.
|
||||
PHOTO_AI_FACE_MODEL=gfpgan
|
||||
# Какие веса photo-ai скачивает на этапе сборки образа (build-arg, не переменная контейнера):
|
||||
# codeformer (по умолчанию) | face (codeformer + gfpgan) | all | none.
|
||||
# Веса попадают в слой образа, при старте переносятся в том photo-ai-models и живут там.
|
||||
PHOTO_AI_PREFETCH=codeformer
|
||||
# Предзагрузка всех моделей при старте (1) или ленивая загрузка по требованию (0).
|
||||
PHOTO_AI_LOAD_ALL=0
|
||||
# Качество JPEG результата (70..100).
|
||||
PHOTO_AI_JPEG_QUALITY=92
|
||||
# Таймаут заданий с восстановлением лиц на стороне приложения (мс).
|
||||
PHOTO_AI_FACE_TIMEOUT_MS=600000
|
||||
# Сколько раз воркер повторит задание, если photo-ai недоступен (503/обрыв сети),
|
||||
# не увеличивая attempts; после исчерпания — одна честная ошибка в задании.
|
||||
PHOTO_AI_SOFT_MAX_RETRIES=60
|
||||
# Пауза перед мягким повтором и её потолок (мс), задержка растёт вдвое до потолка.
|
||||
PHOTO_AI_SOFT_BACKOFF_MS=10000
|
||||
PHOTO_AI_SOFT_BACKOFF_MAX_MS=300000
|
||||
|
||||
# === Хранилище файлов (S3: SeaweedFS по умолчанию / MinIO) ===
|
||||
# local — файлы в ./uploads (по умолчанию), s3 — объекты в бакете S3/MinIO.
|
||||
STORAGE_DRIVER=local
|
||||
# Читать локальную копию, если объект ещё не перенесён в S3 (1 — включено).
|
||||
STORAGE_LOCAL_FALLBACK=1
|
||||
# Оставлять локальную копию после выгрузки в S3 (1 — оставлять, 0 — удалять).
|
||||
STORAGE_KEEP_LOCAL=0
|
||||
# Сколько часов хранить локальный кэш оригиналов (для sharp/миниатюр), 0 — не чистить.
|
||||
STORAGE_CACHE_MAX_AGE_HOURS=168
|
||||
# Образ S3-сервиса. По умолчанию SeaweedFS (свободный S3-сервер).
|
||||
# Для MinIO: S3_IMAGE=minio/minio:<тег> и запуск через docker-compose.minio.yml.
|
||||
S3_IMAGE=chrislusf/seaweedfs:latest
|
||||
S3_ENDPOINT=http://s3:9000
|
||||
S3_REGION=us-east-1
|
||||
S3_BUCKET=whatido
|
||||
# Логин/пароль S3 (для SeaweedFS — AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY,
|
||||
# для MinIO — MINIO_ROOT_USER/MINIO_ROOT_PASSWORD).
|
||||
S3_ACCESS_KEY=whatido
|
||||
S3_SECRET_KEY=замените-на-длинный-секрет
|
||||
# Path-style адресация (1 — включено, нужно для MinIO и SeaweedFS).
|
||||
S3_FORCE_PATH_STYLE=1
|
||||
# Необязательный префикс ключей внутри бакета (например, prod).
|
||||
S3_PREFIX=
|
||||
# Показывать веб-консоль MinIO (on/off), только для docker-compose.minio.yml
|
||||
MINIO_BROWSER=off
|
||||
@@ -6,6 +6,7 @@
|
||||
*.key
|
||||
*.crt
|
||||
certs/
|
||||
wg/
|
||||
|
||||
# --- Run-time data / uploads ---
|
||||
uploads/
|
||||
@@ -13,6 +14,9 @@ backups/
|
||||
*.sql.gz
|
||||
*.tar.gz
|
||||
|
||||
# --- Generated build artifacts ---
|
||||
public/version.json
|
||||
|
||||
# --- Node ---
|
||||
node_modules/
|
||||
|
||||
@@ -22,3 +26,13 @@ node_modules/
|
||||
tmp/
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# --- Playwright test artifacts ---
|
||||
.playwright-mcp/
|
||||
|
||||
# --- Python bytecode (photo-ai) ---
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
# --- Internal audit (not for commit) ---
|
||||
SECURITY_AUDIT.md
|
||||
|
||||
@@ -0,0 +1,419 @@
|
||||
# AGENT.md — Developer Agent Guidelines for WhatIDo
|
||||
|
||||
This document defines how AI agents should work with the WhatIDo codebase. Follow these rules strictly.
|
||||
|
||||
> **Обязательное правило:** разведку, чтение и анализ репозитория делают **субагенты**, а не основной агент — контекст основного агента не должен раздуваться простынями кода. См. [Agent Workflow](#agent-workflow-обязательные-правила-работы).
|
||||
|
||||
---
|
||||
|
||||
## Project Overview
|
||||
|
||||
**WhatIDo** — Accounting system for an educational center: attendance journal, student project works, group gallery, detached files, and public showcase pages (share links).
|
||||
|
||||
- **Stack**: Node.js 20 + Express, PostgreSQL 16, Redis 7, Docker Compose, S3-совместимое хранилище файлов, Tailscale (Serve/Funnel)
|
||||
- **Architecture**: Single Express server (`server.js`) + storage abstraction (`storage.js`) + cache/pub-sub abstraction (`redis.js`) + static frontend in `public/`
|
||||
- **Deployment**: Docker Compose (app + db + s3 + tailscale), bind-mounted uploads, named volumes for Postgres and S3 data
|
||||
- **Auth**: сессии в БД. `POST /api/auth/login` (bcrypt) → токен в заголовке `X-Auth-Token`. Второй способ для внешних систем — API-ключи в `X-Api-Key` (см. 3f), они не дают доступа к UI и живут только в `/api/v1/*`. Роли: `admin` и не-admin, ограниченные филиалами (`user_branches`). `ADMIN_PASSWORD` используется **только** для автосоздания первого админа в пустой БД — это не механизм авторизации API
|
||||
|
||||
---
|
||||
|
||||
## Agent Workflow (обязательные правила работы)
|
||||
|
||||
**Главное правило: исследование репозитория выполняют субагенты, а не основной агент.** Контекст основного агента — самый дорогой ресурс проекта: он живёт дольше одной задачи и должен содержать план, решения и точные точки правки, а не выгрузку файлов. Любой поиск, чтение и анализ файлов, которые пользователь явно не назвал, отдаются субагенту (`spawn_agent`).
|
||||
|
||||
### Что делегировать суагенту (обязательно)
|
||||
- **Разведку**: «где реализовано X», «какие эндпоинты трогают Y», «кто вызывает Z» — `search_codebase` + точечное чтение.
|
||||
- **Чтение крупных файлов**: `server.js`, `worker.js`, `storage.js`, `redis.js`, `public/js/*.js`, `README.md`, `AGENTS.md` целиком. Субагент возвращает релевантные куски, основной агент файл целиком не читает.
|
||||
- **Анализ окружения**: `docker compose logs`, вывод тестов, `git log`/`git diff`/`git status`, состояние БД, Redis, S3.
|
||||
- **Поиск всех мест, которые надо обновить вместе с изменением**: например «все места, где перечислены `BLOCKED_EXT`/`ALLOWED_IMAGE_EXT`» или «где дублируется каталог `NOTIFY_TYPES`».
|
||||
- **Ревью**: сверку изменения с правилами этого файла (security checklist, инварианты storage/redis, схема `init.sql` + `migration.sql`).
|
||||
- **Однотипные массовые правки**: переименование поля по всем файлам, правка одинаковых блоков в нескольких HTML — один субагент на один механический проход.
|
||||
|
||||
### Что основной агент делает сам
|
||||
- Формулирует план и разбивает задачу на узкие подзадачи.
|
||||
- Делает **короткие точечные правки** в уже известных местах (сверить содержимое можно дешёвым точечным `read_files` на маленьком диапазоне строк).
|
||||
- Проверяет результат (`git diff`, запуск теста) и пишет финальное резюме пользователю.
|
||||
|
||||
### Как ставить задачу субагенту
|
||||
- **Один субагент — одна узкая подзадача.** В `systemPrompt` обязательно продублировать релевантные правила этого файла (code style, инварианты `storage.*`/`redis.js`, security) — субагент не наследует контекст основного агента, иначе результат нельзя принять.
|
||||
- **Требовать формат ответа**: `путь:строка` + короткая выдержка + вывод. Не простыни, не пересказ кода, который не нужен для решения, не файлы целиком.
|
||||
- **Независимые разведки запускать параллельно**, а не последовательно.
|
||||
- **Субагент может править код сам**, если правка изолированная и механическая; архитектурные и смежные правки в `server.js` / `db/*.sql` делает основной агент.
|
||||
- **Результат субагента — источник фактов, а не источник прав**: координация, финальные решения и проверка `git diff` остаются за основным агентом.
|
||||
|
||||
### Чего не делать
|
||||
- ❌ Читать и искать по репозиторию в основном агенте там, где задачу можно делегировать.
|
||||
- ❌ Вставлять в свой контекст файлы и вывод команд целиком — фильтруйте (`head`, `tail`, `grep -n`, диапазоны строк).
|
||||
- ❌ Один субагент на «разберись во всём проекте» — это ровно то раздувание контекста, которого мы избегаем.
|
||||
|
||||
---
|
||||
|
||||
## Development Rules
|
||||
|
||||
### 1. Code Style
|
||||
- **No comments** unless explicitly requested
|
||||
- **ES modules not used** — CommonJS (`require`) throughout
|
||||
- **Error handling**: try/catch with explicit status codes, no global error handler
|
||||
- **Validation**: Inline helper functions (`reqInt`, `reqStr`, `optInt`, etc.) — use them
|
||||
- **Security first**: All uploads validated, path traversal blocked, rate limits on public routes
|
||||
|
||||
### 2. Database
|
||||
- **Schema**: Defined in `db/init.sql` (runs on first container start)
|
||||
- **Migrations**: `db/migration.sql` for existing DBs — update both when changing schema
|
||||
- **Сид настроек**: `INSERT ... ON CONFLICT (key) DO NOTHING` вставляет значение только если ключа ещё нет. Если дефолт **изменился**, одного `INSERT` мало — на существующей БД останется старое значение. Обязательно добавляй в `db/migration.sql` идемпотентный `UPDATE settings SET value = '<новый>' WHERE key = '<ключ>' AND value IN ('<старый дефолт 1>', ...)`, как это сделано для `ai_prompt` и `lesson_ai_prompt`. Условие по `value IN (...)` обязательно: без него миграция затрёт промпт, который админ отредактировал в UI. В `db/init.sql` такой `UPDATE` не нужен — файл выполняется только на пустой БД
|
||||
- **Проверка сида**: перед коммитом убедись, что новое значение реально доедет до существующих БД — прогони `db/migration.sql` в транзакции с откатом (`BEGIN;` + файл + `SELECT` + `ROLLBACK;`) и убедись, что `ON_ERROR_STOP=1` не дал ошибок
|
||||
- **Connection**: Single `Pool` from `pg`, `DATABASE_URL` from env
|
||||
- **Queries**: Parameterized only (`$1`, `$2`...), never string interpolation
|
||||
- **Transactions**: Use `client.query('BEGIN')` / `COMMIT` / `ROLLBACK` for multi-statement ops
|
||||
|
||||
### 3. File Uploads
|
||||
- **Multer configs**: `upload` (images only), `adminUpload` (wider allowed ext), `uploadBackup` (restore)
|
||||
- **Видео в интерфейсе**: `mp4`/`m4v`/`webm`/`ogv` играются в модалке `#videoModal` в `journal.html` (`data-video` в `filesHTML`); остальные видео (`mov`, `mkv`, `avi`, …) остаются обычными ссылками на скачивание. Отдача — `GET /api/files/:token?play=1` **inline** с `Accept-Ranges`; без `?play=1` файл по-прежнему уходит как `attachment`, чтобы старые ссылки не поменяли поведение
|
||||
- **Limits**: `UPLOAD_FILE_LIMIT_MB` per file (default 50), `UPLOAD_TOTAL_LIMIT_MB` per entry (default 200) — both env-driven; `UPLOAD_REQUEST_TIMEOUT_MS` overrides the auto-computed request timeout. The frontend reads the two MB values from `GET /api/public-settings` (`upload_file_limit_mb`, `upload_total_limit_mb`) — do not hardcode them again in `public/js/index.js`
|
||||
- **Staging**: Multer always writes to `uploads/` (`timestamp-random.ext`); a global `res.on('finish')` hook persists each uploaded file through `storage.persist` on successful responses (only when `STORAGE_DRIVER=s3`)
|
||||
- **HEIC**: Auto-converted to JPEG via `heic-convert`
|
||||
- **Cleanup**: `safeUnlink` / `sweepOrphanedUploads` — never delete outside `uploads/` or the configured bucket
|
||||
|
||||
### 3a. Storage (`storage.js`)
|
||||
- **Drivers**: `local` (default, files in `uploads/`) and `s3` (S3-compatible: SeaweedFS by default, MinIO via `docker-compose.minio.yml`)
|
||||
- **Keys are stable**: DB stores `/uploads/<name>`; S3 object keys are the same `<name>` (plus `.originals/<name>`). Never change key format — it would break existing DB rows and URLs
|
||||
- **API**: `put`, `putFile`, `head`, `exists`, `sizeOf`, `getStream`, `getBuffer`, `getRange`, `del`, `copyObject`, `listAll`, `localize`, `persist`, `streamTo`, `streamRangeTo`, `downloadAll`, `uploadTree`, `ensureBucket`, `usage`, `pruneCache`
|
||||
- **Диапазоны**: `getRange(key, start, end)` и `streamRangeTo(res, key, start, end, opts)` отдают `206` с `Content-Range`/`Accept-Ranges` — только для медиа, разбор `Range` на стороне сервера (`parseByteRange`)
|
||||
- **Rules**: never call `fs.*` on `uploads/` directly in request/worker code — use `storage.*`. `safeUnlink` is the only deletion helper (local + remote, idempotent)
|
||||
- **Read path**: `STORAGE_LOCAL_FALLBACK=1` prefers a local file when it still exists (covers in-flight uploads and partial migration); otherwise the app streams the object from S3
|
||||
- **Cache**: `.thumbs` (WebP miniatures) and `.cache` (originals localized for sharp/zip) live inside `uploads/` and are pruned hourly (`STORAGE_CACHE_MAX_AGE_HOURS`)
|
||||
- **Never publish the S3 API port**: only `127.0.0.1` on the host, file access stays behind app auth/rate limits
|
||||
|
||||
### 3b. Redis (`redis.js`)
|
||||
- **Единственная точка доступа**: `createRedis({ url, prefix })` — все операции кэша/счётчиков/pub-sub идут через неё
|
||||
- **API**: `get`, `set`, `del`, `dropPrefix`, `dropMatch`, `clear`, `wrap`, `incr`, `publish`, `on`, `rateLimitStore`, `info`, `connect`, `close`
|
||||
- **Graceful fallback — обязательное требование**: при недоступном Redis все операции уходят в in-memory backend с той же семантикой. Приложение обязано стартовать и работать без Redis
|
||||
- **Первое подключение ограничено по времени** (`REDIS_CONNECT_TIMEOUT_MS`, 5 с): node-redis не отклоняет `connect()` при недоступном сервере, а повторяет попытки бесконечно — без таймаута старт приложения зависнет навсегда
|
||||
- **Переподключение**: node-redis переподключается сам; по событию `ready` подписки и subscriber-клиент восстанавливаются (`ensureSubscriber`). Не пересоздавать subscriber через `destroy()` — это гонка с внутренним teardown node-redis
|
||||
- **Ключи**: `get`/`set` сами добавляют namespace (`REDIS_PREFIX`, по умолчанию `whatido`), `dropPrefix`/`dropMatch` тоже. В `rateLimitStore` префикс добавляется один раз в `base` — не применяйте `fullKey` повторно
|
||||
- **`scanDelete`**: курсор `SCAN` в node-redis v5 обязан быть строкой, числовой `0` вызовет `TypeError`. Возвращаемое значение курсора — тоже строка, сравнивайте с `'0'`
|
||||
- **`resetTime` в `rateLimitStore.increment` обязан быть `Date`** — express-rate-limit v8 вызывает `resetTime.getTime()`
|
||||
- **Пабликация всегда отдаёт подписчикам строку** (JSON), независимо от бэкенда — иначе fallback и Redis расходятся по формату
|
||||
- **Инвалидация — по префиксу** (`SCAN` + `DEL`), точечного удаления по ключу избегайте
|
||||
- **Ключевые пространства**: `setting:`, `groups:`, `students:`, `entries:`, `lessons:`, `stats:`, `dashboard:`, `share:payload:`, `public-settings`, `system-info`, `session:`, `ban:`, `fail:`, `rl:`
|
||||
- **Сессии**: `loadUserByToken` кэширует пользователя на 30 с. Любая мутация `users` / `sessions` / `user_branches` обязана вызывать `invalidateSessions()` или удалять `session:<token>`, иначе деактивированный пользователь сохранит доступ
|
||||
- **Секреты**: пароль только в `REDIS_URL` / `REDIS_PASSWORD`, порт 6379 публикуется лишь на `127.0.0.1`
|
||||
|
||||
### 3c. Уведомления (`server.js`, `worker.js`, `public/`)
|
||||
- **Каталог событий** — только `NOTIFY_TYPES` в `server.js` (тип → `label`, `hint`, `icon`, `level`, `enabled` по умолчанию, `admin`); фронтенд берёт список из `GET /api/notifications/meta`, дублировать каталог в HTML нельзя
|
||||
- **Таблицы**: `notifications` (событие, `admin_only`, `branch_id`) + `notification_reads` (прочтение на пользователя). Изменения схемы — в `db/init.sql` и `db/migration.sql` и в `ensureNotificationsTable()`
|
||||
- **Настройки**: `notify_enabled` (общий), `notify_retention_days` (1–365), `notify_<тип>` (точки типа заменяются на `_`, см. `notifySettingKey`). Значения только `'true'` / `'false'` — `PUT /api/settings` это валидирует
|
||||
- **Создание события** — только через `pushNotification()` / `notifyEntry()`; они сами проверяют переключатели и при выключенном типе возвращают `null`. `notifyEntry` подставляет `{student}` и `{group}` и определяет филиал по группе записи
|
||||
- **Хук в фото-воркере называется `notifyEvent`** — имя `notify` внутри `createPhotoEnhanceWorker` уже занято будильником воркера (`photoWorker.notify()` из `POST /api/photo-jobs/wake`), объявление функции перекрыло бы параметр
|
||||
- **Видимость**: админ видит всё; остальные — `admin_only = false` и `branch_id IS NULL` или филиал из `user_branches`
|
||||
- **Доставка**: запись в БД → `cache.publish('whatido:notifications', row)` → SSE `GET /api/notifications/stream` (клиенты фильтруются по `notificationVisible`). Redis недоступен — работает in-memory pub/sub
|
||||
- **Очистка**: `purgeOldNotifications()` при старте и раз в час по `notify_retention_days`; чтения удаляются каскадом
|
||||
- **Новое событие добавляется вместе с**: записью в `NOTIFY_TYPES`, строками `INSERT INTO settings` в `db/init.sql` + `db/migration.sql`, вызовом `pushNotification`/`notifyEntry` в точке события и парой `icon` из Lucide
|
||||
- **Аудит**: удаление/очистка уведомлений логируется (`notifications.delete`, `notifications.clear`)
|
||||
|
||||
### 3d. Отчёты о занятии и проверка по шаблону (`server.js`, `worker.js`, `public/`)
|
||||
- **Таблицы**: `lesson_reports` (+ `text_original`, `text_ai`, `ai_status`, `ai_checked_at`, `ai_error`) и `lesson_report_versions` (история версий: `text`, `source` = `manual` | `ai` | `restore`). Схема — в `db/init.sql`, `db/migration.sql` и `ensureLessonReportsTable()`
|
||||
- **Настройки**: `lesson_ai_enabled` (`'true'` / `'false'` — общий выклюжатель) и `lesson_ai_prompt` (промпт редактора сообщений тьютора + правила, пример вставляется в `db/init.sql`, `db/migration.sql` и в `LESSON_AI_DEFAULT_PROMPT` в `server.js`). Раздел в UI — `sec-lesson-ai` на `public/settings.html` Лимит промпта в `PUT /api/settings` — 8000 символов
|
||||
- **Начало фразы зависит от номера темы**: `lesson_reports.topic` — свободный текст, номер вида `N/M` тьютор пишет в конце строки (`Photoshop 3/5`). Позицию разбирает **воркер**, а не модель: `lessonTopicPosition()` в `worker.js` возвращает `{ kind, n, total, label }` (`kind` = `first` | `middle` | `last`; `N = M` проверяется раньше `N = 1`, поэтому `1/1` — это `last`) и добавляет в контекст готовую строку `Позиция темы: <label>` (например `последнее занятие модуля (2 из 2)`). Модель только выбирает формулировку по этой строке. Проверено на локальной модели: без явной подсказки `2/2` читается как «продолжали» вместо «завершили», поэтому полагаться на разбор номера моделью нельзя
|
||||
- **Хелпер экспортируется** в `module.exports` `worker.js` ради юнит-проверок: `N > M`, `M = 0`, пустая тема → `null`, строка `Позиция темы` не добавляется, и промпт требует нейтрального начала фразы
|
||||
- **Смена шаблона промпта — это четыре правки, а не три**: новое значение в `LESSON_AI_DEFAULT_PROMPT` (`server.js`), `db/init.sql`, `db/migration.sql` (там же `INSERT` для свежих БД) и **обязательно** `UPDATE settings SET value = '<новый>' WHERE key = 'lesson_ai_prompt' AND value IN ('<старый дефолт>')` в `db/migration.sql` — без него правка в SQL-файлах действует только на свежие установки, а у всех, кто уже пользовался разделом `sec-lesson-ai`, в `settings` останется старый промпт (см. «Сид настроек» в разделе 2). Все три текстовые копии должны быть побайтово идентичны `LESSON_AI_DEFAULT_PROMPT`
|
||||
- **Флаг из UI**: чекбокс `#lessonAiCheck` в модалке `#lessonModal` — включён при создании, выключен при редактировании (`resetLessonModalFields` / `fillLessonModalFromReport`). Уходит в теле как `ai_check`
|
||||
- **Роут не ждёт модель**: `POST`/`PUT /api/lesson-reports` при `ai_check: true` сохраняют отчёт как есть и ставят `ai_status = 'pending'`, затем `wakeLessonAiWorker()`. Ответ возвращается сразу — не блокируйте HTTP-запрос вызовом модели
|
||||
- **Воркер**: `createLessonReportChecker` в `worker.js` забирает `pending` через `FOR UPDATE OF lr SKIP LOCKED`, шлёт в модель текст + контекст (группа, дата, время, тема, позиция темы), результат: без изменений → `skipped`, переписан → `done` (новый текст в `text` и `text_ai`), сбой → до 3 попыток, затем `error`
|
||||
- **Доставка результата**: `onDone` в `server.js` пишет версию (`saveLessonReportVersion`), аудит с diff (`lesson_report.ai.format`), уведомление `lesson.ai.formatted` и SSE `lesson_report_status` на `EVENTS_CHANNEL`
|
||||
- **История версий**: `GET /api/lesson-reports/:id/versions`, восстановление — `POST /api/lesson-reports/:id/versions/:versionId/restore`, откат к тексту тьютора — `POST /api/lesson-reports/:id/ai/revert`. Хранится последние `LESSON_AI_VERSION_LIMIT` версий на отчёт
|
||||
- **Хуки фронтенда**: `openLessonVersions(id)` и `restoreLessonVersion(...)` живут в `public/admin.js` (модалка доступна с журнала, отчётов и дашборда), список и бейджи статусов — в `public/js/lessons.js`
|
||||
|
||||
### 3e. Дата, время и часовой пояс (`server.js`, `public/js/datetime.js`)
|
||||
- **Настройки**: `timezone` (IANA, валидируется через `validTimezone`) и `time_format` (`'24h'` | `'12h'`). Сид — в `db/init.sql` + `db/migration.sql`. `DEFAULT_TIMEZONE` берётся из `process.env.TZ`, иначе `Europe/Moscow`
|
||||
- **Единая точка**: `public/js/datetime.js` подключается на **каждой** странице (`public/*.html`) перед `admin.js`/`js/*.js`, включая публичные `share.html`, `report.html`, `index.html`. Инициализация — `await initDateTime()` (грузят `timezone`/`time_format` из `GET /api/public-settings`). На админ-страницах вызов встроен в `checkAuth()` в `admin.js`
|
||||
- **Запрещено** в `public/`: прямые `toLocaleString`/`toLocaleDateString`/`toLocaleTimeString`, `new Date().getFullYear()` и `new Date().toISOString().slice(0,10)` для показа/вычисления дат. Только хелперы `datetime.js`
|
||||
- **Три семейства данных — не путать**:
|
||||
- *instant* (`TIMESTAMPTZ`, ISO c `Z`) → `fmtFull` / `fmtDateTime` / `fmtDateFull` / `fmtDayMonth` / `fmtDateShort` / `fmtDateLong` / `fmtTimeOnly`. Зона применяется
|
||||
- *чистая DATE-строка* `'YYYY-MM-DD'` (`lesson_date`, `taken_at`, `date_from`) → `fmtDateOnlyIso` / `fmtShortIso` / `fmtDayMonthIso` / `fmtDateOnlyLongIso`. **Без `Date()`** — иначе `new Date('YYYY-MM-DD')` (UTC-полночь) сдвинет дату на день назад
|
||||
- *чистая TIME-строка* `'HH:MM(:SS)'` (`lesson_time`, `time_start`/`time_end`) → `fmtHmStr`. Учитывает 12h/24h, зону не применяет
|
||||
- **Текущие значения**: `todayIso()`, `nowHm()` (всегда 24h — для `<input type="time">`), `nowYear()`, `todayDow()`, `isoAddDays(iso, n)` (арифметика по ISO без зоны)
|
||||
- **Границы дней в SQL**: `TIMESTAMPTZ`-колонки фильтруются **только** через `tzDayStart`/`tzDayEnd` + `bindTz` (плейсхолдер `$TZ$` → `$N`, зона добавляется в `params` последней). Прямой `$n::date` по `created_at` считает дни в UTC (у контейнера `TimeZone=UTC`) и молча ломает границы — такого кода быть не должно. `DATE`-колонки (`lr.lesson_date`) сравниваются напрямую, зона не нужна
|
||||
- **`now()` по зоне**: `tzWall()` для «сейчас» и для `day_of_week`/расписания. Хардкод `'Europe/Moscow'` в SQL запрещён
|
||||
- **`bindTz` добавляет параметр только если в SQL есть `$TZ$`**: иначе Postgres отвечает `bind message supplies 1 parameters, but prepared statement requires 0`, а без global error handler запрос **висит вечно** (страница остаётся «Загрузка...»). Поэтому запрос с фильтрами дат работает, а без них — падает: проверяй оба варианта. Регрессия закрыта в `api.smoketest.js`
|
||||
- **`TZ` в compose** (`docker-compose.yml`, 5 мест) — только фолбэк для `DEFAULT_TIMEZONE`; фактическая зона берётся из настройки
|
||||
|
||||
### 3f. Внешний API и API-ключи (`server.js`, `public/apikeys.html`, `public/js/apikeys.js`)
|
||||
- **Два независимых способа аутентификации**: сессии (`X-Auth-Token` → `requireAuth`) и API-ключи (`X-Api-Key` или `Authorization: Bearer` → `requireApiKey`). Это **разные** middleware: не смешивайте их, иначе поедет контракт из `api.smoketest.js`. `requireApiKey` обслуживает **только** `/api/v1/*`
|
||||
- **Таблица**: `api_keys` (`user_id`, `name`, `prefix`, `key_hash`, `scopes TEXT[]`, `branch_ids INT[]`, `rate_limit_per_min`, `last_used_at/ip`, `expires_at`, `revoked_at`). DDL — в `db/init.sql`, `db/migration.sql` **и** `ensureApiKeysTable()` (`server.js`), вызывается из `ensureUsersAndFirstAdmin()`
|
||||
- **Ключ не хранится**: в БД лежит только `sha256(ключ)` в `key_hash` + первые 12 символов в `prefix` для отображения. Секрет возвращается **один раз** при `POST /api/api-keys` и `POST /api/api-keys/:id/rotate` — восстановить его нельзя, только выпустить новый. Формат `wsk_<64 hex>`
|
||||
- **Поиск ключа** — по хешу (`key_hash` UNIQUE), не по префиксу; сравнение строк не TimingSafe, поэтому и не делается: вход идёт через индекс по хешу
|
||||
- **Права (`scopes`)**: `read` и `write`. Весь `/api/v1/*` требует `read` (в `apiV1.use`), мутации дополнительно проходят `apiWrite('write')` — без `write` ключ читает, но получает 403 на записи
|
||||
- **Филиалы ключа сужают, но не расширяют права**: `apiKeyUser()` пересекает `branch_ids` ключа с филиалами владельца и **понижает роль до `tutor`**, даже если владелец — админ. Ключ не может стать шире возможностей того, кто его выдал
|
||||
- **Кэш**: `apikey:<sha256>` в Redis, TTL `SESSION_CACHE_TTL_MS` (30 с). Любая мутация ключа, а также смена роли/активности/филиалов пользователя (`PUT`/`DELETE /api/users/:id`) обязана звать `invalidateApiKeys()` — иначе отозванный ключ продолжит работать до истечения кэша
|
||||
- **`last_used_at` троттлится** маркером `apikey:touch:<id>` (5 мин), а не пишется на каждый запрос
|
||||
- **Rate limit**: отдельный `apiKeyLimiter` на `cache.rateLimitStore('apikey', 60s)`; лимит — функция от `rate_limit_per_min` ключа (по умолчанию `API_KEY_DEFAULT_RPM` = 120). Ключ счёта — `k<id>` по `keyGenerator`, для неавторизованных — `ip` через `ipKeyGenerator(ipOf(req))` (обязателен, иначе IPv6-клиенты обходят лимит)
|
||||
- **Подбор ключа** считается через `recordFailure(req, 'apikey-bruteforce', 30, BAN_TTL_MS)`; метка причины есть в `BAN_REASON_LABELS`
|
||||
- **CRUD ключей** (`/api/api-keys`, `/meta`, `/:id`, `/:id/rotate`) — под `requireAuth, requireAdmin`. Не-admin видит и правит только свои ключи. Валидация: `reqStr` для имени, `normalizeApiScopes`, `apiKeyRateValue` (1..10000), `apiKeyExpiry`, `apiKeyAllowedBranches` (возвращает `null` при чужом/несуществующем филиале)
|
||||
- **Формат ответов `/api/v1`**: списки возвращают единый конверт `{ items, total, limit, offset }` (`apiList`) — в отличие от внутреннего API, где формы ответа разные (`{entries,total}`, `{modules,total}`, голый массив). Не смешивайте с внутренними хелперами
|
||||
- **Мутации через API** аудитятся через `apiAudit()` — он добавляет `via_api_key: <id>` в `audit_log.target`, поэтому в аудите видно, каким ключом сделано изменение. После мутаций обязательны `invalidateEntries()` / `invalidateLessonReports()` / `invalidateStudents()` / `invalidateStats()` + `broadcastEntryChanged()`, иначе фронтенд не обновится
|
||||
- **`DELETE /api/v1/entries/:id` — мягкое удаление** (`deleted_at`), как и во внутреннем API; физическое удаление живёт только в корзине
|
||||
- **Управление воркерами на `apiV1`** (`POST /ai/wake`, `/photo-jobs/wake`, `/ai/requeue-failed`, `/photo-jobs/requeue-failed`, `/entries/:id/ai/recheck`) — все под `apiWrite('write')`, аудит через `apiAudit()` с префиксом `api.`. `wake` — только пинок `worker.notify()`, он **не гарантирует немедленную обработку**: воркер берёт задачи из БД через `FOR UPDATE SKIP LOCKED` и просыпается по своему backoff-циклу, поэтому `wake` — оптимизация, а не условие работы
|
||||
- **Массовые операции на `apiV1` обязаны учитывать филиалы ключа.** Внутренние `POST /api/ai/requeue-failed` и `/api/photo-jobs/requeue-failed` делают `UPDATE ... WHERE status='error'` по всей таблице — для `apiV1` такой код ломает правило «ключ не шире выдавшего». Фильтр строится хелпером `apiBranchClause(user, expr, params)` (`server.js`): возвращает пустую строку для admin'а, `AND FALSE` при пустом списке филиалов и `AND <expr> = ANY($N::int[])` иначе. Выражение для записей — `(SELECT g.branch_id FROM groups g WHERE g.id = e.group_id)`, для фото-джобов — через `photo_jobs → entries → groups`. Не переносите внутренний `UPDATE` в `apiV1` без этого фильтра
|
||||
- **Воркер отчётов о занятии не имеет `wake`-эндпоинта ни во внутреннем API, ни в `apiV1`** — он будится неявно через `wakeLessonAiWorker()` из `POST`/`PUT /lesson-reports`, когда `ai_check === true` (строго boolean; строка `"true"` не срабатывает) и `lesson_ai_enabled` ≠ `'false'`
|
||||
- **`api_keys` НЕ входит в бэкап** (как `sessions`), а `POST /api/restore` делает `DELETE FROM api_keys` — после восстановления все внешние ключи мертвы, их надо выпустить заново
|
||||
|
||||
### 4. API Patterns
|
||||
- **Middleware**: `requireAuth` — читает `X-Auth-Token`, 401 без валидной активной сессии. `requireAdmin` — самодостаточный (внутри вызывает `requireAuth`, если `req.user` ещё нет), 403 при `role !== 'admin'`. `optionalAuth` — для публичных страниц с персонализацией
|
||||
- **Филиалы**: `branchScope(user)` / `branchWhere(user, alias)` — для не-admin `user.branch_ids` (из `user_branches`) ограничивают выборку; у `admin` `ids = null` и фильтр не добавляется
|
||||
- **Public routes**: `apiLimiter` (300/15min), `entryLimiter` (10/15min), `fileLimiter` (300/15min) — все на `cache.rateLimitStore(...)`, не на `MemoryStore`
|
||||
- **Responses**: JSON, `{ error: 'message' }` on failure, data directly on success
|
||||
- **Pagination**: `limit` / `offset` query params, return `{ items, total }` or `{ entries, total }`
|
||||
- **Filters**: `group_id`, `date_from`, `date_to`, `student_name`, `search`, `deleted`
|
||||
|
||||
### 5. Frontend (public/)
|
||||
- Vanilla HTML/CSS/JS, no build step
|
||||
- Each page = single HTML file + shared `admin.js` / `admin.css`
|
||||
- API calls via `fetch` with `X-Auth-Token` (токен из `localStorage`); `X-Admin-Token` больше не используется и не работает
|
||||
- Share pages (`share.html`, `links.html`) work without auth
|
||||
|
||||
### 6. Docker / Compose
|
||||
- **Dockerfile**: Node 22 Alpine, installs deps, generates self-signed TLS cert
|
||||
- **docker-compose.yml**: сервисы `db`, `app`, `redis`, `s3` (+ опционально `tailscale`, `cloudflared`, `text-corrector`, `photo-ai`)
|
||||
- `db`: postgres:16-alpine, healthcheck, init.sql mounted
|
||||
- `redis`: redis:7-alpine, `--requirepass`, AOF, `maxmemory` + `allkeys-lru`, healthcheck, том `redis-data`, порт только на `127.0.0.1`
|
||||
- `app`: builds from Dockerfile, exposes 3003/3443, mounts uploads
|
||||
- `tailscale`: host network, NET_ADMIN, runs `start-tailscale.sh` (funnel to 127.0.0.1:3443)
|
||||
- **Env vars** (required): `ADMIN_PASSWORD`, `DB_PASSWORD`, `REDIS_PASSWORD`
|
||||
- **Env vars** (optional): `REDIS_PREFIX` (default `whatido`), `REDIS_MAXMEMORY` (default `256mb`), `REDIS_CONNECT_TIMEOUT_MS` (default `5000`)
|
||||
- **Port 443 on host** must be free (tailscale listens directly)
|
||||
|
||||
### 7. Tailscale Publication
|
||||
- No external IP / port forwarding needed
|
||||
- Access: `https://whatido.<tailnet>.ts.net` (inside tailnet + internet via Funnel)
|
||||
- First run: `docker exec -it whatido-tailscale-1 tailscale up --hostname=whatido` → authorize in browser
|
||||
- Enable Serve/Funnel in Tailscale admin console for the node
|
||||
- Cert: app generates self-signed cert at build (`certs/cert.pem`), mounted into tailscale container
|
||||
|
||||
### 8. Backup / Restore
|
||||
- **Admin UI**: `POST /api/backup` → тикет + `GET /api/backup/:token` (ссылка живёт `BACKUP_TTL_MS`, 30 мин, **скачивание можно повторять** — в том числе после обрыва связи и F5; не «сжигать» тикет `HEAD`-пробой, Express 4 отдаёт HEAD через GET-хендлер), `POST /api/restore` (upload `.tar.gz`)
|
||||
- **Хранение тикетов**: in-memory `Map` (`server.js`), поэтому после рестарта приложения ссылка даёт 404 — это ожидаемо. Одновременно живых тикетов не больше `BACKUP_TICKETS_MAX`, лишние и истёкшие вычищаются `pruneBackupTickets()`; файлы доживают `sweepBackupStorage()` с запасом в 5 минут сверх TTL
|
||||
- **Scripts**: `scripts/backup.sh`, `scripts/restore.sh` (host-level)
|
||||
- Формат архива: `tar.gz` с `data.json` + `uploads/`. Версия формата — `BACKUP_FORMAT_VERSION` в `backup-restore.js` (сейчас `2`), принимаются версии `1..2`; версия пишется в `data.json.version` и возвращается в ответе `POST /api/backup` и `POST /api/restore`
|
||||
- `data.json` содержит `version`, `created_at`, `app` (версия/коммит), `counts` (строки по таблицам + `files`) и сами данные. Таблицы перечислены в `BACKUP_TABLES` — **при добавлении таблицы править её и в `buildBackupArchive`, и здесь**
|
||||
- `sessions` в бэкап **не входит** намеренно: после restore все токены должны умереть. `audit_log`, `notifications`, `notification_reads`, `banned_ips` — входят
|
||||
- Файлы: `storage.downloadAll` кладёт в архив всё, кроме регенерируемых `.thumbs/` и `.cache/`; `.originals/` (оригиналы фото до ИИ-обработки) **входят** и восстанавливаются через `uploadTree`
|
||||
- Restore: валидация всего через `normalizeRestoreData` (`backup-restore.js`), транзакция с `DELETE` в FK-безопасном порядке → `INSERT` → `setval` по `BACKUP_SEQUENCE_TABLES` → файлы → `sweepOrphanedUploads()` → `loadBans()` → `invalidateAll()`
|
||||
- **Колонки, которые normalizeRestoreData обязана сохранять**: `groups.deleted_at`/`purge_at`, `entries.purge_at`. Потеря `deleted_at` воскрешает мягко удалённые группы как активные — это не «мелочь», а порча данных
|
||||
- `sweepOrphanedUploads()` считает ссылками фото из `entries.photo_path`/`photo_original_path`, `project_files.path`, `group_photos`, `entry_photos`, `student_photos`, `modules.photo_path`, `students.photo_path`, `groups.cover_path`, `photo_jobs.before_path`/`after_path`, `settings.system_logo`. Новая колонка с путём к файлу → добавить сюда, иначе sweep снесёт файл сразу после restore
|
||||
|
||||
---
|
||||
|
||||
## Common Tasks
|
||||
|
||||
Каждый рецепт ниже начинается с субагента-разведки (см. [Agent Workflow](#agent-workflow-обязательные-правила-работы)): пусть он найдёт нужные места и вернёт `путь:строка`, а правки вносит основной агент.
|
||||
|
||||
### Add a new API endpoint
|
||||
1. Add route in `server.js` (group with related routes)
|
||||
2. Use `requireAdmin` for admin, `apiLimiter`/`fileLimiter` for public
|
||||
3. Validate input with helper functions
|
||||
4. Use parameterized queries, transactions if multi-table
|
||||
5. Call `logAudit(req, 'action.name', { ... })` for mutations
|
||||
6. Return JSON, handle errors with appropriate status codes
|
||||
|
||||
### Add a database column/table
|
||||
1. Update `db/init.sql` (CREATE TABLE / ALTER TABLE)
|
||||
2. Update `db/migration.sql` (idempotent ALTERs)
|
||||
3. Update `server.js` queries that SELECT/INSERT the table
|
||||
4. Test: `docker compose down && docker compose up -d --build`
|
||||
|
||||
### Add a frontend page
|
||||
1. Create `public/newpage.html` (copy structure from existing)
|
||||
2. Link in `public/admin.html` navigation if admin page
|
||||
3. Use `admin.js` utilities: `api()`, `requireAuth()`, `formatDate()`, etc.
|
||||
4. No build step — just refresh browser
|
||||
|
||||
### Modify file upload rules
|
||||
- Edit `BLOCKED_EXT`, `ALLOWED_IMAGE_EXT`, `ADMIN_ALLOWED_EXT` constants
|
||||
- Update Multer `fileFilter` functions
|
||||
- Keep `MAX_TOTAL_UPLOAD_BYTES` and per-file limit in sync
|
||||
|
||||
### Migrate files to S3 / switch storage driver
|
||||
1. `docker compose up -d s3`
|
||||
2. `docker compose exec -T app node scripts/migrate-to-s3.js --dry-run` then without the flag (idempotent, size-checked, keeps local files)
|
||||
3. `docker compose exec -T app node scripts/migrate-to-s3.js --verify-only`
|
||||
4. Set `STORAGE_DRIVER=s3` in `.env`, `docker compose up -d app`
|
||||
5. After verification: `docker compose exec -T app node scripts/migrate-to-s3.js --delete-local`
|
||||
- Rollback: `STORAGE_DRIVER=local` + `docker compose up -d app`
|
||||
- Do not run `--delete-local` before the app serves reads from S3 and the verification passes
|
||||
|
||||
---
|
||||
|
||||
## Testing & Verification
|
||||
|
||||
No automated test suite exists. Verify manually:
|
||||
|
||||
```bash
|
||||
# Start stack
|
||||
docker compose up -d --build
|
||||
|
||||
# Check logs
|
||||
docker compose logs -f app
|
||||
|
||||
# Test API (replace LOGIN/PASS; X-Admin-Token больше не работает)
|
||||
TOKEN=$(curl -s -X POST http://localhost:3003/api/auth/login \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{\"username\":\"$LOGIN\",\"password\":\"$PASS\"}" | sed -E 's/.*"token":"([a-f0-9]+)".*/\1/')
|
||||
curl -H "X-Auth-Token: $TOKEN" http://localhost:3003/api/auth/me
|
||||
# /api/groups — публичный (optionalAuth), 200 даже без токена:
|
||||
# для проверки авторизации берите /api/auth/me или /api/users
|
||||
|
||||
# Run backup/restore scripts
|
||||
./scripts/backup.sh
|
||||
./scripts/restore.sh backups/whatido-backup-<date>.tar.gz
|
||||
|
||||
# Storage checks
|
||||
docker compose up -d s3
|
||||
docker compose exec -T app node scripts/migrate-to-s3.js --dry-run
|
||||
docker compose exec -T app node scripts/migrate-to-s3.js --verify-only
|
||||
|
||||
# Redis checks
|
||||
node redis.selftest.js # unit + degradation, needs redis on 127.0.0.1:6379
|
||||
node api.smoketest.js # e2e, needs running stack
|
||||
docker compose exec redis redis-cli -a "$REDIS_PASSWORD" --no-auth-warning INFO
|
||||
docker compose stop redis && node api.smoketest.js # app must keep working in-memory
|
||||
|
||||
# Backup checks
|
||||
node backup.selftest.js # unit, no stack needed
|
||||
curl -sk -X POST https://127.0.0.1:3443/api/backup -H "X-Auth-Token: $TOKEN" # -> counts по всем таблицам
|
||||
docker compose start redis # app reconnects on its own
|
||||
|
||||
# Audit diff checks
|
||||
node diff.selftest.js # unit, no stack needed
|
||||
|
||||
# External API checks
|
||||
node api-keys.selftest.js # e2e, needs running stack
|
||||
curl -H "X-Api-Key: wsk_..." http://localhost:3003/api/v1/me
|
||||
```
|
||||
|
||||
`diff.js` builds the audit payload for text changes: word-level segments
|
||||
(`eq`/`del`/`add`), per-step stats (`added_words`, `removed_words`, `chars_before/after`) and a
|
||||
light `summarizeChanges`/`stripDiffs` pair for the audit list. Two rules to keep:
|
||||
the diff payload stored in `audit_log.target` must stay capped (it is rendered raw in the audit
|
||||
UI), and `GET /api/audit` must keep stripping `diff` while `GET /api/audit/:id` returns it —
|
||||
otherwise the list endpoint ships kilobytes of text per row.
|
||||
|
||||
Verify Redis state through `GET /api/system-info` → `cache` (`driver`, `ready`, `hits`, `misses`,
|
||||
`fallbackOps`, `used_memory_human`, `keys`).
|
||||
|
||||
`GET /api/system-info` also returns a `stack` block (built by `getStackInfo()`, outside the
|
||||
response cache so versions and load stay fresh): `app` (Node, PID, RSS/heap, uptime, version from
|
||||
`package.json` + `public/version.json`), `deps` (installed versions of the main packages), `runtime`
|
||||
(OS from `/etc/os-release`, kernel, arch, CPU count/model, loadavg, memory, container detection),
|
||||
`database` (PostgreSQL version, host, pool counters), `cache` (driver, version, ready, keys, memory,
|
||||
hits/misses, fallback ops) and `storage` (driver, endpoint, bucket). Hosts come from `URL.hostname`
|
||||
only — credentials from `DATABASE_URL`/`REDIS_URL` must never reach the payload. The «Статус стека»
|
||||
block on `public/settings.html` renders exactly this payload.
|
||||
|
||||
`api.smoketest.js` also locks the auth contract: only `X-Auth-Token` with a session token
|
||||
authenticates, while `X-Admin-Token`, `Authorization: Bearer` and `ADMIN_PASSWORD` used as a
|
||||
token must all be rejected with 401. If you change the auth scheme, update this test and the
|
||||
Auth notes in this file together — a doc that drifts from the code is the failure mode this
|
||||
guards against.
|
||||
|
||||
---
|
||||
|
||||
## Security Checklist (before any change)
|
||||
- [ ] No SQL interpolation — only `$1`, `$2`...
|
||||
- [ ] Upload path validation via `isSafeUploadPath` / `safeUnlink`
|
||||
- [ ] File I/O через `storage.*`, ключи объектов не выходят за пределы бакета/`uploads/`
|
||||
- [ ] Rate limiter on new public routes
|
||||
- [ ] Admin routes behind `requireAdmin`
|
||||
- [ ] New auth paths checked against the contract in `api.smoketest.js`, docs updated in the same change
|
||||
- [ ] API-ключи: в БД только хеш+префикс, секрет возвращается один раз; любая мутация ключа зовёт `invalidateApiKeys()`
|
||||
- [ ] No secrets in code — only via env vars
|
||||
- [ ] Helmet headers present (already global)
|
||||
- [ ] CORS disabled (no `cors` middleware)
|
||||
|
||||
---
|
||||
|
||||
## File Map (key files)
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `server.js` | Entire backend (Express, routes, DB, uploads, backup) |
|
||||
| `storage.js` | Storage abstraction: `local` and `s3` drivers, key normalization, cache/thumb helpers |
|
||||
| `redis.js` | Redis abstraction: cache, counters, rate-limit store, pub/sub, in-memory fallback |
|
||||
| `redis.selftest.js` | Self-tests for `redis.js`, including behaviour with Redis unavailable |
|
||||
| `diff.js` / `diff.selftest.js` | Word-level text diff and audit change payload; self-tests |
|
||||
| `backup-restore.js` / `backup.selftest.js` | Backup format version, `normalizeRestoreData` validation of restore payloads, backup table lists; self-tests |
|
||||
| `api.smoketest.js` | End-to-end API smoke test against a running stack |
|
||||
| `api-keys.selftest.js` | Self-tests for external API: `X-Api-Key` auth, scopes, branch scoping, rate limit, rotate/revoke |
|
||||
| `worker.js` | Background AI auto-check workers: entry messages, lesson-report template check, photo enhance |
|
||||
| `db/init.sql` | Initial schema (runs on fresh DB) |
|
||||
| `db/migration.sql` | Idempotent migrations for existing DBs |
|
||||
| `docker-compose.yml` | Service definitions (app, db, s3, tailscale) |
|
||||
| `docker-compose.minio.yml` | Override: S3 service backed by MinIO instead of SeaweedFS |
|
||||
| `Dockerfile` | App image build |
|
||||
| `public/*.html` | Frontend pages |
|
||||
| `public/admin.js` | Shared frontend logic, модалка отчёта о занятии (`openLessonModal`) |
|
||||
| `public/js/datetime.js` | Единая точка форматирования дат/времени: часовой пояс + 24h/12h. Подключается на всех страницах |
|
||||
| `public/lessons.html` | Отчёты о занятиях: список, фильтры, редактирование |
|
||||
| `scripts/backup.sh` | Host-level backup script (DB dump + storage export) |
|
||||
| `scripts/restore.sh` | Host-level restore script (DB dump + storage import) |
|
||||
| `scripts/storage-sync.js` | Export/import all storage objects (used by backup/restore) |
|
||||
| `scripts/migrate-to-s3.js` | One-off/idempotent migration `uploads/` -> S3 bucket |
|
||||
| `scripts/deploy.sh` | Deploy script (pull master, build image with commit version, restart app) |
|
||||
| `start-tailscale.sh` | Tailscale container entrypoint |
|
||||
| `.env.example` | Env var template |
|
||||
|
||||
---
|
||||
|
||||
## Do Not
|
||||
|
||||
- ❌ Add dependencies without updating `package.json` and rebuilding
|
||||
- ❌ Write files outside `uploads/` or `certs/`
|
||||
- ❌ Touch `uploads/` with `fs.*` in request/worker code — use `storage.*` (files may live only in S3)
|
||||
- ❌ Run `migrate-to-s3.js --delete-local` before verification and cutover
|
||||
- ❌ Expose the S3 API port publicly (only `127.0.0.1` in compose)
|
||||
- ❌ Expose the Redis port publicly (only `127.0.0.1` in compose)
|
||||
- ❌ Call `fs.*`/`pg` directly for cache, counters or pub/sub — use `redis.js`
|
||||
- ❌ Make Redis a hard dependency: any new Redis-backed path must keep the in-memory fallback
|
||||
- ❌ `await client.connect()` without a timeout — it never rejects while Redis is unreachable
|
||||
- ❌ Cache authorization-relevant data without an invalidation path on the mutation
|
||||
- ❌ Commit `.env`, `certs/`, `uploads/`, `backups/`, `node_modules/`
|
||||
- ❌ Expose DB port (5432) outside docker network
|
||||
- ❌ Use `eval`, `Function` constructor, or dynamic code execution
|
||||
- ❌ Add comments to code (this file excepted)
|
||||
- ❌ Read/search the repo in the main agent when the work can be delegated to a subagent
|
||||
- ❌ Load whole files (`server.js`, `worker.js`, `storage.js`, `redis.js`, `public/js/*.js`, `README.md`) or raw command output into the main context — delegate and filter (`grep -n`, `head`, line ranges)
|
||||
- ❌ Give a subagent an open-ended "explore the whole project" task, or omit the project rules from its `systemPrompt` — it does not inherit the main context
|
||||
- ❌ Treat a subagent report as the final word: coordinate, edit and verify the result yourself (`git diff`)
|
||||
|
||||
---
|
||||
|
||||
## Quick Commands
|
||||
|
||||
```bash
|
||||
# Full rebuild
|
||||
docker compose down && docker compose up -d --build
|
||||
|
||||
# Обновление на сервере (pull master + сборка образа с версией коммита + перезапуск app)
|
||||
./scripts/deploy.sh
|
||||
|
||||
# App logs
|
||||
docker compose logs -f app
|
||||
|
||||
# DB shell
|
||||
docker compose exec db psql -U app -d whereldo
|
||||
|
||||
# Redis status and cache keys
|
||||
docker compose exec redis redis-cli -a "$REDIS_PASSWORD" --no-auth-warning DBSIZE
|
||||
docker compose exec redis redis-cli -a "$REDIS_PASSWORD" --no-auth-warning KEYS 'whatido:*'
|
||||
|
||||
# S3 storage status and migration verification
|
||||
docker compose up -d s3
|
||||
docker compose exec -T app node scripts/migrate-to-s3.js --verify-only
|
||||
docker compose exec -T app node scripts/migrate-to-s3.js --delete-local
|
||||
|
||||
# Tailscale status
|
||||
docker exec -it whatido-tailscale-1 tailscale status
|
||||
|
||||
# Manual funnel restart
|
||||
docker exec whatido-tailscale-1 tailscale funnel --bg --yes https://127.0.0.1:3443
|
||||
```
|
||||
@@ -0,0 +1,29 @@
|
||||
# Пример конфигурации Caddy для публичного развёртывания WhatIDo.
|
||||
#
|
||||
# Порядок включения:
|
||||
# 1. Скопируйте этот файл в ./Caddyfile (cp Caddyfile.example Caddyfile)
|
||||
# 2. Замените yourdomain.example на реальный домен/WWW, указывающий на сервер
|
||||
# 3. В docker-compose.yml расскомментируйте сервис caddy (и тома caddy_data/caddy_config)
|
||||
# и переведите блок ports сервиса app в expose (см. комментарии в compose)
|
||||
# 4. docker compose up -d --build
|
||||
#
|
||||
# Caddy автоматически получит Let's Encrypt сертификат на 80/443 портах.
|
||||
# Запрос к app идёт по HTTPS на внутренний порт 3443 (самоподписанный серт
|
||||
# приложения, поэтому tls_insecure_skip_verify).
|
||||
|
||||
kiberone.dns.army {
|
||||
reverse_proxy app:3443 {
|
||||
transport http {
|
||||
tls_insecure_skip_verify
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Внутренний HTTP-листенер для Cloudflare Quick Tunnel (без редиректов).
|
||||
:8080 {
|
||||
reverse_proxy app:3443 {
|
||||
transport http {
|
||||
tls_insecure_skip_verify
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
# Пример конфигурации Caddy для публичного развёртывания WhatIDo.
|
||||
#
|
||||
# Порядок включения:
|
||||
# 1. Скопируйте этот файл в ./Caddyfile (cp Caddyfile.example Caddyfile)
|
||||
# 2. Замените yourdomain.example на реальный домен/WWW, указывающий на сервер
|
||||
# 3. В docker-compose.yml расскомментируйте сервис caddy (и тома caddy_data/caddy_config)
|
||||
# и переведите блок ports сервиса app в expose (см. комментарии в compose)
|
||||
# 4. docker compose up -d --build
|
||||
#
|
||||
# Caddy автоматически получит Let's Encrypt сертификат на 80/443 портах.
|
||||
# Запрос к app идёт по HTTPS на внутренний порт 3443 (самоподписанный серт
|
||||
# приложения, поэтому tls_insecure_skip_verify).
|
||||
|
||||
yourdomain.example {
|
||||
reverse_proxy app:3443 {
|
||||
transport http {
|
||||
tls_insecure_skip_verify
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,33 @@
|
||||
FROM node:20-alpine
|
||||
FROM node:22-alpine
|
||||
ENV TZ=Europe/Moscow
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm install --omit=dev
|
||||
COPY . .
|
||||
RUN mkdir -p uploads certs
|
||||
RUN apk add --no-cache openssl && \
|
||||
ARG GIT_COMMIT=""
|
||||
ARG GIT_COMMIT_DATE=""
|
||||
RUN node -e "require('fs').writeFileSync('public/version.json', JSON.stringify({ full: process.env.GIT_COMMIT || '', short: (process.env.GIT_COMMIT || '').slice(0, 7), date: process.env.GIT_COMMIT_DATE || '' }, null, 2))"
|
||||
RUN set -e; \
|
||||
V="v$(cut -d. -f1-2 /etc/alpine-release)"; \
|
||||
try_repo() { \
|
||||
printf "%s/%s/main\n%s/%s/community\n" "$1" "$V" "$1" "$V" > /etc/apk/repositories; \
|
||||
echo "Trying apk mirror: $1"; \
|
||||
timeout 45 apk add --no-cache --timeout 20 openssl; \
|
||||
}; \
|
||||
if ! try_repo "https://dl-cdn.alpinelinux.org/alpine" && \
|
||||
! try_repo "https://mirrors.edge.kernel.org/alpine" && \
|
||||
! try_repo "http://mirrors.edge.kernel.org/alpine"; then \
|
||||
echo "apk failed on all mirrors (HTTPS and HTTP). Diagnostics:"; \
|
||||
echo "-- date:"; date; \
|
||||
echo "-- resolv.conf:"; cat /etc/resolv.conf; \
|
||||
echo "-- proxy env:"; env | grep -i proxy || echo none; \
|
||||
wget -qO- --timeout=15 https://mirrors.edge.kernel.org/alpine/$V/main/x86_64/APKINDEX.tar.gz >/dev/null && echo "https probe: ok" || echo "https probe: fail"; \
|
||||
wget -qO- --timeout=15 http://mirrors.edge.kernel.org/alpine/$V/main/x86_64/APKINDEX.tar.gz >/dev/null && echo "http probe: ok" || echo "http probe: fail"; \
|
||||
exit 1; \
|
||||
fi && \
|
||||
openssl req -x509 -nodes -newkey rsa:2048 -days 3650 \
|
||||
-keyout certs/key.pem -out certs/cert.pem \
|
||||
-subj "/CN=whatido.local" -addext "subjectAltName=DNS:localhost,IP:127.0.0.1"
|
||||
EXPOSE 3000 3443
|
||||
EXPOSE 3003 3443
|
||||
CMD ["node", "server.js"]
|
||||
@@ -0,0 +1,12 @@
|
||||
# Сборка контейнера cloudflared с опциональным WireGuard.
|
||||
# Официальный образ cloudflare/cloudflared — distroless (без оболочки/apk), поэтому:
|
||||
# 1) берём из него статичный бинарник cloudflared;
|
||||
# 2) саму сборку делаем на Alpine, где ставим wireguard-tools.
|
||||
# Если в wg/wg0.conf нет конфига — VPN не поднимается, туннель запускается как обычно.
|
||||
FROM cloudflare/cloudflared:latest AS cf
|
||||
|
||||
FROM alpine:3.20
|
||||
RUN apk add --no-cache wireguard-tools iproute2 iptables ip6tables
|
||||
COPY --from=cf /usr/local/bin/cloudflared /usr/bin/cloudflared
|
||||
COPY start-cloudflared.sh /start-cloudflared.sh
|
||||
ENTRYPOINT ["/bin/sh", "/start-cloudflared.sh"]
|
||||
@@ -0,0 +1,11 @@
|
||||
FROM alpine:3.20
|
||||
|
||||
RUN apk add --no-cache curl bash
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY update-dynv6.sh /app/update-dynv6.sh
|
||||
RUN chmod +x /app/update-dynv6.sh
|
||||
|
||||
# Run once on startup, then every 5 minutes in a loop
|
||||
CMD /app/update-dynv6.sh && while true; do sleep 300; /app/update-dynv6.sh; done
|
||||
@@ -0,0 +1,142 @@
|
||||
# План: Мультифилиальность и роли пользователей
|
||||
|
||||
## Проблема
|
||||
Сейчас приложение однофилиальное и одно-user-овое. Нужно поддержать:
|
||||
- 5-20 филиалов
|
||||
- Несколько тьюторов/админов
|
||||
- Филиал студента определяется автоматически через его группу
|
||||
|
||||
---
|
||||
|
||||
## 1. База данных — схема
|
||||
|
||||
### Новые таблицы
|
||||
|
||||
```sql
|
||||
-- Филиалы
|
||||
CREATE TABLE branches (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL UNIQUE,
|
||||
created_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
-- Пользователи системы
|
||||
CREATE TABLE users (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(150) NOT NULL,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
role VARCHAR(20) NOT NULL DEFAULT 'tutor', -- 'admin' или 'tutor'
|
||||
branch_id INT REFERENCES branches(id), -- NULL для супер-админа
|
||||
created_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
```
|
||||
|
||||
### Изменения в существующих таблицах
|
||||
|
||||
| Таблица | Новое поле | Тип | Связь |
|
||||
|---------|-----------|-----|-------|
|
||||
| `groups` | `branch_id` | `INT` | `REFERENCES branches(id)` |
|
||||
| `students` | `branch_id` | `INT` | `REFERENCES branches(id)` |
|
||||
| `entries` | `branch_id` | `INT` | `REFERENCES branches(id)` |
|
||||
| `group_photos` | `branch_id` | `INT` | `REFERENCES branches(id)` |
|
||||
|
||||
### Логика определения филиала
|
||||
- Студент → в группе → группа привязана к филиалу → филиал определён
|
||||
- При отправке записи `branch_id` берётся из группы студента
|
||||
- Студенту не нужно выбирать филиал — он определяется автоматически
|
||||
|
||||
---
|
||||
|
||||
## 2. Аутентификация
|
||||
|
||||
- Вход по **имени + пароль** (без email)
|
||||
- Пароли хранятся в `bcrypt` хеше
|
||||
- Токен (JWT) выдаётся при логине, хранится в `sessionStorage`
|
||||
- `requireAdmin` заменяется на `requireAuth` + проверку роли
|
||||
|
||||
---
|
||||
|
||||
## 3. Роли
|
||||
|
||||
| Роль | Возможности |
|
||||
|------|-------------|
|
||||
| **admin** | Всё: управление пользователями, филиалами, настройками, бэкапами + все данные |
|
||||
| **tutor** | Управление записями, группами, студентами, файлами, ссылками — видит все группы |
|
||||
|
||||
---
|
||||
|
||||
## 4. Публичная форма
|
||||
|
||||
- Ссылка на форму одна (как сейчас)
|
||||
- Студент вводит имя → система находит студента → определяет группу → определяет филиал
|
||||
- Если студент новый (нет в базе) — варианты на обсуждение:
|
||||
- Не пускать (только зарегистрированные студенты)
|
||||
- Автоматически создать в группе "Не распределён"
|
||||
|
||||
---
|
||||
|
||||
## 5. Фронтенд — изменения
|
||||
|
||||
### Страница логина
|
||||
- Поле "Имя" + "Пароль" вместо одного пароля
|
||||
|
||||
### Админ-панель
|
||||
- В хедере/сайдбаре выпадающий список филиалов для фильтрации
|
||||
- Группы — отображение филиала у каждой группы
|
||||
- Студенты — отображение филиала у каждого студента
|
||||
- Журнал — фильтр по филиалу
|
||||
- Настройки — управление пользователями и филиалами (только для admin)
|
||||
|
||||
---
|
||||
|
||||
## 6. API — новые эндпоинты
|
||||
|
||||
| Метод | Путь | Описание |
|
||||
|-------|------|----------|
|
||||
| `POST` | `/api/auth/login` | Вход (имя + пароль → токен) |
|
||||
| `GET` | `/api/branches` | Список филиалов |
|
||||
| `POST` | `/api/branches` | Создать филиал (admin) |
|
||||
| `DELETE` | `/api/branches/:id` | Удалить филиал (admin) |
|
||||
| `GET` | `/api/users` | Список пользователей (admin) |
|
||||
| `POST` | `/api/users` | Создать пользователя (admin) |
|
||||
| `DELETE` | `/api/users/:id` | Удалить пользователя (admin) |
|
||||
|
||||
---
|
||||
|
||||
## 7. Миграция данных
|
||||
|
||||
- Существующие группы/студенты/записи автоматически попадут в филиал "Основной" (или первый созданный)
|
||||
- `ADMIN_PASSWORD` из `.env` конвертируется в запись `users` с ролью `admin`
|
||||
|
||||
---
|
||||
|
||||
## 8. Структура файлов (рефакторинг)
|
||||
|
||||
```
|
||||
server.js → рефакторинг на модули:
|
||||
├── routes/
|
||||
│ ├── auth.js (логин/аутентификация)
|
||||
│ ├── branches.js (CRUD филиалов)
|
||||
│ ├── users.js (CRUD пользователей)
|
||||
│ ├── groups.js (существующие + branch_id)
|
||||
│ ├── students.js (существующие + branch_id)
|
||||
│ ├── entries.js (существующие + branch_id)
|
||||
│ └── ...
|
||||
├── middleware/
|
||||
│ ├── auth.js (requireAuth, requireRole)
|
||||
│ └── branch.js (branch scoping)
|
||||
└── db.js (подключение к БД)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Порядок реализации
|
||||
|
||||
1. **Миграция БД** — создать таблицы `branches`, `users`, добавить `branch_id` к существующим
|
||||
2. **Миграция данных** — создать филиал "Основной", перенести существующие данные
|
||||
3. **Бэкенд: аутентификация** — `auth.js` (логин, JWT, middleware)
|
||||
4. **Бэкенд: CRUD филиалов и пользователей** — новые роуты
|
||||
5. **Бэкенд: branch scoping** — добавить `branch_id` ко всем существующим запросам
|
||||
6. **Фронтенд: логин** — новая страница логина
|
||||
7. **Фронтенд: admin panel** — добавить фильтр филиалов, страницы управления
|
||||
8. **Тестирование** — проверить все сценарии
|
||||
@@ -0,0 +1,553 @@
|
||||
# План: Улучшение лиц на фотографиях (Real-ESRGAN + GFPGAN/CodeFormer) с автовыбором GPU/CPU
|
||||
|
||||
Статус: **план, реализация не начата**. Документ описывает целевую архитектуру, изменения по файлам,
|
||||
порядок внедрения и критерии приёмки. Реализацию начинать после знакомства с этим файлом.
|
||||
|
||||
---
|
||||
|
||||
## 1. Постановка задачи
|
||||
|
||||
Сейчас система улучшает фотографии одной моделью `RealESRGAN_x2plus` (x2, CPU, `half=False`,
|
||||
`device='cpu'`, захардкожено в `photo-ai/app.py:44`). Модель хорошо восстанавливает текстуры
|
||||
(одежда, фон, бумага), но **лица** при апскейле часто получают артефакты, «пластиковую» кожу и
|
||||
искажённые черты, потому что у `RealESRGAN_x2plus` нет приора на структуру лица.
|
||||
|
||||
Нужно:
|
||||
|
||||
1. Добавить модели восстановления **лиц** — GFPGAN v1.4 и/или CodeFormer (оба — face restoration
|
||||
с prior-сетью, работают в связке с `RealESRGANer` как `bg_upsampler`).
|
||||
2. Дать пользователю **выбор модели** для обработки: универсальная x2, face-модель, комбинация
|
||||
(фон + лица), быстрая VGG-модель `realesr-general-x4v3`.
|
||||
3. Поддержать работу **на GPU и на CPU** с **автоматическим выбором** устройства: есть рабочий
|
||||
CUDA — используем GPU, нет — молча и без падений уходим на CPU.
|
||||
4. Не допустить простоя GPU-контейнера: пока модели грузятся — сервис отвечает `503`, воркер
|
||||
ждёт, а не теряет задания.
|
||||
5. Не сломать существующие контракты: `photo_jobs`, `/enhance`, `PHOTO_AI_URL`, автономную работу
|
||||
без `photo-ai` (`PHOTO_AI_URL` пустой → кнопка «ИИ» недоступна).
|
||||
|
||||
### Ограничения окружения (проверено на текущем хосте)
|
||||
|
||||
| Параметр | Значение | Следствие |
|
||||
|----------|----------|-----------|
|
||||
| GPU | `NVIDIA GeForce RTX 3050 ...`, 4096 MiB VRAM, драйвер 615.71.09, CUDA UMD 13.4 | GPU-режим реален, но 4 ГБ VRAM — тесно, нужен `tile` и запас |
|
||||
| `/dev/dri` | `card1`, `card2`, `renderD128`, `renderD129` | iGPU тоже виден, но torch будет использовать CUDA |
|
||||
| `nvidia-ctk` | **не установлен** | нужен NVIDIA Container Toolkit на хосте, иначе `--gpus` не заработает |
|
||||
| Runtime Docker | только `runc` (нет `nvidia`) | требуется установка toolkit + `docker compose` override |
|
||||
| CPU | Intel i5-12500H, 16 потоков | CPU-режим приемлем как fallback, но медленный |
|
||||
| RAM | 15 GiB (занято ~9 ГБ), swap 31 GiB | CPU-модели + torch требуют ~2–3 ГБ; следить за OOM |
|
||||
| Диск | 59 ГБ свободно на `/home` | веса: x2plus 64 МБ + GFPGAN 333 МБ + CodeFormer 360 МБ + wdn 64 МБ — ок |
|
||||
| Docker / Compose | 29.8.1 / 5.5.1 | поддерживают `deploy.resources.reservations.devices` (Compose v5) |
|
||||
|
||||
Важно: **GPU-режим не должен быть обязательным условием запуска**. Если toolkit не установлен,
|
||||
compose-файл с `devices` не поднимется — поэтому GPU выносим в отдельный override-файл.
|
||||
|
||||
---
|
||||
|
||||
## 2. Что уже есть (точки интеграции)
|
||||
|
||||
| Место | Что делает | Что меняем |
|
||||
|-------|-----------|-----------|
|
||||
| `photo-ai/app.py` | FastAPI, `POST /enhance` (`image`, `scale`), одна модель, `device='cpu'` | Расширяем до реестра моделей + автовыбор device + `POST /enhance` с `model`/`face` |
|
||||
| `photo-ai/Dockerfile` | `python:3.10-slim`, torch CPU-only, правка `basicsr/data/degradations.py` | Разделяем на CPU-базу и GPU-базу (`ARG`), добавляем `gfpgan`, `facexlib` |
|
||||
| `docker-compose.yml` (`photo-ai`) | build `./photo-ai`, `MODEL_PATH`, `MAX_INPUT_PIXELS`, том `photo-ai-models` | Добавляем env `PHOTO_AI_DEVICE`, `PHOTO_AI_FACE_MODEL`, `PHOTO_AI_TILE`, healthcheck |
|
||||
| `worker.js` → `createPhotoEnhanceWorker` | `runAiEnhance(srcKey)` шлёт `image` + `scale=2`, ждёт `image/jpeg` (таймаут 300 с, 3 попытки) | Передаём `model`/`face`/`strength` из `job.params`, разбираем JSON-ответ, 503 ждёт без траты попыток |
|
||||
| `server.js` → `POST /api/entries/:id/photo/enhance-ai` | `INSERT INTO photo_jobs (entry_id, action, status, params) VALUES ($1,'ai','pending',NULL)` | Принимаем `model`/`face`/`face_model`/`strength`, валидируем, пишем в `params` (колонка уже есть) |
|
||||
| `server.js` → `GET /api/photo-jobs/status` | `ai_configured`, `ai_url`, `worker`, `counts` | Добавляем `service.device`, `service.models`, `service.face_models`, `service.ready` |
|
||||
| `server.js` → `PHOTO_JOB_ACTIONS` | белый список действий, `action VARCHAR(20)` | Новые действия `ai_face`, `ai_upscale` в двух местах (валидация + restore) |
|
||||
| `public/js/journal.js` (кнопка «🤖 ИИ») | Ставит задание `action='ai'` | Выбор модели: универсальная / быстрая / лица / обе |
|
||||
| `public/js/worker.js` + `worker.html` | Таблица заданий, `PHOTO_ACTION_LABELS`, сравнение «Было/Стало» | Новые метки, показ модели/устройства/времени обработки |
|
||||
| `settings.html` / `settings.js` | `photo_enhance_engine` (`auto`/`server`/`client`) | Новые настройки: `photo_ai_face_mode`, `photo_ai_device_pref` |
|
||||
|
||||
Текущее поведение, которое **сохраняем байт-в-байт**:
|
||||
`/enhance` с `scale=2` без указания модели → та же картинка, что и сегодня (x2plus, JPEG q92).
|
||||
Это нужно, чтобы старые `photo_jobs` со `params = NULL` и все закешированные превью не изменились.
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 3. Модели: что именно добавляем
|
||||
|
||||
### 3.1 Каталог моделей
|
||||
|
||||
| Ключ | Класс | Веса | Размер | Назначение |
|
||||
|------|-------|------|--------|-----------|
|
||||
| `x2plus` | `RRDBNet(scale=2)` | `RealESRGAN_x2plus.pth` | 64 МБ | **текущая**, универсальный апскейл x2, дефолт |
|
||||
| `general-x4v3` | `SRVGGNetCompact(upscale=4)` | `realesr-general-x4v3.pth` | 4.7 МБ | быстрый апскейл x4 + денойз через DNI (`realesr-general-wdn-x4v3.pth`) |
|
||||
| `animevideo-v3` | `SRVGGNetCompact(upscale=4)` | `realesr-animevideov3.pth` | 2.4 МБ | быстрый x4, для скриншотов/иллюстраций |
|
||||
| `gfpgan` | `GFPGANer(arch='clean', channel_multiplier=2)` | `GFPGANv1.4.pth` | 333 МБ | **восстановление лиц**, `bg_upsampler` = выбранный ESRGAN |
|
||||
| `codeformer` | `CodeFormer` | `codeformer.pth` + `detection_Resnet50_Final.pth` + `parsing_parsenet.pth` | 360 МБ + ~110 МБ | **восстановление лиц**, регулируемая сила `fidelity_weight` (`-w`), лучше на сильных искажениях |
|
||||
|
||||
Рекомендация: **GFPGAN v1.4 как основная face-модель** (меньше весов, стабильнее на 4 ГБ VRAM,
|
||||
это модель по умолчанию в апстриме `inference_realesrgan.py` через `--face_enhance`), CodeFormer —
|
||||
опционально, как альтернатива с регулируемой силой. На CPU CodeFormer практически
|
||||
неработоспособен по времени (facexlib + parsing) — оставляем его «только GPU, если включён явно».
|
||||
|
||||
### 3.2 Режимы обработки (`face_mode`)
|
||||
|
||||
| `face_mode` | Что происходит | Модель | Когда использовать |
|
||||
|-------------|----------------|--------|--------------------|
|
||||
| `off` | только апскейл фона, лицо не трогается | ESRGAN | текущее поведение, фон/текстуры |
|
||||
| `face` | апскейл фона + **только лица** вставлены восстановленными (paste-back) | ESRGAN + GFPGAN/CodeFormer | портреты, крупные лица |
|
||||
| `all` | как `face`, плюс мягкий денойз фона | ESRGAN(+wdn) + GFPGAN | зашумлённые снимки с веб-камеры 640×480 |
|
||||
|
||||
Технически GFPGAN работает так: детектирует лица (facexlib/RetinaFace), кропает → восстанавливает
|
||||
→ paste-back в альбом. Если лиц не найдено — возвращает чистый результат `bg_upsampler`: это
|
||||
безопасный no-op и не должно считаться ошибкой.
|
||||
|
||||
### 3.3 Почему face-модель нельзя ставить «всегда»
|
||||
|
||||
1. **Скорость.** Детекция + face-restore + paste-back даёт +40…150 % ко времени. На CPU это
|
||||
разница между ~40 с и ~90 с на фото 640×480.
|
||||
2. **Гарантий нет.** GFPGAN «дорисовывает» лица по приору: на сильно замытых, боковых или
|
||||
закрытых лицах он может сделать человека похожим на другого. Для журнала посещаемости ошибка
|
||||
идентификации недопустима, поэтому face-режим — **явный выбор пользователя**, а не молчаливый
|
||||
дефолт.
|
||||
3. **VRAM.** GFPGAN + x2plus одновременно держат два графа на GPU; на 4 ГБ нужен `tile <= 256`.
|
||||
|
||||
### 3.4 Точные URL весов (скачиваются при первом запуске)
|
||||
|
||||
```
|
||||
https://github.com/xinntao/Real-ESRGAN/releases/download/v0.2.1/RealESRGAN_x2plus.pth
|
||||
https://github.com/xinntao/Real-ESRGAN/releases/download/v0.2.5.0/realesr-general-x4v3.pth
|
||||
https://github.com/xinntao/Real-ESRGAN/releases/download/v0.2.5.0/realesr-general-wdn-x4v3.pth
|
||||
https://github.com/xinntao/Real-ESRGAN/releases/download/v0.2.5.0/realesr-animevideov3.pth
|
||||
https://github.com/TencentARC/GFPGAN/releases/download/v1.3.0/GFPGANv1.4.pth
|
||||
https://github.com/sczhou/CodeFormer/releases/download/v0.1.0/codeformer.pth
|
||||
```
|
||||
|
||||
Каждый файл кладём в `/models/weights/<name>.pth` (том `photo-ai-models`), загрузка через
|
||||
`.tmp` + `os.replace` и проверку минимального размера — иначе оборванная закачка оставит «битые»
|
||||
веса, и сервис будет падать при загрузке.
|
||||
|
||||
---
|
||||
|
||||
## 4. Автоматический выбор GPU/CPU
|
||||
|
||||
### 4.1 Приоритет выбора (в `app.py` при старте)
|
||||
|
||||
```
|
||||
1. PHOTO_AI_DEVICE=cuda|cpu|auto (env, дефолт auto)
|
||||
2. если auto:
|
||||
a. torch.cuda.is_available() and torch.cuda.device_count() > 0
|
||||
-> device = 'cuda:0', half = True (fp16 быстрее и экономит VRAM)
|
||||
b. иначе если torch.backends.mps.is_available()
|
||||
-> device = 'mps', half = False (Apple Silicon, на случай dev-машины)
|
||||
c. иначе -> device = 'cpu', half = False
|
||||
3. если явно cuda, но cuda недоступна -> НЕ падать: WARN в лог и уйти на cpu
|
||||
(контейнер обязан подниматься даже без GPU — требование отказоустойчивости)
|
||||
4. явно cpu — всегда cpu, даже если GPU есть (для отладки и воспроизводимости)
|
||||
```
|
||||
|
||||
### 4.2 Прогрев, ленивая загрузка и деградация
|
||||
|
||||
- `load_model()` вызывается в `startup` (сейчас через `asyncio.to_thread`), модели грузятся
|
||||
**лениво по требованию** и кешируются в `MODELS` dict под `threading.Lock`. Первый запрос к
|
||||
новой модели оплачивает её загрузку (x2plus 64 МБ грузится быстрее, чем GFPGAN 333 МБ).
|
||||
- Пока нужная модель не готова, `/enhance` возвращает `503` + `Retry-After: 5`, а `/health`
|
||||
отдаёт `{"ok": true, "ready": false, "loading": ["gfpgan"]}`. Воркер на `503` **не** тратит
|
||||
попытку из `PHOTO_MAX_ATTEMPTS`: он ждёт и повторяет (см. §5.4).
|
||||
- `CUDA out of memory` при инференсе — ловим `RuntimeError`, уменьшаем `tile` вдвое
|
||||
(256 → 128 → 64) и повторяем **один раз**; если снова OOM — переключаемся на `cpu`,
|
||||
инвалидируем модель и повторяем. Если и на CPU не получилось — `500` с понятным текстом.
|
||||
- Прогрев (warm-up) на синтетическом шуме 64×64 сразу после загрузки: убирает «первый запрос в
|
||||
3 раза дольше» и немедленно выявляет OOM.
|
||||
|
||||
### 4.3 Что показывать оператору
|
||||
|
||||
`GET /health` (расширяем, обратно совместимо: поле `ok` остаётся):
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"ready": true,
|
||||
"device": "cuda:0",
|
||||
"device_name": "NVIDIA GeForce RTX 3050 ...",
|
||||
"half": true,
|
||||
"tile": 256,
|
||||
"driver": "615.71.09",
|
||||
"cuda": "13.4",
|
||||
"vram_total_mb": 4096,
|
||||
"vram_free_mb": 3780,
|
||||
"models": ["x2plus", "general-x4v3", "animevideo-v3"],
|
||||
"face_models": ["gfpgan", "codeformer"],
|
||||
"loaded": ["x2plus", "gfpgan"],
|
||||
"max_pixels": 4000000
|
||||
}
|
||||
```
|
||||
|
||||
`server.js` проксирует это в `GET /api/photo-jobs/status` → `service` и в блок «Статус стека»
|
||||
(`getStackInfo()`, `public/settings.html`), чтобы было видно, на чём реально считает фото-ИИ.
|
||||
|
||||
### 4.4 Docker: как отдать GPU контейнеру
|
||||
|
||||
Два обязательных шага на хосте (сейчас **не выполнены** — `nvidia-ctk` отсутствует):
|
||||
|
||||
```bash
|
||||
# 1. NVIDIA Container Toolkit
|
||||
curl -fsSL https://nvidia.github.io/libnvidia-container/gpgkey | \
|
||||
sudo gpg --dearmor -o /usr/share/keyrings/nvidia-container-toolkit-keyring.gpg
|
||||
curl -s -L https://nvidia.github.io/libnvidia-container/stable/deb/nvidia-container-toolkit.list | \
|
||||
sed 's#deb https://#deb [signed-by=/usr/share/keyrings/nvidia-container-toolkit-keyring.gpg] https://#g' | \
|
||||
sudo tee /etc/apt/sources.list.d/nvidia-container-toolkit.list
|
||||
sudo apt-get update && sudo apt-get install -y nvidia-container-toolkit
|
||||
sudo nvidia-ctk runtime configure --runtime=docker && sudo systemctl restart docker
|
||||
|
||||
# 2. Проверка
|
||||
docker run --rm --gpus all nvidia/cuda:12.4.1-base-ubuntu22.04 nvidia-smi
|
||||
```
|
||||
|
||||
Compose делим на два файла, чтобы GPU был **опцией, а не требованием** (по образцу
|
||||
существующего `docker-compose.minio.yml`):
|
||||
|
||||
- `docker-compose.yml` — `photo-ai` без GPU (CPU-образ, как сейчас). Стек поднимается на любой
|
||||
машине.
|
||||
- `docker-compose.gpu.yml` (новый) — override:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
photo-ai:
|
||||
build:
|
||||
context: ./photo-ai
|
||||
args:
|
||||
TORCH_VARIANT: cu124
|
||||
environment:
|
||||
PHOTO_AI_DEVICE: cuda
|
||||
deploy:
|
||||
resources:
|
||||
reservations:
|
||||
devices:
|
||||
- driver: nvidia
|
||||
count: 1
|
||||
capabilities: [gpu]
|
||||
```
|
||||
|
||||
Запуск GPU-режима:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.yml -f docker-compose.gpu.yml up -d --build photo-ai
|
||||
```
|
||||
|
||||
Если `photo-ai` уже запущен в CPU-режиме — сначала `docker compose stop photo-ai`, затем команда
|
||||
выше (иначе Compose ругается на конфликт конфигурации сервиса).
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 5. Изменения по файлам
|
||||
|
||||
### 5.1 `photo-ai/app.py` (переписываем, ~250–300 строк)
|
||||
|
||||
Новая структура:
|
||||
|
||||
```
|
||||
ENV: PHOTO_AI_DEVICE, PHOTO_AI_FACE_MODEL, PHOTO_AI_TILE, PHOTO_AI_MAX_PIXELS,
|
||||
PHOTO_AI_MODELS_DIR, PHOTO_AI_LOAD_ALL, PHOTO_AI_WARMUP, PHOTO_AI_JPEG_QUALITY
|
||||
pick_device() -> (device, half, device_name) # §4.1
|
||||
MODEL_REGISTRY = { 'x2plus': {...}, 'general-x4v3': {...}, 'animevideo-v3': {...} }
|
||||
FACE_REGISTRY = { 'gfpgan': {...}, 'codeformer': {...} }
|
||||
class ModelPool: # Lock, dict, lazy load, OOM-ретрай
|
||||
get_esrgan(name) -> RealESRGANer
|
||||
get_face(name, bg_upsampler) -> GFPGANer | CodeFormer
|
||||
encode_jpeg(out, quality)
|
||||
@app.get('/health') # расширенный контракт §4.3
|
||||
@app.get('/models') # список моделей + устройство
|
||||
@app.post('/enhance') # image, scale, model, face, face_model, strength
|
||||
```
|
||||
|
||||
Ключевые детали реализации:
|
||||
|
||||
- `POST /enhance` принимает те же `image` и `scale`, плюс новые необязательные поля:
|
||||
`model` (дефолт `x2plus`), `face` (`off`|`face`|`all`, дефолт `off`),
|
||||
`face_model` (`gfpgan`|`codeformer`), `strength` (0.0–1.0, только CodeFormer, дефолт 0.7),
|
||||
`jpeg_quality` (70–100, дефолт 92). Неизвестная модель → `400` со списком допустимых.
|
||||
- Полная обратная совместимость: без `model`/`face` → путь «x2plus, JPEG q92», как сегодня.
|
||||
- `face != off` → `face_enhancer.enhance(img, has_aligned=False, only_center_face=False,
|
||||
paste_back=True)`; в ответ добавляем `faces_found`.
|
||||
- **Два формата ответа**: JSON (`{ok, image_base64, model, face, face_model, faces_found,
|
||||
device, elapsed_ms, warnings}`) при `Accept: application/json` — новый путь для воркера;
|
||||
сырой `image/jpeg` без заголовка — текущее поведение (совместимость с ручными `curl`).
|
||||
- `upsampler.enhance()` уже делает тайлинг сам — `tile`/`tile_pad=10` остаются, `tile` берём из env.
|
||||
- `half=True` только при CUDA; `dni_weight` — только для `general-x4v3` при `denoise_strength != 1`.
|
||||
- Расширения определяем по имени файла, а не по `UploadFile.content_type` (браузер и `FormData`
|
||||
в `worker.js` шлют `image/jpeg` для любого исходника — сейчас это уже так, сохраняем).
|
||||
|
||||
### 5.2 `photo-ai/Dockerfile`
|
||||
|
||||
```dockerfile
|
||||
FROM python:3.10-slim
|
||||
ARG TORCH_VARIANT=cpu # cpu | cu124
|
||||
ARG TORCH_INDEX=https://download.pytorch.org/whl/${TORCH_VARIANT}
|
||||
```
|
||||
|
||||
- `TORCH_VARIANT=cpu` → `torch torchvision --index-url .../whl/cpu` (как сейчас);
|
||||
`cu124` → тот же `pip` с `.../whl/cu124`. Образ один, вариант — аргумент сборки.
|
||||
- Добавить `gfpgan==1.3.8` и `facexlib==0.3.0` (CodeFormer — из TencentARC, пакет/вендоринг).
|
||||
**Проверить доступность пакетов в зеркале pip заранее** — это риск сборки, если недоступны,
|
||||
вендорим исходники в `photo-ai/vendor/` и копируем каталог в образ.
|
||||
- `libgl1 libglib2.0-0` уже есть — facexlib их требует.
|
||||
- Патч `basicsr/data/degradations.py` (`torchvision.transforms.functional_tensor` → `functional`)
|
||||
**сохранить** — без него basicsr падает на torch >= 2.0.
|
||||
- `ENV PHOTO_AI_MODELS_DIR=/models`; старый `MODEL_PATH` продолжаем читать как алиас, чтобы
|
||||
существующий `.env`/том не сломался.
|
||||
- Размер образа: CPU ~1.2 ГБ → ~2.5 ГБ; cu124 ~6–7 ГБ. Учитывать при `--build`.
|
||||
- Тома: `photo-ai-models` уже есть — все веса в `/models/weights/*.pth`.
|
||||
|
||||
### 5.3 `docker-compose.yml`
|
||||
|
||||
```yaml
|
||||
photo-ai:
|
||||
environment:
|
||||
PHOTO_AI_DEVICE: ${PHOTO_AI_DEVICE:-auto}
|
||||
PHOTO_AI_FACE_MODEL: ${PHOTO_AI_FACE_MODEL:-gfpgan}
|
||||
PHOTO_AI_TILE: ${PHOTO_AI_TILE:-256}
|
||||
PHOTO_AI_MAX_PIXELS: ${PHOTO_AI_MAX_PIXELS:-4000000}
|
||||
PHOTO_AI_LOAD_ALL: ${PHOTO_AI_LOAD_ALL:-0}
|
||||
PHOTO_AI_JPEG_QUALITY: ${PHOTO_AI_JPEG_QUALITY:-92}
|
||||
MODEL_PATH: /models/RealESRGAN_x2plus.pth
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "python -c \"import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8080/health', timeout=3).status==200 else 1)\""]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 300s
|
||||
```
|
||||
|
||||
`start_period: 300s` — загрузка x2plus + GFPGAN на CPU занимает минуты; короткий период даст
|
||||
`unhealthy` на старте. `PHOTO_AI_LOAD_ALL=1` предзагружает все модели (для GPU-сервера с запасом
|
||||
RAM), по умолчанию `0` — ленивая загрузка.
|
||||
|
||||
### 5.4 `worker.js` (функция `createPhotoEnhanceWorker`)
|
||||
|
||||
- `runAiEnhance(srcKey, params)`:
|
||||
- читает `params.model`, `params.face`, `params.face_model`, `params.strength`;
|
||||
- отправляет их вместе с `image` и `scale`; ставит `Accept: application/json`, разбирает
|
||||
JSON-ответ, декодирует base64 в буфер;
|
||||
- если сервис вернул `image/jpeg` (старая версия photo-ai) — работает как сейчас, без ошибок;
|
||||
- `503` (`Retry-After`) обрабатывает отдельно: `sleep` 10 с, `return false` — **без** инкремента
|
||||
`attempts` (мягкий повтор, задание не сгорает);
|
||||
- `AbortError` от `AbortSignal.timeout(PHOTO_AI_TIMEOUT_MS)` → сообщение
|
||||
«ИИ-сервис не ответил за N с» (уже ошибка с попытками);
|
||||
- в аудит пишет модель/устройство/время: `logAudit(null, 'photo.job.preview', { ..., model,
|
||||
face, device, elapsed_ms })`.
|
||||
- `processOne`: `job.action === 'ai' || job.action === 'ai_face'` → AI-путь; `enhance` → sharp.
|
||||
Если `params` пусты, `action='ai_face'` даёт дефолт `face='face'`.
|
||||
- `CONFIG` воркера дополняем `face_model`, `default_model`, `face_timeout_ms` — они попадают в
|
||||
`GET /api/photo-jobs/status.worker.config`.
|
||||
- Для face-режима на CPU вводим отдельный, больший таймаут: `PHOTO_AI_FACE_TIMEOUT_MS`
|
||||
(дефолт 600 000) вместо 300 000.
|
||||
|
||||
|
||||
### 5.5 `server.js`
|
||||
|
||||
1. `POST /api/entries/:id/photo/enhance-ai` (строка 5114) — тело
|
||||
`{ model, face, face_model, strength }`:
|
||||
- `model` ∈ `['x2plus', 'general-x4v3', 'animevideo-v3']`;
|
||||
- `face` ∈ `['off', 'face', 'all']`;
|
||||
- `face_model` ∈ `['gfpgan', 'codeformer']`;
|
||||
- `strength` — число 0…1, только для CodeFormer;
|
||||
- `params = JSON.stringify({ model, face, face_model, strength })` в `photo_jobs.params`;
|
||||
- `action = face === 'off' ? 'ai' : 'ai_face'`;
|
||||
- `400` при невалидных значениях; пустое тело → сегодняшнее поведение (`params = NULL`,
|
||||
`action = 'ai'`). Валидация — инлайн-хелперами (`optInt`) и явными списками, как в
|
||||
`PUT /api/settings`.
|
||||
2. `PHOTO_JOB_ACTIONS` — добавить `'ai_face'`, `'ai_upscale'`. Схема не меняется
|
||||
(`action VARCHAR(20)`), но белый список используется в двух местах: `ensurePhotoJobsTable()`
|
||||
(~1238–1256) и при разборе restore-данных (~2259) — обновить **оба**.
|
||||
3. `GET /api/photo-jobs/status` (строка 5782) — прокинуть `service.device`,
|
||||
`service.device_name`, `service.ready`, `service.vram_free_mb`, `service.models`,
|
||||
`service.face_models` из `GET /health` photo-ai (таймаут 5 с, по образцу `aiHealthCheck()`).
|
||||
Запрос делать без падения: photo-ai недоступен → `service = { reachable: false }`.
|
||||
4. Новый `GET /api/photo-ai/health` (`requireAdmin`) — прямой прокси `/health` для оператора.
|
||||
В `getStackInfo()` добавить блок `photo_ai` (`device`, `device_name`, `vram_total_mb`,
|
||||
`models`) — рендерится в «Статус стека» на `public/settings.html`.
|
||||
5. Настройки — валидация рядом со строкой 1710:
|
||||
- `photo_ai_face_mode` ∈ `['off', 'face', 'all']`, дефолт `off`;
|
||||
- `photo_ai_device_pref` ∈ `['auto', 'cuda', 'cpu']`, дефолт `auto` — **только для UI и
|
||||
документации**: фактическое устройство определяет контейнер через env, UI показывает,
|
||||
совпадает ли желаемое с фактическим (если нет — подсветить).
|
||||
- дефолты дописать в `db/init.sql`, `db/migration.sql` (`INSERT ... ON CONFLICT DO NOTHING`)
|
||||
и в `keys`/`defaults` `/api/public-settings` (строка 1663–1664);
|
||||
- `photo_ai_enabled` там уже отдаётся (строка 1677) — сохранить.
|
||||
6. `worker.js` вызывается с новыми параметрами: в `createPhotoEnhanceWorker({...})` (строка 6045)
|
||||
добавить `faceTimeoutMs: PHOTO_AI_FACE_TIMEOUT_MS`, `defaultFaceModel: PHOTO_AI_FACE_MODEL`.
|
||||
7. Предупреждения сервиса (`warnings`, например «вход меньше 320×320») сохранять в аудит, чтобы
|
||||
оператор понимал, что face-режим не сработал не из-за ошибки.
|
||||
|
||||
### 5.6 Фронтенд
|
||||
|
||||
- `public/js/journal.js` (кнопка «🤖 ИИ», ~строка 620) — рядом dropdown: «Универсально (x2)»,
|
||||
«Быстро (x4)», «Лица (GFPGAN)», «Лица + фон». Значение уходит в
|
||||
`POST /api/entries/:id/photo/enhance-ai` телом `{ model, face, face_model }`. Дефолт — из
|
||||
`photo_ai_face_mode` (публичные настройки уже читаются на этой странице).
|
||||
- `public/js/worker.js` — `PHOTO_ACTION_LABELS` дополнить (`ai_face`: «ИИ + лица»,
|
||||
`ai_upscale`: «ИИ-апскейл»); в модалке сравнения «Было/Стало» показать `model`, `device`,
|
||||
`faces_found`, `elapsed_ms`. Новых колонок в БД не нужно — данные берём из `params` и аудита.
|
||||
- `public/settings.html` + `public/js/settings.js` — блок «Фото-ИИ»: селект face-режима, селект
|
||||
устройства («желаемое» + строка «фактическое»), read-only статус (`device_name`, VRAM, список
|
||||
моделей) из `/api/photo-jobs/status`. Новые поля добавить в `DIRTY_FIELDS` (строка 1
|
||||
`settings.js`) и в сборку payload (строка ~739).
|
||||
- `public/js/audit.js` — новые коды действий аудита прописать в словарь меток, иначе в UI будет
|
||||
сырой код (`photo.job.preview` уже есть, при добавлении `photo.job.ai` — добавить и там).
|
||||
|
||||
### 5.7 Документация и тесты
|
||||
|
||||
- `AGENTS.md` — раздел про photo-ai: реестр моделей, device-политика, новые env, GPU-override,
|
||||
обновлённый контракт `/enhance` и `/health`.
|
||||
- `README.md` — установка NVIDIA Container Toolkit, команда запуска с `docker-compose.gpu.yml`,
|
||||
проверка `GET /api/photo-jobs/status` → `service.device`.
|
||||
- `.env.example` — новые переменные с комментариями (см. §7).
|
||||
- `api.smoketest.js` — добавить проверки:
|
||||
- `POST /api/entries/:id/photo/enhance-ai` с `model: 'нет такой'` → `400`;
|
||||
- `face: 'face'` без `PHOTO_AI_URL` → `503`;
|
||||
- `GET /api/photo-jobs/status` содержит `service` (или `ai_configured: false`).
|
||||
Это соответствует правилу из `AGENTS.md`: контракт авторизации и API фиксируется в smoke-тесте
|
||||
в том же изменении.
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 6. Порядок внедрения (этапы)
|
||||
|
||||
Каждый этап заканчивается проверяемым результатом и не ломает предыдущий.
|
||||
|
||||
### Этап 0. Подготовка (0.5 дня)
|
||||
- Установить NVIDIA Container Toolkit, проверить `docker run --gpus all ... nvidia-smi`.
|
||||
- Зафиксировать текущее состояние `.env` (`PHOTO_AI_URL=` пусто или `http://photo-ai:8080`).
|
||||
- Сохранить эталон «до»: 3–5 фото (портрет, групповое, без лиц, зашумлённое), прогнать
|
||||
`curl -F image=@photo.jpg -F scale=2 http://localhost:8080/enhance`.
|
||||
|
||||
**Приёмка:** `nvidia-smi` внутри контейнера видит RTX 3050; эталонные JPEG сохранены для
|
||||
сравнения на следующих этапах.
|
||||
|
||||
### Этап 1. `app.py`: реестр моделей + автовыбор device (1–1.5 дня)
|
||||
- `pick_device()`, `ModelPool`, расширенный `/health`, ленивая загрузка x2plus, warm-up.
|
||||
- Старый `/enhance` по поведению не меняется (проверить визуально и по размеру файла).
|
||||
|
||||
**Приёмка:** `/health` отдаёт `device`, `device_name`, `half`; `PHOTO_AI_DEVICE=cpu` при рабочей
|
||||
CUDA даёт `cpu`; `PHOTO_AI_DEVICE=cuda` без toolkit даёт `cpu` + WARN, сервис поднимается.
|
||||
|
||||
### Этап 2. Dockerfile + compose (0.5–1 день)
|
||||
- `ARG TORCH_VARIANT`, `docker-compose.gpu.yml`, healthcheck, новые env, алиас `MODEL_PATH`.
|
||||
|
||||
**Приёмка:** CPU- и GPU-сборка стартуют; `/api/photo-jobs/status` показывает
|
||||
`service.device = "cuda:0"` в GPU-режиме и `"cpu"` в CPU-режиме; при пустом `PHOTO_AI_URL`
|
||||
приложение полностью работает без photo-ai.
|
||||
|
||||
### Этап 3. GFPGAN — face-режим (1–2 дня)
|
||||
- `FACE_REGISTRY`, поля `face`/`face_model`/`strength` в `/enhance`, JSON-ответ, `faces_found`,
|
||||
OOM-ретрай и fallback на CPU.
|
||||
|
||||
**Приёмка:** на тестовом портрете 640×480 в режиме `face` лицо резче, `faces_found = 1`;
|
||||
на фото без лиц — `faces_found = 0` и результат не хуже, чем `x2plus`; искусственный OOM
|
||||
(`tile=1024` на 4 ГБ) деградирует до CPU без падения сервиса.
|
||||
|
||||
### Этап 4. Воркер и API (1 день)
|
||||
- `worker.js`: `params` → модель/face, JSON-разбор, `503`-ожидание без траты попыток, аудит.
|
||||
- `server.js`: валидация `params`, `action = 'ai_face'`, `service` в статусе, новые настройки.
|
||||
|
||||
**Приёмка:** задание через API с `face='face'` доходит до `done`, параметры сохранены в
|
||||
`photo_jobs.params`, в аудите — модель/устройство/время; остановка photo-ai на лету даёт задание,
|
||||
которое дообработается после возврата сервиса (без `error`).
|
||||
|
||||
### Этап 5. Фронтенд (1 день)
|
||||
- Выбор модели в журнале, статус устройства в настройках, новые метки в воркере.
|
||||
|
||||
**Приёмка:** из журнала доступны все 4 варианта; после постановки видно «В очереди», затем
|
||||
сравнение «Было/Стало»; в настройках показано фактическое устройство и VRAM.
|
||||
|
||||
### Этап 6. Docs + smoke (0.5 дня)
|
||||
- `AGENTS.md`, `README.md`, `.env.example`, `api.smoketest.js`.
|
||||
|
||||
**Приёмка:** `node api.smoketest.js` проходит; тесты валидации возвращают `400`; документация
|
||||
совпадает с кодом.
|
||||
|
||||
**Итого:** ~5–7 рабочих дней. Этапы 1–3 дают ценность уже без фронтенда (ручные вызовы `curl`).
|
||||
|
||||
---
|
||||
|
||||
## 7. Новые переменные окружения
|
||||
|
||||
```bash
|
||||
# === Фото-ИИ (Real-ESRGAN + восстановление лиц) ===
|
||||
# Адрес сервиса; пусто = фото-ИИ выключен (кнопка «ИИ» недоступна)
|
||||
PHOTO_AI_URL=
|
||||
# Максимум входных пикселей (даунскейл перед обработкой)
|
||||
PHOTO_AI_MAX_PIXELS=4000000
|
||||
# Устройство: auto | cuda | cpu. auto = CUDA, если доступна, иначе CPU
|
||||
PHOTO_AI_DEVICE=auto
|
||||
# Тайл для апскейла: меньше = меньше VRAM, но медленнее (256 на 4 ГБ, 512+ при 8 ГБ+)
|
||||
PHOTO_AI_TILE=256
|
||||
# Модель лиц: gfpgan | codeformer | none
|
||||
PHOTO_AI_FACE_MODEL=gfpgan
|
||||
# Предзагружать все модели при старте (1 — да, нужно больше RAM/VRAM)
|
||||
PHOTO_AI_LOAD_ALL=0
|
||||
# Режим лиц по умолчанию для UI: off | face | all
|
||||
PHOTO_AI_FACE_MODE=off
|
||||
# Качество JPEG результата (70–100)
|
||||
PHOTO_AI_JPEG_QUALITY=92
|
||||
# Отдельный таймаут для face-режима, мс (на CPU медленно)
|
||||
PHOTO_AI_FACE_TIMEOUT_MS=600000
|
||||
```
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 8. Риски и как их закрываем
|
||||
|
||||
| Риск | Вероятность | Митигация |
|
||||
|------|-------------|-----------|
|
||||
| `nvidia-container-toolkit` не установлен / политика хоста запрещает | средняя | GPU — отдельный override-файл; CPU-путь остаётся дефолтом и полностью рабочим; в `/health` видно фактическое устройство |
|
||||
| 4 ГБ VRAM не хватает для GFPGAN + x2plus | высокая | `tile=256` по умолчанию, авто-снижение до 128/64 при OOM, `half=True` только на CUDA, при повторном OOM — fallback на CPU |
|
||||
| GFPGAN «портит» лица (артефакты идентичности) | средняя | face-режим только по явному выбору; результат попадает в `photo_jobs.after_path` и **не применяется автоматически** (нужно нажать «Применить»), история и «Вернуть оригинал» сохраняются |
|
||||
| Долгая загрузка весов (333 МБ) на первом запросе | высокая | ленивая загрузка + `503`/`Retry-After` вместо ошибки; `PHOTO_AI_LOAD_ALL=1` для прогрева; том `photo-ai-models` сохраняет веса между перезапусками; `start_period: 300s` в healthcheck |
|
||||
| Сборка ломается на `pip install gfpgan/facexlib` (пакета нет в зеркале) | средняя | **проверить доступность пакетов до начала работ**; при отсутствии — вендорить исходники в `photo-ai/vendor/` и копировать каталог в образ |
|
||||
| Рост образа до ~6–7 ГБ (cu124) | средняя | CPU-образ по умолчанию, GPU-образ собирается отдельно; на `/home` свободно 59 ГБ |
|
||||
| CPU-инференс с GFPGAN медленнее таймаута воркера | средняя | отдельный `PHOTO_AI_FACE_TIMEOUT_MS` (600 с) для face-режима; в UI предупреждение «на CPU медленно»; для слабых машин — `off` |
|
||||
| Регресс текущего `/enhance` | низкая | контракт по умолчанию не меняется; эталонное сравнение на этапах 1–2; старые `photo_jobs.params = NULL` продолжают работать |
|
||||
| Нехватка RAM (15 ГБ, занято ~9 ГБ) при загрузке моделей на CPU | средняя | `PHOTO_AI_LOAD_ALL=0`, кеш моделей с вытеснением (LRU, максимум 2), контроль через `docker stats` |
|
||||
| Воркер зацикливается на «вечно недоступном» сервисе | низкая | при `503`/`unreachable` — экспоненциальный `sleep`, лимит мягких повторов (например 60) → затем одна честная ошибка с понятным текстом |
|
||||
|
||||
---
|
||||
|
||||
## 9. Критерии готовности
|
||||
|
||||
1. `photo-ai` стартует и на CPU-хосте, и с CUDA, определяя устройство автоматически; `/health`
|
||||
сообщает фактическое устройство, имя GPU, VRAM, доступные и загруженные модели.
|
||||
2. Существующий сценарий «🤖 ИИ» без параметров даёт результат, эквивалентный текущему.
|
||||
3. Доступны режимы: универсальный x2, быстрый x4, только лица (GFPGAN), лица + фон.
|
||||
4. Задание с face-моделью проходит полный цикл `pending → processing → done`; результат виден в
|
||||
сравнении «Было/Стало», применим вручную и откатывается.
|
||||
5. Отсутствие GPU, отсутствие `nvidia-ctk` и недоступный `photo-ai` **не** ломают приложение:
|
||||
`PHOTO_AI_URL=` пусто → кнопка «ИИ» недоступна; сервис упал → задания пережидают (`503`), не
|
||||
сжигая попытки; пустой `.env` полностью совместим.
|
||||
6. `node api.smoketest.js` проходит; `AGENTS.md`, `README.md`, `.env.example` описывают новые
|
||||
переменные и GPU-запуск.
|
||||
|
||||
---
|
||||
|
||||
## 10. Быстрые команды для проверки после реализации
|
||||
|
||||
```bash
|
||||
# Статус устройства и моделей
|
||||
docker compose exec -T app node -e "fetch(process.env.PHOTO_AI_URL+'/health').then(r=>r.json()).then(console.log)"
|
||||
curl -s -H "X-Auth-Token: $TOKEN" http://localhost:3003/api/photo-jobs/status | python3 -m json.tool
|
||||
|
||||
# Прямой вызов с лицами (ручная проверка)
|
||||
curl -s -X POST http://localhost:8080/enhance \
|
||||
-F image=@photo.jpg -F scale=2 -F model=x2plus -F face=face -F face_model=gfpgan \
|
||||
-H 'Accept: application/json' | python3 -c "import json,sys,base64; d=json.load(sys.stdin); open('out.jpg','wb').write(base64.b64decode(d['image_base64'])); print({k:v for k,v in d.items() if k!='image_base64'})"
|
||||
|
||||
# CPU-режим принудительно
|
||||
docker compose stop photo-ai
|
||||
PHOTO_AI_DEVICE=cpu docker compose -f docker-compose.yml up -d --build photo-ai
|
||||
|
||||
# GPU-режим
|
||||
docker compose -f docker-compose.yml -f docker-compose.gpu.yml up -d --build photo-ai
|
||||
|
||||
# Проверка обратной совместимости (без model/face — как раньше)
|
||||
curl -s -X POST http://localhost:8080/enhance -F image=@photo.jpg -F scale=2 -o old_behavior.jpg
|
||||
|
||||
# Смоук
|
||||
node api.smoketest.js
|
||||
```
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
# PRD.md — Product Requirements Document: WhatIDo
|
||||
|
||||
**Version**: 1.0
|
||||
**Status**: Active
|
||||
**Last Updated**: 2026-09-09
|
||||
|
||||
---
|
||||
|
||||
## 1. Product Summary
|
||||
|
||||
**WhatIDo** is a self-hosted accounting system for an educational center (youth club, coding school, art studio). It tracks attendance, student project works, group photo chronicles, and publishes public showcase pages via share links. Designed for zero-public-IP deployment using Tailscale Funnel.
|
||||
|
||||
**Target Users**:
|
||||
- **Administrators/Teachers** — manage groups, students, entries, files, settings
|
||||
- **Parents/Students** — view public showcase pages (read-only, no auth)
|
||||
|
||||
**Core Value**: Simple, secure, zero-infrastructure publishing. Runs on any Linux box with Docker.
|
||||
|
||||
---
|
||||
|
||||
## 2. Functional Requirements
|
||||
|
||||
### 2.1 Groups Management
|
||||
| ID | Requirement | Priority |
|
||||
|----|-------------|----------|
|
||||
| GRP-1 | CRUD groups: name, schedule (day of week, start/end time), branch assignment | Must |
|
||||
| GRP-2 | Group cover photo (auto-set from latest group photo or manual) | Must |
|
||||
| GRP-3 | Photo chronicle per group: upload, caption, date taken, pagination | Must |
|
||||
| GRP-4 | Active groups endpoint (filters by current day/time in Europe/Moscow) | Must |
|
||||
| GRP-5 | Branches (locations): CRUD with address, phone; groups link to branch | Should |
|
||||
|
||||
### 2.2 Students Management
|
||||
| ID | Requirement | Priority |
|
||||
|----|-------------|----------|
|
||||
| STU-1 | CRUD students: name, group assignment | Must |
|
||||
| STU-2 | Batch assign students to group | Should |
|
||||
| STU-3 | Unique name constraint per student | Must |
|
||||
|
||||
### 2.3 Journal Entries (Attendance + Project Works)
|
||||
| ID | Requirement | Priority |
|
||||
|----|-------------|----------|
|
||||
| ENT-1 | Create entry: student name (free text), group, description, photo, multiple files | Must |
|
||||
| ENT-2 | List entries with filters: group, date range, student name, search (name/description), deleted flag | Must |
|
||||
| ENT-3 | Update entry: description, photo, files | Must |
|
||||
| ENT-4 | Soft delete / restore (deleted_at timestamp) | Must |
|
||||
| ENT-5 | Trash view: list deleted entries, restore, permanent delete | Must |
|
||||
| ENT-6 | Pagination (limit/offset) + total count | Must |
|
||||
| ENT-7 | Anti-spam: min interval between entries per student (configurable, default 30 min) | Must |
|
||||
| ENT-8 | Files attached to entry: upload (max 10 files, per-file and total size limits from `UPLOAD_FILE_LIMIT_MB`/`UPLOAD_TOTAL_LIMIT_MB`), download by token | Must |
|
||||
| ENT-9 | Video attachments: `mp4`/`m4v`/`webm`/`ogv` playable inline in the journal with seeking (Range), other video formats download-only | Should |
|
||||
|
||||
### 2.4 Files Management (Centralized)
|
||||
| ID | Requirement | Priority |
|
||||
|----|-------------|----------|
|
||||
| FIL-1 | List all files with filters: search, student, group, date range | Must |
|
||||
| FIL-2 | Detached files tab: files with `entry_id = NULL` | Must |
|
||||
| FIL-3 | Detach file from entry (sets `detached_at`) | Must |
|
||||
| FIL-4 | Delete file (removes from disk + DB) | Must |
|
||||
| FIL-5 | Public file access by token (image inline, others download) | Must |
|
||||
| FIL-6 | File size display in list | Should |
|
||||
|
||||
### 2.5 Share Links (Public Showcase Pages)
|
||||
| ID | Requirement | Priority |
|
||||
|----|-------------|----------|
|
||||
| SHR-1 | Create share link: name, optional group, student, date range, anonymize names, expiry (default 7 days), optional password | Must |
|
||||
| SHR-2 | List/Edit/Delete share links (admin) | Must |
|
||||
| SHR-3 | Public page (`/s/:token`): shows filtered entries + group photos | Must |
|
||||
| SHR-4 | Password protection on share link (bcrypt) | Must |
|
||||
| SHR-5 | Expiry enforcement (410 Gone after expires_at) | Must |
|
||||
| SHR-6 | Anonymize student names on public page (Student 1, Student 2...) | Should |
|
||||
| SHR-7 | Group photos on public page (latest 12) | Should |
|
||||
| SHR-8 | File download from share page (validates link + password + filters) | Must |
|
||||
|
||||
### 2.6 Dashboard & Statistics
|
||||
| ID | Requirement | Priority |
|
||||
|----|-------------|----------|
|
||||
| DSH-1 | Stats cards: total entries, trash count, groups, unique students | Must |
|
||||
| DSH-2 | Active groups right now (schedule match) | Must |
|
||||
| DSH-3 | Activity chart: entries per day (last 14 days) | Should |
|
||||
| DSH-4 | Top students by entry count | Should |
|
||||
| DSH-5 | Recent entries list (last 10) | Should |
|
||||
|
||||
### 2.7 Settings
|
||||
| ID | Requirement | Priority |
|
||||
|----|-------------|----------|
|
||||
| SET-1 | Footer left/right text (displayed on public pages) | Must |
|
||||
| SET-2 | Anti-spam interval (minutes) | Must |
|
||||
| SET-3 | Admin-only access | Must |
|
||||
|
||||
### 2.8 Backup & Restore
|
||||
| ID | Requirement | Priority |
|
||||
|----|-------------|----------|
|
||||
| BAK-1 | Download full backup: tar.gz with data.json (all tables) + uploads/ | Must |
|
||||
| BAK-2 | Restore from backup file: validates format, replaces all data, resets sequences | Must |
|
||||
| BAK-3 | Host-level scripts: `backup.sh`, `restore.sh` | Should |
|
||||
| BAK-4 | Audit log entry for backup download/restore | Must |
|
||||
|
||||
### 2.9 Audit Log
|
||||
| ID | Requirement | Priority |
|
||||
|----|-------------|----------|
|
||||
| AUD-1 | Log all mutating actions: action name, target JSON, IP, timestamp | Must |
|
||||
| AUD-2 | Admin view: paginated list (default 100, max 1000) | Must |
|
||||
|
||||
### 2.10 Security & Infrastructure
|
||||
| ID | Requirement | Priority |
|
||||
|----|-------------|----------|
|
||||
| SEC-1 | Admin auth via `X-Admin-Token` header (env `ADMIN_PASSWORD`, no default) | Must |
|
||||
| SEC-2 | Rate limiting: entries 10/15min, files/share 300/15min | Must |
|
||||
| SEC-3 | Upload validation: block dangerous extensions, MIME check, size limits | Must |
|
||||
| SEC-4 | Path traversal protection on file delete/serve | Must |
|
||||
| SEC-5 | Helmet headers (X-Frame-Options, nosniff, HSTS, Referrer-Policy) | Must |
|
||||
| SEC-6 | No CORS (cross-origin blocked) | Must |
|
||||
| SEC-7 | DB port not exposed publicly | Must |
|
||||
| SEC-8 | TLS termination by Tailscale (Let's Encrypt), app uses self-signed cert internally | Must |
|
||||
| SEC-9 | Tailscale Funnel publication (no public IP, no port forward) | Must |
|
||||
|
||||
---
|
||||
|
||||
## 3. Non-Functional Requirements
|
||||
|
||||
| Category | Requirement |
|
||||
|----------|-------------|
|
||||
| **Performance** | API responses < 500ms for typical queries; pagination for large lists |
|
||||
| **Reliability** | DB healthcheck; app restarts on crash; uploads persisted on host |
|
||||
| **Scalability** | Single-instance design; PostgreSQL connection pooling via `pg.Pool` |
|
||||
| **Maintainability** | Single `server.js` file; vanilla frontend; no build step |
|
||||
| **Portability** | Docker Compose; runs on any Linux/ARM64/AMD64 with Docker |
|
||||
| **Backup/Recovery** | Full restore < 5 min for typical dataset (< 1 GB) |
|
||||
| **Security** | No secrets in image; env vars only; regular dependency updates |
|
||||
|
||||
---
|
||||
|
||||
## 4. Data Model
|
||||
|
||||
```
|
||||
groups
|
||||
id PK, name UK, created_at, day_of_week (0-6), time_start, time_end, branch_id FK, cover_path
|
||||
|
||||
students
|
||||
id PK, name UK, group_id FK, created_at
|
||||
|
||||
entries
|
||||
id PK, student_name, group_id FK, description, photo_path, deleted_at, created_at
|
||||
|
||||
project_files
|
||||
id PK, entry_id FK (nullable), token UK, path, name, created_at, detached_at
|
||||
|
||||
group_photos
|
||||
id PK, group_id FK, photo_path, caption, taken_at, created_at
|
||||
|
||||
share_links
|
||||
id PK, token UK, name, group_id FK, student_name, date_from, date_to,
|
||||
anonymize_names, expires_at, access_password_hash, created_at
|
||||
|
||||
settings
|
||||
key PK, value
|
||||
|
||||
audit_log
|
||||
id PK, action, target JSONB, ip, created_at
|
||||
|
||||
branches
|
||||
id PK, name UK, address, phone, created_at
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. API Surface (Key Endpoints)
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| GET | `/api/entries` | Admin | List entries (filters, pagination) |
|
||||
| POST | `/api/entries` | Admin | Create entry (photo + files) |
|
||||
| PUT | `/api/entries/:id` | Admin | Update entry |
|
||||
| DELETE | `/api/entries/:id` | Admin | Soft delete |
|
||||
| POST | `/api/entries/:id/restore` | Admin | Restore from trash |
|
||||
| GET | `/api/files` | Admin | All files (filters) |
|
||||
| GET | `/api/files/detached` | Admin | Detached files |
|
||||
| POST | `/api/files/:id/detach` | Admin | Detach file |
|
||||
| GET | `/api/files/:token` | Public | Download file by token; `?play=1` serves `mp4`/`m4v`/`webm`/`ogv` inline with `Range` support |
|
||||
| GET | `/api/groups` | Public | List groups |
|
||||
| POST/PUT/DELETE | `/api/groups` | Admin | CRUD groups |
|
||||
| GET/POST | `/api/groups/:id/photos` | Admin | Group photo chronicle |
|
||||
| GET | `/api/share/:token` | Public | Share page data |
|
||||
| GET | `/api/links` | Admin | List share links |
|
||||
| POST/PUT/DELETE | `/api/links` | Admin | CRUD share links |
|
||||
| GET | `/api/backup` | Admin | Build & download backup (compat) |
|
||||
| POST | `/api/backup` | Admin | Build backup, returns download URL |
|
||||
| GET | `/api/backup/:token` | Token | Download built backup (resumable) |
|
||||
| POST | `/api/restore` | Admin | Upload & restore backup |
|
||||
| GET | `/api/dashboard` | Admin | Dashboard data |
|
||||
| GET | `/api/stats` | Admin | Stats cards |
|
||||
| GET | `/api/audit` | Admin | Audit log |
|
||||
|
||||
---
|
||||
|
||||
## 6. User Flows
|
||||
|
||||
### 6.1 Teacher Creates Attendance Entry
|
||||
1. Opens `/journal.html`
|
||||
2. Fills form: student name (typeahead from existing), group, description
|
||||
3. Adds photo (optional) + project files (optional)
|
||||
4. Submits → entry appears in list, files accessible by token
|
||||
|
||||
### 6.2 Admin Publishes Showcase for Parents
|
||||
1. Opens `/links.html`
|
||||
2. Creates share link: selects group, date range, sets password
|
||||
3. Copies link `https://whatido.tailnet.ts.net/s/abc123`
|
||||
4. Sends to parents → they open, enter password, view entries + photos
|
||||
|
||||
### 6.3 Admin Restores from Backup
|
||||
1. Opens `/settings.html` → Backups tab
|
||||
2. Uploads `.tar.gz` backup file
|
||||
3. Confirms → all data replaced, sequences reset, orphans cleaned
|
||||
|
||||
---
|
||||
|
||||
## 7. Deployment Architecture
|
||||
|
||||
```
|
||||
Internet / Tailnet
|
||||
│
|
||||
▼
|
||||
┌──────────────────┐
|
||||
│ Tailscale │ (network_mode: host, port 443)
|
||||
│ Funnel/Serve │ TLS: Let's Encrypt (*.ts.net)
|
||||
└────────┬─────────┘
|
||||
│ HTTPS (trusts app self-signed cert)
|
||||
▼
|
||||
┌──────────────────┐
|
||||
│ App (Node.js) │ 127.0.0.1:3443 (HTTPS), :3003 (HTTP→HTTPS redirect)
|
||||
│ Express + pg │
|
||||
└────────┬─────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────┐
|
||||
│ PostgreSQL 16 │ Internal docker network only
|
||||
│ (named volume) │
|
||||
└──────────────────┘
|
||||
|
||||
Host filesystem:
|
||||
./uploads ──────► /app/uploads (bind mount)
|
||||
./certs ──────► /etc/tailscale/app-certs (ro)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Configuration
|
||||
|
||||
| Variable | Required | Description |
|
||||
|----------|----------|-------------|
|
||||
| `ADMIN_PASSWORD` | Yes | Admin token value (no default, server refuses start) |
|
||||
| `DB_PASSWORD` | Yes | Postgres `app` user password |
|
||||
|
||||
---
|
||||
|
||||
## 9. Release Criteria
|
||||
|
||||
- [ ] All Must-have requirements implemented and tested
|
||||
- [ ] Docker Compose starts cleanly on fresh host (`docker compose up -d --build`)
|
||||
- [ ] Tailscale Funnel publishes successfully (manual verification)
|
||||
- [ ] Backup/restore roundtrip works (data + files intact)
|
||||
- [ ] No critical security findings (rate limits, upload validation, auth)
|
||||
- [ ] README.md updated with accurate setup instructions
|
||||
|
||||
---
|
||||
|
||||
## 10. Future Considerations (Not in Scope v1)
|
||||
|
||||
- Multi-user auth (teachers with own logins)
|
||||
- Email/push notifications
|
||||
- Mobile app / PWA
|
||||
- Rich text editor for descriptions
|
||||
- Bulk import students (CSV)
|
||||
- Webhooks for external integrations
|
||||
- Automated scheduled backups to S3/remote
|
||||
- Role-based access (read-only vs admin)
|
||||
|
||||
---
|
||||
|
||||
## 11. Acceptance Test Scenarios
|
||||
|
||||
| Scenario | Steps | Expected |
|
||||
|----------|-------|----------|
|
||||
| Fresh deploy | `cp .env.example .env` → edit → `docker compose up -d --build` | App healthy, DB migrated, HTTPS on 3443 |
|
||||
| Create entry | POST `/api/entries` with photo + 2 files | Entry created, files downloadable by token |
|
||||
| Share link | Create link with password → open `/s/token` → enter password | Entries filtered, photos shown, files download |
|
||||
| Backup/restore | Download backup → delete entry → restore → verify entry back | Full state restored, sequences correct |
|
||||
| Tailscale publish | `tailscale up` → enable Funnel → `tailscale funnel` | Public URL accessible via HTTPS |
|
||||
| Upload rejection | POST `.html` file → 400 error | Dangerous extensions blocked |
|
||||
| Rate limit | 11 rapid POST `/api/entries` → 429 on 11th | Entry limiter enforced |
|
||||
|
||||
---
|
||||
@@ -4,60 +4,349 @@
|
||||
|
||||
## Возможности
|
||||
|
||||
- **Журнал записей** — отметки о занятиях с фото и прикреплёнными файлами (проектные работы), мягкое удаление и корзина
|
||||
- **Журнал записей** — отметки о занятиях с фото и прикреплёнными файлами (проектные работы), мягкое удаление и корзина; в режиме карточек у записи показывается бейдж группы поверх фото и иконка статуса AI-проверки
|
||||
- **AI-проверка (воркер)** — фоновый авто-чек текста записей (`worker.js`); статус каждой записи (очередь / проверка / проверено / пропущено / ошибка) отображается компактной иконкой в журнале с всплывающей подсказкой
|
||||
- **Файлы** — централизованный раздел со всеми загруженными файлами, фильтры (имя воспитанника, группа, даты, поиск) и вкладка «Откреплённые»
|
||||
- **Группы** — учебные группы, расписание (день недели, время), фотохроника группы
|
||||
- **Воспитанники** — справочник с привязкой к группам
|
||||
- **Share-ссылки** — публичные страницы-витрины с выбором группы / воспитанника / диапазона дат
|
||||
- **Дашборд** — статистика, активные группы, активность за 14 дней, последние записи, топ воспитанников
|
||||
- **Отчёты о занятиях** — тьютор описывает, что прошли на занятии; галочка в окне отчёта отправляет текст модели, которая сверяет его с шаблоном делового сообщения (настраивается в «Настройках» → «Шаблон отчёта»): совпал — остаётся как есть, не совпал — переписывается в деловом виде. Обработка идёт в фоне, оригинал тьютора сохраняется, доступна история версий с восстановлением
|
||||
- **Резервное копирование** — экспорт/импорт полного дампа (БД + файлы) в `tar.gz`
|
||||
- **Настройки** — научные тексты футера, анти-спам интервал
|
||||
- **HTTPS** — самоподписанный TLS-сертификат, автогенерация при сборке
|
||||
- **Хранилище файлов** — локальный каталог `uploads/` или S3-совместимый сервис (`s3`: SeaweedFS, либо MinIO через оверрайд), перенос файлов скриптом миграции
|
||||
- **Настройки** — тексты футера, анти-спам интервал, системная информация (объёмы БД и хранилища) и «Статус стека»: версии Node.js/Express/PostgreSQL/Redis, состояние сервисов, ОС, CPU, память и аптаймы (`GET /api/system-info` → `stack`)
|
||||
- **Уведомления** — системные события (новые записи журнала, обработка фото нейросетью, ошибки авто-проверки текста, блокировки IP, бэкапы) собираются в «колокольчике» и на странице «Уведомления»; набор событий включается/выключается в «Настройках» → «Уведомления»
|
||||
- **Публикация через Tailscale** — приложение открывается по постоянному адресу `https://whatido.<tailnet>.ts.net` без проброса портов, внешнего IP и reverse-proxy
|
||||
|
||||
## Технологии
|
||||
|
||||
- Node.js + Express
|
||||
- PostgreSQL (pg)
|
||||
- Multer (загрузка файлов), Tar (бэкапы)
|
||||
- S3-совместимое хранилище (AWS SDK v3): сервис `s3` (SeaweedFS / MinIO)
|
||||
- Redis: кэш, rate limit, баны IP, кэш сессий, pub/sub (SSE и воркеры)
|
||||
- Lucide (иконки UI)
|
||||
- Docker / Docker Compose
|
||||
- Tailscale (Serve / Funnel) — публикация по HTTPS
|
||||
|
||||
## Быстрый старт
|
||||
|
||||
### Требования
|
||||
|
||||
- Docker + Docker Compose
|
||||
- Linux-хост с Docker и плагином `docker compose`
|
||||
- Free порта `443` на хосте (его займёт tailscale для Funnel)
|
||||
- Аккаунт Tailscale (для публикации по ссылке)
|
||||
|
||||
### Запуск
|
||||
|
||||
```bash
|
||||
# создайте .env с паролем администратора (см. раздел «Конфигурация»)
|
||||
# 1) создайте .env из примера и задайте свои пароли
|
||||
cp .env.example .env
|
||||
$EDITOR .env
|
||||
|
||||
# 2) соберите и поднимите стек
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
После старта:
|
||||
После старта (без публикации через tailscale):
|
||||
|
||||
- **HTTP** `http://localhost:3000` — редирект на HTTPS
|
||||
- **HTTPS** `https://localhost:3443` — приложение (самоподписанный сертификат, принимайте предупреждение браузера)
|
||||
- **PostgreSQL** `localhost:5432` — `app:app`, база `whereldo`
|
||||
- **HTTP** `http://localhost:3003` — редирект на HTTPS
|
||||
- **HTTPS** `https://localhost:3443` — приложение (самоподписанный сертификат, примите предупреждение браузера)
|
||||
- **PostgreSQL** — доступен только внутри docker-сети (наружу не публикуется)
|
||||
- **Redis** — `127.0.0.1:6379` на хосте (только loopback), внутри сети — `redis:6379`
|
||||
|
||||
Управление:
|
||||
|
||||
```bash
|
||||
docker compose ps # статус
|
||||
docker compose logs -f app
|
||||
docker compose down # остановка (данные сохраняются)
|
||||
docker compose ps # статус
|
||||
docker compose logs -f app # логи приложения
|
||||
docker compose down # остановка (данные сохраняются)
|
||||
```
|
||||
|
||||
> Приложение **не запустится** без `ADMIN_PASSWORD` (защита от пароля по умолчанию).
|
||||
> `DB_PASSWORD` задаёт пароль пользователя `app` в PostgreSQL.
|
||||
> `REDIS_PASSWORD` задаёт пароль Redis. Если сервис `redis` убрать из `docker-compose.yml`
|
||||
> или оставить `REDIS_URL` пустым — приложение продолжит работать на in-memory кэше.
|
||||
|
||||
## Обновление на сервере (деплой)
|
||||
|
||||
Код приложения находится внутри образа: bind-монтируется только `uploads/`. Поэтому после `git pull` нужна **пересборка образа** — `docker compose up -d` без `--build` и `docker compose restart` новый `server.js` и статику не подхватят.
|
||||
|
||||
```bash
|
||||
./scripts/deploy.sh # ветка master (либо $DEPLOY_BRANCH, либо первый аргумент)
|
||||
```
|
||||
|
||||
Скрипт проверяет рабочую копию, обновляет ветку (`fetch` + `checkout` + `pull --ff-only`), собирает образ с версией коммита, перезапускает `app`, затем сверяет `server.js` в контейнере с рабочей копией и печатает `/version.json`.
|
||||
|
||||
Вручную то же самое:
|
||||
|
||||
```bash
|
||||
git checkout master && git pull --ff-only origin master
|
||||
docker compose build --build-arg GIT_COMMIT=$(git rev-parse HEAD) --build-arg GIT_COMMIT_DATE=$(git log -1 --format=%cI) app
|
||||
docker compose up -d app
|
||||
```
|
||||
|
||||
Проверка:
|
||||
|
||||
```bash
|
||||
curl -sk https://127.0.0.1:3443/version.json # версия собранного коммита
|
||||
docker compose exec app md5sum /app/server.js # совпадает с md5sum server.js
|
||||
```
|
||||
|
||||
Версия сборки записывается в `public/version.json` внутри образа из аргументов `GIT_COMMIT` / `GIT_COMMIT_DATE` (`build.args` в `docker-compose.yml`, подставляет `scripts/deploy.sh`) и показывается в сайдбаре админки — она всегда соответствует собранному коду, даже если файл в рабочей копии устарел. Локально файл обновляет хук: `cp scripts/post-commit.sh .git/hooks/post-commit`. Если образ собран без аргументов (`docker compose build` вместо `deploy.sh`), версия в сайдбаре будет пустой.
|
||||
|
||||
## Конфигурация
|
||||
|
||||
Переменные окружения (`.env`):
|
||||
|
||||
| Переменная | По умолчанию | Назначение |
|
||||
|------------------|--------------|-------------------------------------|
|
||||
| `ADMIN_PASSWORD` | `admin` | Пароль администратора (X-Admin-Token) |
|
||||
| `DATABASE_URL` | см. compose | Строка подключения к PostgreSQL |
|
||||
| Переменная | По умолчанию | Назначение |
|
||||
|------------------|--------------------|-------------------------------------|
|
||||
| `ADMIN_PASSWORD` | — (обязательно) | Пароль первого администратора, создаётся в пустой БД. Не является способом авторизации в API |
|
||||
| `ADMIN_USERNAME` | `admin` | Логин первого администратора |
|
||||
| `DB_PASSWORD` | — (обязательно) | Пароль пользователя `app` в PostgreSQL |
|
||||
| `REDIS_PASSWORD` | — (обязательно) | Пароль Redis (`--requirepass`) |
|
||||
| `REDIS_PREFIX` | `whatido` | Префикс ключей Redis — свой для каждого инстанса |
|
||||
| `REDIS_MAXMEMORY` | `256mb` | Лимит памяти Redis, при переполнении вытесняется LRU |
|
||||
|
||||
Внутри контейнера `db` также задаются `POSTGRES_DB=whereldo`, `POSTGRES_USER=app`, `POSTGRES_PASSWORD=app`, `TZ=Europe/Moscow`.
|
||||
Пример `.env` (в репозитории — `.env.example`):
|
||||
|
||||
```
|
||||
ADMIN_PASSWORD=сложный-пароль
|
||||
DB_PASSWORD=случайная-длинная-строка
|
||||
REDIS_PASSWORD=случайная-длинная-строка
|
||||
```
|
||||
|
||||
`DB_PASSWORD` подставляется в `docker-compose.yml` в `POSTGRES_PASSWORD` и `DATABASE_URL`. Если БД уже была инициализирована ранее, значение `DB_PASSWORD` должно совпадать с фактическим паролем пользователя `app` в БД (иначе приложение не подключится).
|
||||
|
||||
Имя узла Tailscale задаётся в `docker-compose.yml` (`tailscale.hostname`, по умолчанию `whatido`).
|
||||
|
||||
## ИИ-улучшение фото (photo-ai)
|
||||
|
||||
Сервис `photo-ai` (Real-ESRGAN + GFPGAN) поднимается вместе со стеком и **включён по умолчанию**:
|
||||
`PHOTO_AI_URL` в `docker-compose.yml` равен `http://photo-ai:8080`, кнопка «🤖 ИИ» активна,
|
||||
а задания обрабатывает фоновый воркер. Базовая сборка работает на CPU, GPU не требуется.
|
||||
|
||||
| Переменная | По умолчанию | Назначение |
|
||||
|---|---|---|
|
||||
| `PHOTO_AI_URL` | `http://photo-ai:8080` | Адрес сервиса. **Пустое значение = сервис выключен**: кнопка «🤖 ИИ» скрыта, `POST /api/entries/:id/photo/enhance-ai` отвечает `503`, приложение при этом полностью работоспособно |
|
||||
| `PHOTO_AI_MAX_PIXELS` | `4000000` | Максимум пикселей входного изображения, вход большего размера уменьшается |
|
||||
| `PHOTO_AI_DEVICE` | `auto` | Устройство инференса: `auto` (CUDA, если контейнеру выдан GPU, иначе CPU), `cuda`, `cpu`. Явный `cuda` без CUDA не роняет сервис: WARN в лог и работа на CPU |
|
||||
| `PHOTO_AI_TILE` | `256` | Размер тайла инференса (`0` — без тайлов): меньше тайл — меньше памяти, но медленнее |
|
||||
| `PHOTO_AI_FACE_MODEL` | `gfpgan` | Модель восстановления лиц: `gfpgan` или `codeformer` (официальный модуль вендорен в `photo-ai/vendor/codeformer`, доступен сразу) |
|
||||
| `PHOTO_AI_LOAD_ALL` | `0` | Загружать все модели при старте (`1`) или лениво по требованию (`0`) |
|
||||
| `PHOTO_AI_JPEG_QUALITY` | `92` | Качество JPEG результата, 70..100 |
|
||||
| `PHOTO_AI_FACE_TIMEOUT_MS` | `600000` | Таймаут заданий с восстановлением лиц (мс) |
|
||||
| `PHOTO_AI_SOFT_MAX_RETRIES` | `60` | Сколько раз задание ждёт недоступный сервис, не увеличивая счётчик попыток; после исчерпания — честная ошибка |
|
||||
| `PHOTO_AI_SOFT_BACKOFF_MS` | `10000` | Первая пауза перед мягким повтором |
|
||||
| `PHOTO_AI_SOFT_BACKOFF_MAX_MS` | `300000` | Потолок паузы (задержка растёт вдвое) |
|
||||
|
||||
### Модели лиц и где лежат веса
|
||||
|
||||
Face-модели доступны обе: `gfpgan` (дефолт) и `codeformer`. Модуль `codeformer` — официальный
|
||||
`sczhou/CodeFormer` (`b33cc7d`), вендорен в `photo-ai/vendor/codeformer/` (`codeformer_arch.py`
|
||||
+ `vqgan_arch.py`, лицензия S-Lab 1.0 лежит рядом). Отдельный пакет с PyPI не используется: там лежит
|
||||
сторонняя обёртка `rohitkhatri`, которая тянет свой `facelib` и `lpips`. Модуль попадает в образ
|
||||
через `COPY vendor/` и подхватывается `sys.path` в `app.py` — правки Dockerfile не требуется.
|
||||
|
||||
Веса **не** лежат в репозитории и **не** скачиваются при первом запросе: на сборке образа
|
||||
`fetch-weights.py` кладёт их в `/opt/photo-ai-seed`, а при старте сервис переносит их в том
|
||||
`photo-ai-models:/models/weights` (`seed_weights()`). Дальше модель живёт в томе и переживает
|
||||
пересборку образа; если её нет ни в томе, ни в образе, работает старый ленивый заозагрузчик.
|
||||
Сам файл качается в BuildKit-кэш `/var/cache/photo-ai-weights`, поэтому повторная сборка
|
||||
(и сборка с другим `PHOTO_AI_PREFETCH`) берёт его оттуда и заново не качает.
|
||||
|
||||
| Сборка | Что скачает |
|
||||
|---|---|
|
||||
| `docker compose build photo-ai` | `codeformer` (дефолт `PHOTO_AI_PREFETCH=codeformer`) |
|
||||
| `PHOTO_AI_PREFETCH=face docker compose build photo-ai` | `codeformer` + `gfpgan` |
|
||||
| `PHOTO_AI_PREFETCH=all docker compose build photo-ai` | всё: апскейлы, face-модели, веса facexlib |
|
||||
| `PHOTO_AI_PREFETCH=none docker compose build photo-ai` | ничего, веса качаются лениво в том |
|
||||
|
||||
Переменная `PHOTO_AI_PREFETCH` — именно build-arg, он читается при сборке образа, а не контейнера
|
||||
(в `.env.example` она есть, чтобы задать значение один раз). Скачивание при сборке не роняет образ:
|
||||
при недоступной сети шаг пишет предупреждение, сервис докачает веса при первом использовании.
|
||||
|
||||
### Запуск на NVIDIA GPU
|
||||
|
||||
GPU не обязателен: без него сервис работает на CPU. Чтобы включить GPU-вариант, нужен драйвер NVIDIA
|
||||
и NVIDIA Container Toolkit.
|
||||
|
||||
```bash
|
||||
# 1. Toolkit (один раз, требует sudo)
|
||||
curl -fsSL https://nvidia.github.io/libnvidia-container/gpgkey | sudo gpg --dearmor -o /usr/share/keyrings/nvidia-container-toolkit-keyring.gpg
|
||||
curl -s -L https://nvidia.github.io/libnvidia-container/stable/deb/nvidia-container-toolkit.list \
|
||||
| sed 's#deb https://#deb [signed-by=/usr/share/keyrings/nvidia-container-toolkit-keyring.gpg] https://#g' \
|
||||
| sudo tee /etc/apt/sources.list.d/nvidia-container-toolkit.list
|
||||
sudo apt-get update && sudo apt-get install -y nvidia-container-toolkit
|
||||
|
||||
# 2. Запуск
|
||||
docker compose -f docker-compose.yml -f docker-compose.gpu.yml up -d --build photo-ai
|
||||
curl http://127.0.0.1:8081/health
|
||||
```
|
||||
|
||||
`docker-compose.gpu.yml` собирает отдельный тег `whatido-photo-ai:cu126` (torch из индекса `cu126` —
|
||||
те же версии, что и в CPU-образе, отличаются только CUDA-библиотеки), поэтому CPU-образ
|
||||
`whatido-photo-ai:latest` не перетирается и переключение обратно — обычный `docker compose up -d photo-ai`.
|
||||
|
||||
GPU выдаётся контейнеру ключом `gpus: all`, поэтому править `/etc/docker/daemon.json` и перезапускать
|
||||
демон не нужно. Схема через CDI (`deploy.resources.reservations.devices` → `nvidia.com/gpu=all`)
|
||||
намеренно не используется: спека `/etc/cdi/nvidia.yaml` запекает нумерацию `/dev/dri/card*` на момент
|
||||
генерации, поэтому после переподключения видеокарты или смены порта она начинает ссылаться на
|
||||
несуществующий узел, и контейнер не стартует с `CDI device injection failed: failed to stat CDI host
|
||||
device /dev/dri/cardN`. Перегенерация спеки требует sudo и теряется при каждой перегенерации;
|
||||
`gpus: all` от этого свободен.
|
||||
|
||||
Проверка результата: в `/health` должны быть `device: cuda:0`, `half: true`, непустые
|
||||
`vram_total_mb`/`vram_free_mb`. На 4 ГБ (например, RTX 3050 Laptop) реально держатся одновременно
|
||||
`x2plus` и `gfpgan`: `vram_free_mb` после двух моделей — около 100–300 МБ, поэтому `PHOTO_AI_TILE`
|
||||
оставьте небольшим (`256`), а `PHOTO_AI_LOAD_ALL=1` на 4 ГБ лучше не включать — предзагрузка всех
|
||||
моделей подряд исчерпает VRAM. Если памяти не хватило, сервис сам проходит лестницу тайлов
|
||||
(`PHOTO_AI_TILE` → /2 → /4), затем переключается на CPU и возвращает результат с предупреждением
|
||||
в `warnings` — задание при этом не падает.
|
||||
|
||||
Порт `8081` на `127.0.0.1` — только loopback хоста, наружу ничего не публикуется (хостовый `8080` занят
|
||||
`text-corrector`). Ручные проверки сервиса:
|
||||
|
||||
```bash
|
||||
curl http://127.0.0.1:8081/health
|
||||
curl -F "image=@photo.jpg" -F "scale=2" http://127.0.0.1:8081/enhance -o out.jpg
|
||||
curl -H "Accept: application/json" -F "image=@photo.jpg" -F "face=face" http://127.0.0.1:8081/enhance \
|
||||
| python3 -c "import json,sys; d=json.load(sys.stdin); print({k: d[k] for k in ('device','faces_found','elapsed_ms','warnings')})"
|
||||
```
|
||||
|
||||
Состояние сервиса и воркера — в `GET /api/photo-jobs/status` (admin): `service` отдаёт health фото-сервиса
|
||||
(`configured`, `reachable`, `device`, `device_name`, `half`, `tile`, `vram_total_mb`, `vram_free_mb`,
|
||||
`models`, `face_models`, `loaded`, `latency_ms`, `error`), `ai_configured` — задан ли `PHOTO_AI_URL`,
|
||||
`worker` — состояние очереди и конфигурация воркера.
|
||||
|
||||
Параметры `/enhance`: `image` (файл), `scale` (`2`..`4`), `model` (`x2plus`|`general-x4v3`|`animevideo-v3`),
|
||||
`face` (`off`|`face`|`all`), `face_model` (`gfpgan`|`codeformer`), `strength` (`0..1`, только CodeFormer),
|
||||
`jpeg_quality` (`70..100`). По умолчанию отдаётся сырой `image/jpeg`; заголовок
|
||||
`Accept: application/json` переключает на JSON с `image_base64`, `faces_found`, `device`, `elapsed_ms`
|
||||
и `warnings` (малое разрешение входа, лица не найдены, не хватило памяти, CodeFormer на CPU).
|
||||
|
||||
## Публичный доступ через Tailscale
|
||||
|
||||
Стек не требует внешнего IP и проброса портов: контейнер `tailscale` запускается с `network_mode: host`, входит в вашу tailnet-сеть и через **Serve** открывает приложение внутри tailnet, а через **Funnel** — в публичном интернете.
|
||||
|
||||
Цепочка:
|
||||
|
||||
```
|
||||
Интернет / tailnet → https://whatido.<tailnet>.ts.net (TLS от Tailscale)
|
||||
→ localhost:443 контейнера tailscale
|
||||
→ https://127.0.0.1:3443 (приложение, самоподписанный cert)
|
||||
```
|
||||
|
||||
Контейнер доверяет самоподписанному сертификату приложения через `SSL_CERT_FILE=/etc/tailscale/app-certs/cert.pem` (файл монтируется из `./certs/cert.pem`).
|
||||
|
||||
### 1. Вход в tailnet при первом запуске
|
||||
|
||||
При первом старте контейнер автоматически выполняет `tailscale up` и печатает ссылку для авторизации — откройте её в браузере и добавьте устройство в аккаунт. Ввести устройство вручную там не нужно: `tailscale status` покажет статус:
|
||||
|
||||
```bash
|
||||
docker exec -it whatido-tailscale-1 tailscale status
|
||||
```
|
||||
|
||||
Если авторизация по какой-то причине не прошла, выполните вход вручную:
|
||||
|
||||
```bash
|
||||
docker exec -it whatido-tailscale-1 tailscale up --hostname=whatido
|
||||
# откроется ссылка вида https://login.tailscale.com/a/... — войдите в браузер
|
||||
```
|
||||
|
||||
### 2. Включите функции и найдите адрес
|
||||
|
||||
После авторизации узел получит имя вида `whatido` и адрес:
|
||||
|
||||
```bash
|
||||
docker exec -it whatido-tailscale-1 tailscale status
|
||||
# что-то вроде: whatido.taile47725.ts.net 100.x.x.x receiver online
|
||||
```
|
||||
|
||||
Чтобы публиковать сайт, для tailnet должны быть включены:
|
||||
|
||||
- **HTTPS Certificates** — автоматически выдается при первом Serve/Funnel
|
||||
- **Serve** (доступ из tailnet) и **Funnel** (доступ из интернета) — включаются в админ-консоли Tailscale, например по прямой ссылке на узел:
|
||||
`https://login.tailscale.com/f/serve?node=<node-id>` и `https://login.tailscale.com/funnel?node=<node-id>`
|
||||
(id узла берётся из `tailscale status`)
|
||||
|
||||
### 3. Запуск Serve / Funnel
|
||||
|
||||
Контейнер при старте сам выполняет:
|
||||
|
||||
```bash
|
||||
tailscale funnel --bg --yes https://127.0.0.1:3443
|
||||
```
|
||||
|
||||
(`funnel` включает и serve-часть; флаг `--bg` — работа в фоне, `--yes` — не спрашивать подтверждения.)
|
||||
|
||||
Если сервис уже запущен и команды в compose не отработали (например, функции только что включили в консоли), выполните вручную:
|
||||
|
||||
```bash
|
||||
docker exec whatido-tailscale-1 tailscale funnel --bg --yes https://127.0.0.1:3443
|
||||
```
|
||||
|
||||
Проверить состояние:
|
||||
|
||||
```bash
|
||||
docker exec whatido-tailscale-1 tailscale funnel status
|
||||
# https://whatido.<tailnet>.ts.net/ (Funnel on)
|
||||
```
|
||||
|
||||
### 4. Готово
|
||||
|
||||
- Из любого устройства вашей tailnet: `https://whatido.<tailnet>.ts.net/`
|
||||
- Из интернета (при включённом Funnel): тот же адрес
|
||||
- Админка: `https://whatido.<tailnet>.ts.net/admin`
|
||||
|
||||
### Важные замечания
|
||||
|
||||
- **Порт 443 на хосте должен быть свободен** — tailscale слушает его напрямую (поэтому у сервиса `network_mode: host`, а приложение опубликовано на `127.0.0.1:3003/3443`).
|
||||
- **Сертификат приложения**: контейнер приложения генерирует self-signed `cert.pem` при сборке образа (это не секрет — публичный сертификат). Он монтируется в tailscale через `./certs/cert.pem`. Если образ приложения пересобирали впервые на новом хосте — скопируйте сертификат и перезапустите tailscale:
|
||||
|
||||
```bash
|
||||
docker compose up -d --build app
|
||||
docker cp $(docker compose ps -q app):/app/certs/cert.pem certs/cert.pem
|
||||
docker compose up -d tailscale
|
||||
```
|
||||
|
||||
- **`/lib/modules`** монтируется в контейнер tailscale, чтобы на LC/дистрибутивах без авто-загрузки модулей корректно инициализировался netfilter (иначе `tailscaled` падает с `Table does not exist` и узел мигает online/offline).
|
||||
- **Ограничения сети**: если у провайдера нет глобального IPv6 и соединения к ПК-ядрам Tailscale нестабильные (CGNAT), отвечать наружу узел может с перебоями — классический признак: `tailscale status` показывает `online`, а из интернета URL не открывается. Внутри tailnet сайт работает всегда.
|
||||
|
||||
## Альтернативная публикация (Caddy)
|
||||
|
||||
Исторически проект публиковался через reverse-proxy Caddy + Let's Encrypt (файлы `Caddyfile.example`, закомментированный сервис в старых версиях compose). Если нужно классическое публичное HTTPS на собственном домене с проброшенными портами 80/443 — этот вариант остаётся возможным: раскомментируйте/восстановите сервис `caddy`, укажите домен в `Caddyfile` и уберите сетевые блокировки. По умолчанию сейчас рекомендуется Tailscale-схема выше.
|
||||
|
||||
## Публикация наружу через Cloudflare (Quick Tunnel)
|
||||
|
||||
Помимо Tailscale, стек умеет публиковать приложение через контейнер `cloudflared` (сервис в `docker-compose.yml`). **Quick Tunnel** даёт случайный публичный адрес `*.trycloudflare.com` через Cloudflare — без своего домена, банковской карты и проброса портов (подойдёт, если Zero Trust требует привязать карту).
|
||||
|
||||
Узнать текущий адрес для доступа:
|
||||
|
||||
```bash
|
||||
docker compose logs cloudflared | grep trycloud
|
||||
```
|
||||
|
||||
### Опциональный WireGuard для эгресса Cloudflare
|
||||
|
||||
`cloudflared` собирается из `Dockerfile.cloudflared`, в который добавлена поддержка **опционального WireGuard** — полезно, когда Cloudflare не работает с прямого IP хоста, и исход туннеля нужно пустить через VPN.
|
||||
|
||||
- Положите рабочий конфиг провайдера в `wg/wg0.conf` (папка `wg/` монтируется в контейнер как `/etc/wireguard`). Пример-заглушка уже лежит в `wg/wg0.conf` — замените значения на свои.
|
||||
- Если `wg/wg0.conf` есть → контейнер **сначала поднимает WireGuard и ждёт handshake** (весь эгресс Cloudflare идёт через VPN). Если handshake не установился за `WG_HANDSHAKE_TIMEOUT` сек (по умолчанию 60) — **fallback: туннель стартует напрямую без VPN**, а в логах пишется предупреждение (сайт не лежит из-за недоступного VPN-провайдера).
|
||||
- Если `wg0.conf` нет или папка пустая → VPN не включается, туннель стартует сразу, как в базовой схеме.
|
||||
|
||||
Проверка:
|
||||
|
||||
```bash
|
||||
docker compose logs -f cloudflared # в идеале сначала "WireGuard is up", затем URL
|
||||
docker compose exec cloudflared wg show # есть handshake — VPN поднят
|
||||
```
|
||||
|
||||
Примечания:
|
||||
- `wg/wg0.conf` содержит приватный ключ, поэтому папка `wg/` добавлена в `.gitignore` (в репозиторий не попадёт).
|
||||
- При полном туннеле (`AllowedIPs = 0.0.0.0/0`) существующий маршрут docker-сети сохраняется, поэтому `cloudflared` по-прежнему достаёт `app` локально, а наружу уходит через wg0. Если вдруг `app` станет недоступен из-за VPN — переключите `CLOUDFLARE_TUNNEL_URL` в `.env` на docker-шлюз (`http://<gateway-ip>:3003`).
|
||||
|
||||
Адрес Quick Tunnel меняется при каждом перезапуске контейнера `cloudflared`. Для постоянного адреса используйте именованный туннель (для этого в `start-cloudflared.sh` замените последнюю команду на `exec cloudflared --no-autoupdate tunnel run --token "$CLOUDFLARE_TUNNEL_TOKEN"` и задайте токен; токен — из Cloudflare Zero Trust **Networks → Tunnels**) или схему через Tailscale выше.
|
||||
|
||||
## Структура данных
|
||||
|
||||
@@ -68,20 +357,187 @@ docker compose down # остановка (данные сохраняютс
|
||||
- `group_photos` — фотохроника групп
|
||||
- `share_links` — публичные ссылки-витрины
|
||||
- `settings` — пары ключ/значение (анти-спам интервал, футер)
|
||||
- `audit_log` — журнал действий (`action`, `target` JSONB, `ip`, `user_id`)
|
||||
|
||||
Схема инициализируется при первом запуске из `db/init.sql`; миграции существующей БД — в `db/migration.sql`.
|
||||
|
||||
## Аудит изменений текста
|
||||
|
||||
Каждое сохранение записи журнала (`PUT /api/entries/:id`) сравнивает состояние «до» и «после» и пишет в `audit_log` не только факт, но и сами изменения:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 363,
|
||||
"source": "ai",
|
||||
"changed": true,
|
||||
"fields": ["description", "group_id"],
|
||||
"changes": [
|
||||
{ "field": "description", "label": "Текст работы",
|
||||
"stats": { "added_words": 5, "removed_words": 2, "chars_before": 75, "chars_after": 97 },
|
||||
"diff": [{ "type": "del", "text": "учитель" }, { "type": "add", "text": "очень " }] },
|
||||
{ "field": "group_id", "label": "Группа", "before": "4 · Суббота 9:00", "after": "5 · Суббота 11:30" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- `source` — источник правки: `manual` (вручную), `ai` (текст принят из подсказки ИИ), `ai_manual` (ИИ + ручная правка), `ai_revert` (откат к оригиналу)
|
||||
- `diff` — пословный дифф (`eq` / `del` / `add`) с подсветкой в интерфейсе: удалённое зачёркнуто, добавленное выделено
|
||||
- `stats` — сколько слов и символов добавлено и удалено на этом шаге
|
||||
- те же данные пишутся для автопроверки ИИ (`entry.ai.auto-check`) и отката (`entry.ai.revert`)
|
||||
|
||||
Список `GET /api/audit` отдаёт облегчённый `target` (без `diff`), полный — `GET /api/audit/:id`: страница «Аудит» подгружает его при открытии деталей.
|
||||
|
||||
Пословный дифф и сборка изменений вынесены в `diff.js` (без зависимостей, с обрезкой слишком больших текстов), тесты — `node diff.selftest.js`.
|
||||
|
||||
## Хранилище файлов
|
||||
|
||||
Загруженные фото и файлы хранятся в каталоге `uploads/` на хосте и монтируются в контейнер (`./uploads:/app/uploads`). Это даёт прямой доступ к данным из-под хост-системы. Данные БД хранятся в именованном томе `pgdata`.
|
||||
По умолчанию загруженные фото и файлы хранятся в каталоге `uploads/` на хосте и монтируются в контейнер (`./uploads:/app/uploads`) — это драйвер `local`. Данные БД хранятся в именованном томе `pgdata`.
|
||||
|
||||
Дополнительно поддерживается **S3-совместимое хранилище** (сервис `s3` в compose, драйвер `s3`). Все обращения к файлам идут через приложение: URL (`/uploads/...`, `/uploads/thumb/...`, `/api/files/:token`, share-ссылки) и записи в БД (`/uploads/<файл>`) не меняются, поэтому переключение драйвера не требует миграции данных в БД.
|
||||
|
||||
### Сервис `s3`
|
||||
|
||||
```bash
|
||||
docker compose up -d s3 # поднимает S3-хранилище (том s3-data)
|
||||
```
|
||||
|
||||
- **По умолчанию — SeaweedFS** (`chrislusf/seaweedfs`): свободный S3-сервер; API слушает `127.0.0.1:9000` на хосте и `s3:9000` внутри compose-сети.
|
||||
- **MinIO**: официальные свободные образы `minio/minio` удалены из Docker Hub, поэтому MinIO подключается через оверрайд и образ из доступного вам зеркала:
|
||||
|
||||
```bash
|
||||
S3_IMAGE=<ваш-образ-minio> docker compose -f docker-compose.yml -f docker-compose.minio.yml up -d s3
|
||||
```
|
||||
|
||||
Бакет создаётся автоматически при старте приложения (`ensureBucket`) или скриптом миграции. Анонимный доступ к API хранилища закрыт: порт `9000` не публикуется наружу (только loopback), доступ к файлам остаётся через приложение с его аутентификацией и rate limit.
|
||||
|
||||
### Переменные окружения
|
||||
|
||||
| Переменная | По умолчанию | Назначение |
|
||||
|---|---|---|
|
||||
| `STORAGE_DRIVER` | `local` | `local` — файлы в `uploads/`, `s3` — объекты в бакете |
|
||||
| `S3_ENDPOINT` | `http://s3:9000` | Адрес S3 API внутри compose-сети |
|
||||
| `S3_BUCKET` | `whatido` | Бакет для объектов |
|
||||
| `S3_ACCESS_KEY` / `S3_SECRET_KEY` | `whatido` / — | Доступ к хранилищу (для MinIO это root-пользователь) |
|
||||
| `S3_FORCE_PATH_STYLE` | `1` | Path-style адресация (нужна MinIO/SeaweedFS) |
|
||||
| `S3_PREFIX` | — | Необязательный префикс ключей внутри бакета |
|
||||
| `STORAGE_LOCAL_FALLBACK` | `1` | Читать локальный файл, если объекта в S3 ещё нет |
|
||||
| `STORAGE_KEEP_LOCAL` | `0` | Оставлять локальную копию после выгрузки в S3 |
|
||||
| `STORAGE_CACHE_MAX_AGE_HOURS` | `168` | Срок жизни локального кэша оригиналов (для sharp/миниатюр) |
|
||||
|
||||
### Переход на S3 (миграция)
|
||||
|
||||
Порядок не прерывает работу: файлы сначала копируются в бакет, локальные остаются на месте и продолжают использоваться.
|
||||
|
||||
```bash
|
||||
# 1) поднять хранилище
|
||||
docker compose up -d s3
|
||||
|
||||
# 2) предпросмотр и загрузка файлов в бакет (идемпотентно, по размеру объекта)
|
||||
docker compose exec -T app node scripts/migrate-to-s3.js --dry-run
|
||||
docker compose exec -T app node scripts/migrate-to-s3.js
|
||||
|
||||
# 3) проверить, что все объекты на месте (ничего не меняет)
|
||||
docker compose exec -T app node scripts/migrate-to-s3.js --verify-only
|
||||
```
|
||||
|
||||
Дальше включить драйвер `s3` и перезапустить приложение:
|
||||
|
||||
```bash
|
||||
# в .env: STORAGE_DRIVER=s3
|
||||
docker compose up -d app
|
||||
```
|
||||
|
||||
Новые загрузки уходят в бакет (локальная копия удаляется, если `STORAGE_KEEP_LOCAL=0`), старые файлы ещё читаются из `uploads/` благодаря `STORAGE_LOCAL_FALLBACK=1`. Когда всё проверено — удалите локальные копии:
|
||||
|
||||
```bash
|
||||
docker compose exec -T app node scripts/migrate-to-s3.js --delete-local
|
||||
```
|
||||
|
||||
Откат в любой момент: `STORAGE_DRIVER=local` + `docker compose up -d app` (пока локальные копии не удалены).
|
||||
|
||||
Объём и состав хранилища видны в админке: Настройки → Системная информация (блок «Хранилище»).
|
||||
|
||||
## Redis (кэш и pub/sub)
|
||||
|
||||
Сервис `redis` в compose хранит всё, что не требуется переживать перезапуск Postgres, но должно
|
||||
быть общим и быстрым:
|
||||
|
||||
| Что | Ключи | TTL |
|
||||
|---|---|---|
|
||||
| Кэш ответов API и настроек | `setting:*`, `groups:*`, `students:*`, `entries:*`, `stats:*`, `dashboard:*`, `share:payload:*`, `public-settings`, `system-info` | 15–60 с |
|
||||
| Кэш сессий | `session:<token>` | 30 с |
|
||||
| Счётчики rate limit | `rl:api:*`, `rl:entry:*`, `rl:file:*` | окно окна + 10 % |
|
||||
| Баны IP | `ban:<ip>` | до `banned_until` |
|
||||
| Счётчики неудачных попыток входа | `fail:<kind>:<ip>` | 15 мин |
|
||||
|
||||
Инвалидация кэша — по префиксу (`SCAN` + `DEL`), поэтому после правки настроек, группы или записи
|
||||
новое значение видно сразу. Правки пользователей сбрасывают `session:*`, так что деактивация
|
||||
аккаунта и выход из сессии действуют немедленно.
|
||||
|
||||
Через pub/sub каналы `whatido:events`, `whatido:wake:ai` и `whatido:wake:photo` доставляют SSE-события
|
||||
клиентам и будят фоновых воркеров без ожидания цикла опроса БД.
|
||||
|
||||
### Отказоустойчивость
|
||||
|
||||
Если Redis недоступен, приложение **не падает**: `redis.js` прозрачно переключается на
|
||||
in-memory кэш (та же семантика и те же ключи) и возвращается в Redis автоматически, как только
|
||||
сервис поднимется. Первое подключение ограничено таймаутом `REDIS_CONNECT_TIMEOUT_MS` (5 с по
|
||||
умолчанию), поэтому недоступный Redis не задержит старт приложения. Текущее состояние видно в
|
||||
`GET /api/system-info` → `cache.driver` (`redis` или `memory`) и в блоке «Кэш» на странице
|
||||
Настроек → Стек (там же — «нет связи — в памяти», если Redis не отвечает).
|
||||
|
||||
### Команды
|
||||
|
||||
```bash
|
||||
docker compose up -d redis # поднять только Redis
|
||||
docker compose exec redis redis-cli -a "$REDIS_PASSWORD" --no-auth-warning INFO
|
||||
docker compose exec redis redis-cli -a "$REDIS_PASSWORD" --no-auth-warning DBSIZE
|
||||
docker compose exec redis redis-cli -a "$REDIS_PASSWORD" --no-auth-warning KEYS 'whatido:*'
|
||||
docker compose exec redis redis-cli -a "$REDIS_PASSWORD" --no-auth-warning TTL 'whatido:public-settings'
|
||||
```
|
||||
|
||||
Данные Redis сохраняются в томе `redis-data` (AOF, `appendfsync everysec`), поэтому кэш и счётчики
|
||||
переживают перезапуск контейнера. Порт `6379` публикуется только на `127.0.0.1`.
|
||||
|
||||
Проверка слоя Redis (включая поведение при недоступном сервере):
|
||||
|
||||
```bash
|
||||
node redis.selftest.js # юнит-тесты redis.js
|
||||
node api.smoketest.js # сквозная проверка API (нужен запущенный стек)
|
||||
node api-keys.selftest.js # внешний API и API-ключи (нужен запущенный стек)
|
||||
```
|
||||
|
||||
## Уведомления
|
||||
|
||||
Система уведомлений — журнал событий (`notifications`) с отметками прочтения на пользователя (`notification_reads`) плюс каталог типов событий `NOTIFY_TYPES` в `server.js`.
|
||||
|
||||
| Тип | Событие | Кому видно |
|
||||
|-----|---------|------------|
|
||||
| `entry.new` | новая запись в журнале (форма ученика или ручное добавление) | филиал группы |
|
||||
| `entry.ai.corrected` | ИИ исправил текст (по умолчанию выключено) | филиал группы |
|
||||
| `entry.ai.error` | авто-проверка текста не удалась | филиал группы |
|
||||
| `photo.job.done` | фото обработано нейросетью или сервером | филиал группы |
|
||||
| `photo.job.error` | очередь обработки фото исчерпала попытки | филиал группы |
|
||||
| `ip.ban` | IP отправлен в бан (авто или вручную) | только админ |
|
||||
| `backup.restore` | восстановление из бэкапа | только админ |
|
||||
| `backup.create` | создан архив бэкапа (по умолчанию выключено) | только админ |
|
||||
|
||||
Где видно: «колокольчик» в боковом меню (панель последних событий, бейдж непрочитанных, опциональные уведомления браузера) и страница `notifications.html` (фильтр «непрочитанные», отметка «прочитано», удаление и полная очистка для админа). Новые события приходят в реальном времени по SSE (`GET /api/notifications/stream`), транспорт — Redis pub/sub с in-memory fallback.
|
||||
|
||||
Что настраивается в «Настройках» → «Уведомления» (ключи таблицы `settings`): общий выключатель `notify_enabled`, срок хранения `notify_retention_days` (1–365 дней, старые уведомления удаляются ежечасно) и отдельный переключатель `notify_<тип>` для каждого события. Там же кнопка тестового уведомления.
|
||||
|
||||
Видимость: администратор видит все уведомления, остальные — только события своего филиала (или без филиала) и никогда — события с пометкой `admin_only`.
|
||||
|
||||
## Бэкапы
|
||||
|
||||
|
||||
В админке (Настройки → Бэкап) можно:
|
||||
|
||||
- Скачать полный бэкап — `tar.gz`, содержащий `data.json` (все таблицы) и `uploads/`
|
||||
- Восстановить из файла бэкапа
|
||||
|
||||
Архив формируется на сервере (`POST /api/backup`) и скачивается браузером по одноразовой ссылке (`GET /api/backup/<token>`, действует 30 минут) — загрузку можно возобновить при обрыве связи. Совместимый эндпоинт `GET /api/backup` отдаёт тот же архив сразу.
|
||||
|
||||
Также доступны скрипты на хосте:
|
||||
|
||||
```bash
|
||||
@@ -89,6 +545,22 @@ docker compose down # остановка (данные сохраняютс
|
||||
./scripts/restore.sh # восстановление из архива
|
||||
```
|
||||
|
||||
Форматы не взаимозаменяемы: скриптовый архив содержит `db.sql.gz` + `_uploads/` (перенос на другой хост через `scripts/restore.sh`), а веб-архив из админки — `data.json` + `uploads/` (кнопка «Восстановить»). Если в админку загрузить скриптовый архив, сервер вернёт подсказку, какой инструмент использовать.
|
||||
|
||||
Файлы попадают в бэкап из активного хранилища: при `STORAGE_DRIVER=s3` админ-бэкап и `scripts/backup.sh` выгружают объекты из бакета (`scripts/storage-sync.js export`), а восстановление загружает их обратно (`scripts/storage-sync.js import`). Миниатюры (`.thumbs`) в архив не включаются — они пересоздаются по запросу.
|
||||
|
||||
## Безопасность
|
||||
|
||||
- **Пароль администратора** обязателен (`ADMIN_PASSWORD`) — он создаёт первого админа в пустой БД; фолбэка на `admin` нет. В API сам по себе он не авторизует: доступ дают сессия (`X-Auth-Token`) или API-ключ (`X-Api-Key`, только для `/api/v1/*`).
|
||||
- **CORS отключён** — кросс-доменные запросы к API запрещены.
|
||||
- **Rate limiting** по IP на публичные роуты: `POST /api/entries` — 10 запросов / 15 мин, загрузка файлов и share-ссылки — 300 / 15 мин.
|
||||
- **Загрузки** ограничены: суммарно на запись и на файл — лимиты из `UPLOAD_TOTAL_LIMIT_MB` / `UPLOAD_FILE_LIMIT_MB` (по умолчанию 200 МБ и 50 МБ); заблокированы опасные расширения (`.html`, `.js`, `.svg`, `.xml`, `.exe` и др.); SVG не отдаётся inline.
|
||||
- **Видеофайлы** (`.mp4`, `.m4v`, `.webm`, `.ogv`) играются прямо в журнале: `GET /api/files/:token?play=1` отдаёт файл **inline** с `Accept-Ranges: bytes` и поддержкой `Range` (`206`), поэтому перемотка работает без скачивания целиком. Остальные форматы (`.mov`, `.mkv`, `.avi` и пр.) браузер не играет — они остаются ссылками на скачивание.
|
||||
- **Restore** проходит полную валидацию данных бэкапа; удаление файлов ограничено каталогом `uploads/`.
|
||||
- **Заголовки**: `helmet` — `X-Frame-Options`, `nosniff`, HSTS, `Referrer-Policy`.
|
||||
- **Порт БД** 5432 наружу не публикуется (доступ только внутри docker-сети).
|
||||
- **TLS**: снаружи HTTPS терминируется Tailscale (сертификат Let's Encrypt для `*.ts.net`); между tailscale и приложением используется самоподписанный сертификат приложения.
|
||||
|
||||
## Основные API
|
||||
|
||||
| Метод | Путь | Назначение |
|
||||
@@ -106,21 +578,115 @@ docker compose down # остановка (данные сохраняютс
|
||||
| `GET/POST/PUT/DELETE` | `/api/share/...`, `/api/links` | Публичные ссылки |
|
||||
| `GET` | `/api/backup` | Скачать бэкап |
|
||||
| `POST` | `/api/restore` | Восстановить из бэкапа |
|
||||
| `GET` | `/api/notifications` | Уведомления пользователя (`limit`, `offset`, `unread=1`) |
|
||||
| `GET` | `/api/notifications/stream` | SSE-поток уведомлений (заголовок `X-Auth-Token` или `?token=`) |
|
||||
| `GET` | `/api/notifications/meta` | Каталог типов событий и текущие переключатели (admin) |
|
||||
| `POST` | `/api/notifications/:id/read`, `/api/notifications/read-all` | Отметить прочитанным |
|
||||
| `POST` | `/api/notifications/test` | Тестовое уведомление (admin) |
|
||||
| `DELETE` | `/api/notifications/:id`, `/api/notifications` | Удалить уведомление / очистить все (admin) |
|
||||
| `GET` | `/api/dashboard`, `/api/stats` | Статистика |
|
||||
|
||||
Защищённые админ-маршруты требуют заголовок `X-Admin-Token` с `ADMIN_PASSWORD`.
|
||||
Авторизация — по сессиям, не по статическому токену:
|
||||
|
||||
1. `POST /api/auth/login` с `username` и `password` возвращает `{ token, expires_at }`.
|
||||
2. Токен передаётся в заголовке `X-Auth-Token` во все защищённые запросы; `POST /api/auth/logout` удаляет сессию.
|
||||
3. `GET /api/auth/me` — текущий пользователь (`id`, `username`, `role`, `is_active`, `branch_ids`).
|
||||
|
||||
Заголовок `X-Admin-Token` больше не поддерживается. Маршруты помечены `requireAuth` (любой активный пользователь) или `requireAdmin` (только `role = admin`); филиалы не-admin ограничены его `user_branches`.
|
||||
|
||||
Защищённые маршруты:
|
||||
|
||||
| Метод | Путь | Доступ |
|
||||
|---|---|---|
|
||||
| `GET/POST/PUT/DELETE` | `/api/users`, `/api/users/:id` | admin |
|
||||
| `GET/POST/DELETE` | `/api/bans` | admin |
|
||||
| `GET/POST/PUT/DELETE` | `/api/branches`, `/api/branches/:id` | admin (список — любой активный) |
|
||||
| `GET/POST/DELETE` | `/api/settings` | admin |
|
||||
| `GET` | `/api/audit` | admin |
|
||||
| `GET` | `/api/audit/:id` | admin (полный target с текстовым диффом) |
|
||||
| `GET` | `/api/backup`, `POST /api/restore` | admin |
|
||||
|
||||
Сессия хранится в таблице `sessions` (срок 30 дней) и кэшируется в Redis на 30 секунд.
|
||||
|
||||
### Внешний API и API-ключи
|
||||
|
||||
Для интеграций с внешними системами есть отдельный префикс `/api/v1` и собственная авторизация — **API-ключи**. Ключи создаются в админке: **API-ключи** в боковом меню (`public/apikeys.html`), либо через `GET/POST/PUT/DELETE /api/api-keys` (администратор).
|
||||
|
||||
Ключ передаётся в заголовке `X-Api-Key` или `Authorization: Bearer <ключ>`:
|
||||
|
||||
```bash
|
||||
curl -H "X-Api-Key: wsk_ВАШ_КЛЮЧ" https://ВАШ_ДОМЕН/api/v1/groups
|
||||
curl -H "Authorization: Bearer wsk_ВАШ_КЛЮЧ" https://ВАШ_ДОМЕН/api/v1/stats
|
||||
```
|
||||
|
||||
Секрет показывается **один раз** — при создании и при перевыпуске (`⟳` в таблице). В базе хранится только SHA-256 хеш, поэтому восстановить ключ нельзя: если он потерян или утёк, выпустите новый, а старый удалите.
|
||||
|
||||
| Метод | Путь | Право |
|
||||
| --- | --- | --- |
|
||||
| `GET` | `/api/v1/me` | чтение |
|
||||
| `GET` | `/api/v1/branches` | чтение |
|
||||
| `GET` | `/api/v1/groups`, `/groups/:id` | чтение |
|
||||
| `GET` | `/api/v1/students`, `/students/:id` | чтение |
|
||||
| `POST`, `PUT` | `/api/v1/students[/:id]` | запись |
|
||||
| `GET` | `/api/v1/modules` | чтение |
|
||||
| `GET` | `/api/v1/entries`, `/entries/:id`, `/entries/:id/files` | чтение |
|
||||
| `POST`, `PUT`, `DELETE` | `/api/v1/entries[/:id]` | запись |
|
||||
| `GET` | `/api/v1/lesson-reports`, `/lesson-reports/:id` | чтение |
|
||||
| `POST`, `PUT`, `DELETE` | `/api/v1/lesson-reports[/:id]` | запись |
|
||||
| `GET` | `/api/v1/stats` | чтение |
|
||||
| `POST` | `/api/v1/ai/wake`, `/photo-jobs/wake` | запись |
|
||||
| `POST` | `/api/v1/ai/requeue-failed`, `/photo-jobs/requeue-failed` | запись |
|
||||
| `POST` | `/api/v1/entries/:id/ai/recheck` | запись |
|
||||
|
||||
Списки возвращают единый формат `{ items, total, limit, offset }`; поддерживаются `limit`/`offset` (до 500) и фильтры (`group_id`, `module_id`, `student_name`, `search`, `date_from`, `date_to`).
|
||||
|
||||
Управление ИИ-воркерами:
|
||||
|
||||
- `POST /ai/wake` и `POST /photo-jobs/wake` — разбудить воркер проверки текста записей и фото-воркер. Это только пинок: задачи всё равно подхватятся по своему циклу опроса, задержка возможна при недоступном Redis.
|
||||
- `POST /ai/requeue-failed` и `POST /photo-jobs/requeue-failed` — вернуть в очередь задания со статусом `error`; в ответе `{ ok, count }`.
|
||||
- `POST /entries/:id/ai/recheck` — отправить конкретную запись на повторную ИИ-проверку.
|
||||
|
||||
Все четыре требуют скоуп `write`. Массовые операции уважают филиалы ключа: `requeue-failed` переочередит только записи и фото-задания в доступных филиалах, а не во всей системе. Воркер отчётов о занятии отдельного `wake`-эндпоинта не имеет — он будится сам при `POST`/`PUT /lesson-reports` с `ai_check: true` (значение должно быть именно boolean `true`).
|
||||
|
||||
Меры безопасности:
|
||||
|
||||
- **Права**: у ключа есть скоупы `read` и `write`; без `write` все изменения возвращают `403`.
|
||||
- **Филиалы**: ключ можно ограничить конкретными филиалами — он увидит **не больше**, чем доступно выдавшему его пользователю (админский ключ с ограничением теряет доступ ко всем остальным филиалам).
|
||||
- **Срок и лимиты**: у ключа задаются дата окончания и лимит запросов в минуту (по умолчанию 120); при превышении — `429`.
|
||||
- **Отзыв**: удаление ключа действует немедленно; старый ключ перестаёт работать и после ротации.
|
||||
- **Подбор ключей** считается, при частых неудачах IP получает бан.
|
||||
- **Аудит**: все изменения, сделанные через API, попадают в аудит с пометкой `via_api_key`.
|
||||
- **Изоляция**: ключ работает только в `/api/v1/*` и не открывает доступ к админ-панели.
|
||||
- **Бэкап**: ключи не входят в архив — после восстановления их нужно выпустить заново.
|
||||
|
||||
Проверка:
|
||||
|
||||
```bash
|
||||
node api-keys.selftest.js
|
||||
```
|
||||
|
||||
## Структура проекта
|
||||
|
||||
```
|
||||
├── docker-compose.yml # сервисы app + db
|
||||
├── Dockerfile # сборка образа (Node 20, генерация TLS-сертификата)
|
||||
├── docker-compose.yml # сервисы: app + db + redis + s3 (+ опционально tailscale)
|
||||
├── docker-compose.minio.yml # оверрайд: S3-сервис на MinIO вместо SeaweedFS
|
||||
├── .env.example # шаблон переменных окружения
|
||||
├── Dockerfile # сборка образа (Node 22, генерация TLS-сертификата)
|
||||
├── server.js # Express-приложение
|
||||
├── storage.js # абстракция хранилища: драйверы local и s3
|
||||
├── redis.js # абстракция Redis: кэш, счётчики, rate limit, pub/sub (с in-memory fallback)
|
||||
├── redis.selftest.js # тесты слоя Redis, включая деградацию при недоступном сервере
|
||||
├── diff.js # пословный diff текста и сборка изменений записи для аудита
|
||||
├── diff.selftest.js # тесты diff.js (вставки, удаления, большие тексты, обрезка)
|
||||
├── api.smoketest.js # сквозная проверка API по поднятому стеку
|
||||
├── api-keys.selftest.js # тесты внешнего API: ключи, права, филиалы, rate limit
|
||||
├── worker.js # фоновый worker AI-проверки и ИИ-улучшения фото
|
||||
├── certs/ # cert.pem приложения (монтируется в tailscale, в git не хранится)
|
||||
├── db/
|
||||
│ ├── init.sql # схема при первом запуске
|
||||
│ └── migration.sql # миграции существующей БД
|
||||
├── public/ # статика (HTML/CSS/JS админки и витрин)
|
||||
├── scripts/ # вспомогательные скрипты
|
||||
├── uploads/ # загруженные файлы (bind-монт, вне git)
|
||||
├── scripts/ # вспомогательные скрипты (backup/restore/deploy, migrate-to-s3, storage-sync)
|
||||
├── uploads/ # локальные файлы и кэш миниатюр (bind-монт, вне git)
|
||||
└── backups/ # локальные бэкапы
|
||||
```
|
||||
```
|
||||
@@ -0,0 +1,248 @@
|
||||
# Аудит безопасности и антиспама — WhatIDo
|
||||
|
||||
> Приложение предназначено для публичного развёртывания. Ниже — результаты аудита
|
||||
> по уровню важности: 🔴 критично (исправить обязательно), 🟠 высокий приоритет,
|
||||
> 🟡 средний приоритет, 🔵 замечания по антиспаму.
|
||||
|
||||
---
|
||||
|
||||
## 🔴 КРИТИЧНО (исправить обязательно перед публикацией)
|
||||
|
||||
### 1. Админ-токен в открытом виде + уязвимость по времени
|
||||
**Файл:** `server.js:52-56`
|
||||
```js
|
||||
function requireAdmin(req, res, next) {
|
||||
const token = req.headers['x-admin-token'];
|
||||
if (token !== ADMIN_PASSWORD) return res.status(401).json({ error: 'Unauthorized' });
|
||||
next();
|
||||
}
|
||||
```
|
||||
- Токен сравнивается через `===` — **уязвим к атакам по времени** (timing attack).
|
||||
- Нет хеширования (bcrypt/argon2) — при утечке `.env` или логов токен сразу компрометирован.
|
||||
- Реальный пароль лежит в `.env` на диске.
|
||||
|
||||
**Рекомендация:** использовать `crypto.timingSafeEqual` и хранить bcrypt-хеш.
|
||||
|
||||
---
|
||||
|
||||
### 2. Файлы доступны анонимно по токену
|
||||
**Статус:** 🟢 Частично исправлено (share-файлы привязаны к ссылке)
|
||||
- ✅ Публичные share-файлы теперь отдаются **только** через `GET /api/share/:shareToken/files/:fileToken`, где проверяется принадлежность файла к записям активной ссылки (expiry, пароль, фильтры группы/имени/периода) — `server.js:612-647`.
|
||||
- ⚠️ `GET /api/files/:token` и статика `/uploads/*` по-прежнему доступны по токену (нужны для админ-панели). Токены криптостойкие (16 байт hex).
|
||||
|
||||
**Файлы:** `server.js:612-647`, `public/share.html:106-121`
|
||||
|
||||
---
|
||||
|
||||
### 3. Публичные share-ссылки раскрывают ПИД (персональные данные)
|
||||
**Файл:** `server.js:480-531`
|
||||
- `/api/share/:token` возвращает: имена учеников, фото, описания, файлы проектов.
|
||||
- Нет срока действия ссылки (`expires_at`).
|
||||
- Юридический риск (152-ФЗ, GDPR) — данные детей в открытую.
|
||||
|
||||
**Рекомендация:** добавить `expires_at` в `share_links`, опцию анонимизации имён, требование пароля к ссылке.
|
||||
|
||||
---
|
||||
|
||||
## 🟠 ВЫСОКИЙ ПРИОРИТЕТ
|
||||
|
||||
### 4. Нет IP-based rate limit на публичный POST
|
||||
**Файл:** `server.js:969`
|
||||
- Только интервал по имени студента (`spam_interval_min`, дефолт 30 мин).
|
||||
- Бот может менять имена — ограничение обходится.
|
||||
- Нет лимита по IP — легко завалить сервер или перебрать `/api/files/:token`.
|
||||
|
||||
**Рекомендация:** добавить `express-rate-limit` по IP на `POST /api/entries`, `GET /api/share/:token`, `GET /api/files/:token`.
|
||||
|
||||
---
|
||||
|
||||
### 5. Нет honeypot / CAPTCHA
|
||||
**Файл:** `public/index.html:305`
|
||||
- Форма отправки полностью открыта для ботов.
|
||||
- Скрытое поле-ловушка (honeypot) остановит 90% простых ботов.
|
||||
|
||||
**Рекомендация:** добавить `<input name="website" style="display:none" tabindex="-1" autocomplete="off">` и проверку на сервере.
|
||||
|
||||
---
|
||||
|
||||
### 6. Restore бэкапа загружает 300 МБ в память
|
||||
**Файл:** `server.js:191-194`
|
||||
```js
|
||||
const uploadBackup = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
limits: { fileSize: 300 * 1024 * 1024 },
|
||||
});
|
||||
```
|
||||
- `memoryStorage()` — под нагрузкой DoS (OOM killer).
|
||||
- Архив не проверяется на содержимое до распаковки.
|
||||
|
||||
**Рекомендация:** использовать `diskStorage` во временную директорию, лимит 50 МБ.
|
||||
|
||||
---
|
||||
|
||||
### 7. CSP отключён
|
||||
**Файл:** `server.js:47`
|
||||
```js
|
||||
app.use(helmet({ contentSecurityPolicy: false }));
|
||||
```
|
||||
- Весь фронтенд на inline-скриптах и атрибутных обработчиках — строгий CSP их заблокирует.
|
||||
- Без CSP — риск XSS через инъекции в ошибки/настройки.
|
||||
|
||||
**Рекомендация:** рефакторинг фронтенда на внешние JS-файлы → включить CSP.
|
||||
|
||||
---
|
||||
|
||||
### 8. Сравнение токена без timing-safe
|
||||
**Файл:** `server.js:54`
|
||||
- `token !== ADMIN_PASSWORD` — уязвим к timing attack.
|
||||
|
||||
**Рекомендация:** `crypto.timingSafeEqual(Buffer.from(token), Buffer.from(ADMIN_PASSWORD))`.
|
||||
|
||||
---
|
||||
|
||||
### 9. Поле `files` принимает любые расширения
|
||||
**Файл:** `server.js:83-93`
|
||||
- `fileFilter` проверяет только `photo` (image MIME).
|
||||
- Поле `files` принимает **что угодно** — `.html`, `.js`, `.svg` (SVG может содержать JS).
|
||||
- Имена генерируются случайно, но при угадывании токена — вредоносный файл отдаётся как есть.
|
||||
|
||||
**Рекомендация:** добавить тот же блок-лист расширений для `files`, отдавать как `download` (не inline).
|
||||
|
||||
---
|
||||
|
||||
### 10. Нет лимита на размер JSON-body
|
||||
**Файл:** `server.js:48`
|
||||
```js
|
||||
app.use(express.json());
|
||||
```
|
||||
- Нет `limit` — можно слать огромные JSON, забивать память.
|
||||
|
||||
**Рекомендация:** `express.json({ limit: '1mb' })`.
|
||||
|
||||
---
|
||||
|
||||
## 🟡 СРЕДНИЙ ПРИОРИТЕТ
|
||||
|
||||
| # | Проблема | Файл/Место |
|
||||
|---|----------|------------|
|
||||
| 11 | Share-ссылки никогда не истекают | `db/init.sql:32-41` — нет `expires_at` |
|
||||
| 12 | Нет аудит-лога админ-действий | — |
|
||||
| 13 | Стектрейсы утекают в non-production | `server.js:1155-1157` |
|
||||
| 14 | Нет HSTS / secure cookies / принудительного HTTPS | `server.js:1169-1180` |
|
||||
| 15 | Имена учеников в URL параметрах share | `public/share.html:105` |
|
||||
| 16 | Отсутствует валидация `spam_interval_min` ≥ 1 | `server.js:1004` — можно поставить 0 и отключить антиспам |
|
||||
|
||||
---
|
||||
|
||||
## 🔵 АНТИСПАМ — ПРОБЕЛЫ
|
||||
|
||||
| Мера | Статус | Что нужно |
|
||||
|------|--------|-----------|
|
||||
| IP-based rate limit | ❌ | `rateLimit` по IP на `/api/entries` |
|
||||
| Honeypot поле | ❌ | Скрытый input в форме + проверка сервером |
|
||||
| CAPTCHA / Turnstile | ❌ | Опционально: Cloudflare Turnstile |
|
||||
| Мин. интервал спама | ⚠️ Можно 0 | Валидация: минимум 1 минута |
|
||||
| Квота суммарного объёма на IP/день | ❌ | Добавить (напр. 100 МБ/день) |
|
||||
| Лимит файлов на запрос | ✅ 10 файлов | Оставить |
|
||||
| Суммарный размер на запрос | ✅ 30 МБ | Оставить |
|
||||
|
||||
---
|
||||
|
||||
## ✅ УЖЕ ИСПРАВЛЕНО (подтверждено в текущем коде)
|
||||
|
||||
- ❌ Убран фолбэк-пароль `'admin'` — старт невозможен без `ADMIN_PASSWORD`
|
||||
- ❌ CORS полностью удалён
|
||||
- ❌ Порт БД 5432 не опубликован, креды из `.env`
|
||||
- ❌ `escapeHtml` исправлен (`&`)
|
||||
- ❌ `express-rate-limit` на API роутах
|
||||
- ❌ Валидация restore-данных + безопасный `safeUnlink` (path traversal защита)
|
||||
- ❌ `helmet` + security-заголовки (кроме CSP)
|
||||
- ❌ Блок-лист расширений загрузки + лимит 30 МБ/запись
|
||||
- ❌ Параметризованные запросы (нет SQL-инъекций)
|
||||
- ❌ Токены файлов криптостойкие (16 байт hex)
|
||||
- ❌ Файлы не перезаписываются (случайные имена)
|
||||
- ❌ Multer-лимиты на размеры есть
|
||||
|
||||
---
|
||||
|
||||
## 📋 ПЛАН ДЕЙСТВИЙ (must-do перед публикацией)
|
||||
|
||||
### Фаза 1 — Критично (до публичного запуска)
|
||||
1. **Хеш админ-токена** (bcrypt) + `crypto.timingSafeEqual` для сравнения
|
||||
2. **Защита файлов** — отдача только в контексте валидной share-ссылки или подписанные URL
|
||||
3. **IP rate limit** на `POST /api/entries` (10 req / 15 мин на IP)
|
||||
4. **Honeypot** в публичной форме
|
||||
5. **Backup restore на диск** (diskStorage, лимит 50 МБ)
|
||||
6. **Мин. spam_interval_min = 1** (валидация в settings)
|
||||
|
||||
### Фаза 2 — Укрепление (высокий приоритет)
|
||||
7. **CSP** — рефакторинг inline-скриптов → внешние файлы
|
||||
8. **HSTS** через reverse-proxy (Caddy/nginx)
|
||||
9. **Блок-лист расширений для `files`** + отдача как download
|
||||
10. **JSON body limit** (`1mb`)
|
||||
11. **`expires_at` для share_links** + опция анонимизации
|
||||
12. **Аудит-лог** админ-действий (логин, CRUD, backup/restore)
|
||||
|
||||
### Фаза 3 — Приватность и соответствие
|
||||
13. **Анонимизация share-ссылок** (опция скрыть имена)
|
||||
14. **Политика хранения** — автоудаление старых записей
|
||||
15. **Privacy notice** на публичной форме
|
||||
|
||||
---
|
||||
|
||||
## БЫСТРЫЕ ПОБЕДЫ (можно внедрить сегодня)
|
||||
|
||||
```javascript
|
||||
// 1. Timing-safe админ-проверка (server.js:52-56)
|
||||
const crypto = require('crypto');
|
||||
function requireAdmin(req, res, next) {
|
||||
const token = req.headers['x-admin-token'];
|
||||
const expected = Buffer.from(ADMIN_PASSWORD);
|
||||
const provided = Buffer.from(token || '');
|
||||
if (provided.length !== expected.length || !crypto.timingSafeEqual(provided, expected)) {
|
||||
return res.status(401).json({ error: 'Unauthorized' });
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
// 2. IP rate limit на публичную запись (server.js:969)
|
||||
const entryIpLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: 10,
|
||||
keyGenerator: req => req.ip,
|
||||
message: { error: 'Too many submissions from this IP' }
|
||||
});
|
||||
app.post('/api/entries', entryIpLimiter, entryLimiter, ...);
|
||||
|
||||
// 3. Honeypot в форме (index.html) — скрытое поле
|
||||
// <input type="text" name="website" tabindex="-1" autocomplete="off" style="display:none">
|
||||
// В хендлере: if (req.body.website) return res.status(400).json({ error: 'Spam detected' });
|
||||
|
||||
// 4. JSON body limit (server.js:48)
|
||||
app.use(express.json({ limit: '1mb' }));
|
||||
|
||||
// 5. Блок-лист для project files (server.js:83-93)
|
||||
const BLOCKED_EXT = /\.(?:html?|js|mjs|cjs|svg|xml|json|map|wasm|php\d?|phtml|asp|aspx|jsp|sh|bat|cmd|cgi|exe|dll|com|msi|scr|hta|vbs|py|r|rb|htaccess)$/i;
|
||||
if (file.fieldname === 'files' && ext && BLOCKED_EXT.test(ext)) return cb(new Error('Not allowed extension'));
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Файлы для доработки (приоритет)
|
||||
|
||||
| Файл | Что править |
|
||||
|------|-------------|
|
||||
| `server.js:52-56` | timing-safe compare, bcrypt-хеш |
|
||||
| `server.js:969` | IP rate limiter + honeypot проверка |
|
||||
| `server.js:191-194` | diskStorage для backup restore |
|
||||
| `server.js:83-93` | блок-лист для `files` |
|
||||
| `server.js:48` | `express.json({ limit: '1mb' })` |
|
||||
| `server.js:1004` | валидация `spam_interval_min >= 1` |
|
||||
| `public/index.html` | honeypot input |
|
||||
| `db/init.sql` | добавить `expires_at` в `share_links` |
|
||||
| `docker-compose.yml` | раскомментировать Caddy для TLS |
|
||||
|
||||
---
|
||||
|
||||
*Аудит выполнен: 2026-09-07*
|
||||
*Статус: готово к внедрению Фазы 1*
|
||||
@@ -0,0 +1,249 @@
|
||||
# Аудит безопасности и антиспама — WhatIDo
|
||||
|
||||
> Приложение предназначено для публичного развёртывания. Ниже — результаты аудита
|
||||
> по уровню важности: 🔴 критично (исправить обязательно), 🟠 высокий приоритет,
|
||||
> 🟡 средний приоритет, 🔵 замечания по антиспаму.
|
||||
|
||||
---
|
||||
|
||||
## 🔴 КРИТИЧНО (исправить обязательно перед публикацией)
|
||||
|
||||
### 1. Админ-токен в открытом виде + уязвимость по времени
|
||||
**Файл:** `server.js:52-56`
|
||||
```js
|
||||
function requireAdmin(req, res, next) {
|
||||
const token = req.headers['x-admin-token'];
|
||||
if (token !== ADMIN_PASSWORD) return res.status(401).json({ error: 'Unauthorized' });
|
||||
next();
|
||||
}
|
||||
```
|
||||
- Токен сравнивается через `===` — **уязвим к атакам по времени** (timing attack).
|
||||
- Нет хеширования (bcrypt/argon2) — при утечке `.env` или логов токен сразу компрометирован.
|
||||
- Реальный пароль лежит в `.env` на диске.
|
||||
|
||||
**Рекомендация:** использовать `crypto.timingSafeEqual` и хранить bcrypt-хеш.
|
||||
|
||||
---
|
||||
|
||||
### 2. Файлы доступны анонимно по токену
|
||||
**Файлы:** `server.js:877-885`, `server.js:49`
|
||||
- `GET /api/files/:token` и статика `/uploads/*` отдают любой файл любому, кто знает токен.
|
||||
- Токены криптостойкие (32 hex), но **фото детей и учебные проекты не должны быть публично доступны по угадываемому ключу**.
|
||||
- Share-ссылки (`/api/share/:token`) также выдают все файлы записи.
|
||||
|
||||
**Рекомендация:** отдавать файлы только в контексте действующей share-ссылки (проверка принадлежности entry к ссылке) либо подписанные URL с TTL.
|
||||
|
||||
---
|
||||
|
||||
### 3. Публичные share-ссылки раскрывают ПИД (персональные данные)
|
||||
**Файл:** `server.js:480-531`
|
||||
- `/api/share/:token` возвращает: имена учеников, фото, описания, файлы проектов.
|
||||
- Нет срока действия ссылки (`expires_at`).
|
||||
- Юридический риск (152-ФЗ, GDPR) — данные детей в открытую.
|
||||
|
||||
**Рекомендация:** добавить `expires_at` в `share_links`, опцию анонимизации имён, требование пароля к ссылке.
|
||||
|
||||
---
|
||||
|
||||
## 🟠 ВЫСОКИЙ ПРИОРИТЕТ
|
||||
|
||||
### 4. Нет IP-based rate limit на публичный POST
|
||||
**Файл:** `server.js:969`
|
||||
- Только интервал по имени студента (`spam_interval_min`, дефолт 30 мин).
|
||||
- Бот может менять имена — ограничение обходится.
|
||||
- Нет лимита по IP — легко завалить сервер или перебрать `/api/files/:token`.
|
||||
|
||||
**Рекомендация:** добавить `express-rate-limit` по IP на `POST /api/entries`, `GET /api/share/:token`, `GET /api/files/:token`.
|
||||
|
||||
---
|
||||
|
||||
### 5. Нет honeypot / CAPTCHA
|
||||
**Файл:** `public/index.html:305`
|
||||
- Форма отправки полностью открыта для ботов.
|
||||
- Скрытое поле-ловушка (honeypot) остановит 90% простых ботов.
|
||||
|
||||
**Рекомендация:** добавить `<input name="website" style="display:none" tabindex="-1" autocomplete="off">` и проверку на сервере.
|
||||
|
||||
---
|
||||
|
||||
### 6. Restore бэкапа загружает 300 МБ в память
|
||||
**Файл:** `server.js:191-194`
|
||||
```js
|
||||
const uploadBackup = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
limits: { fileSize: 300 * 1024 * 1024 },
|
||||
});
|
||||
```
|
||||
- `memoryStorage()` — под нагрузкой DoS (OOM killer).
|
||||
- Архив не проверяется на содержимое до распаковки.
|
||||
|
||||
**Рекомендация:** использовать `diskStorage` во временную директорию, лимит 50 МБ.
|
||||
|
||||
---
|
||||
|
||||
### 7. CSP отключён
|
||||
**Файл:** `server.js:47`
|
||||
```js
|
||||
app.use(helmet({ contentSecurityPolicy: false }));
|
||||
```
|
||||
- Весь фронтенд на inline-скриптах и атрибутных обработчиках — строгий CSP их заблокирует.
|
||||
- Без CSP — риск XSS через инъекции в ошибки/настройки.
|
||||
|
||||
**Рекомендация:** рефакторинг фронтенда на внешние JS-файлы → включить CSP.
|
||||
|
||||
---
|
||||
|
||||
### 8. Сравнение токена без timing-safe
|
||||
**Файл:** `server.js:54`
|
||||
- `token !== ADMIN_PASSWORD` — уязвим к timing attack.
|
||||
|
||||
**Рекомендация:** `crypto.timingSafeEqual(Buffer.from(token), Buffer.from(ADMIN_PASSWORD))`.
|
||||
|
||||
---
|
||||
|
||||
### 9. Поле `files` принимает любые расширения
|
||||
**Файл:** `server.js:83-93`
|
||||
- `fileFilter` проверяет только `photo` (image MIME).
|
||||
- Поле `files` принимает **что угодно** — `.html`, `.js`, `.svg` (SVG может содержать JS).
|
||||
- Имена генерируются случайно, но при угадывании токена — вредоносный файл отдаётся как есть.
|
||||
|
||||
**Рекомендация:** добавить тот же блок-лист расширений для `files`, отдавать как `download` (не inline).
|
||||
|
||||
---
|
||||
|
||||
### 10. Нет лимита на размер JSON-body
|
||||
**Файл:** `server.js:48`
|
||||
```js
|
||||
app.use(express.json());
|
||||
```
|
||||
- Нет `limit` — можно слать огромные JSON, забивать память.
|
||||
|
||||
**Рекомендация:** `express.json({ limit: '1mb' })`.
|
||||
|
||||
---
|
||||
|
||||
## 🟡 СРЕДНИЙ ПРИОРИТЕТ
|
||||
|
||||
| # | Проблема | Файл/Место |
|
||||
|---|----------|------------|
|
||||
| 11 | Share-ссылки никогда не истекают | `db/init.sql:32-41` — нет `expires_at` |
|
||||
| 12 | Нет аудит-лога админ-действий | — |
|
||||
| 13 | Стектрейсы утекают в non-production | `server.js:1155-1157` |
|
||||
| 14 | Нет HSTS / secure cookies / принудительного HTTPS | `server.js:1169-1180` |
|
||||
| 15 | Имена учеников в URL параметрах share | `public/share.html:105` |
|
||||
| 16 | Отсутствует валидация `spam_interval_min` ≥ 1 | `server.js:1004` — можно поставить 0 и отключить антиспам |
|
||||
|
||||
---
|
||||
|
||||
## 🔵 АНТИСПАМ — ПРОБЕЛЫ
|
||||
|
||||
| Мера | Статус | Что нужно |
|
||||
|------|--------|-----------|
|
||||
| IP-based rate limit | ❌ | `rateLimit` по IP на `/api/entries` |
|
||||
| Honeypot поле | ❌ | Скрытый input в форме + проверка сервером |
|
||||
| CAPTCHA / Turnstile | ❌ | Опционально: Cloudflare Turnstile |
|
||||
| Мин. интервал спама | ⚠️ Можно 0 | Валидация: минимум 1 минута |
|
||||
| Квота суммарного объёма на IP/день | ❌ | Добавить (напр. 100 МБ/день) |
|
||||
| Лимит файлов на запрос | ✅ 10 файлов | Оставить |
|
||||
| Суммарный размер на запрос | ✅ 30 МБ | Оставить |
|
||||
|
||||
---
|
||||
|
||||
## ✅ УЖЕ ИСПРАВЛЕНО (подтверждено в текущем коде)
|
||||
|
||||
- ❌ Убран фолбэк-пароль `'admin'` — старт невозможен без `ADMIN_PASSWORD`
|
||||
- ❌ CORS полностью удалён
|
||||
- ❌ Порт БД 5432 не опубликован, креды из `.env`
|
||||
- ❌ `escapeHtml` исправлен (`&`)
|
||||
- ❌ `express-rate-limit` на API роутах
|
||||
- ❌ Валидация restore-данных + безопасный `safeUnlink` (path traversal защита)
|
||||
- ❌ `helmet` + security-заголовки (кроме CSP)
|
||||
- ❌ Блок-лист расширений загрузки + лимит 30 МБ/запись
|
||||
- ❌ Параметризованные запросы (нет SQL-инъекций)
|
||||
- ❌ Токены файлов криптостойкие (16 байт hex)
|
||||
- ❌ Файлы не перезаписываются (случайные имена)
|
||||
- ❌ Multer-лимиты на размеры есть
|
||||
|
||||
---
|
||||
|
||||
## 📋 ПЛАН ДЕЙСТВИЙ (must-do перед публикацией)
|
||||
|
||||
### Фаза 1 — Критично (до публичного запуска)
|
||||
1. **Хеш админ-токена** (bcrypt) + `crypto.timingSafeEqual` для сравнения
|
||||
2. **Защита файлов** — отдача только в контексте валидной share-ссылки или подписанные URL
|
||||
3. **IP rate limit** на `POST /api/entries` (10 req / 15 мин на IP)
|
||||
4. **Honeypot** в публичной форме
|
||||
5. **Backup restore на диск** (diskStorage, лимит 50 МБ)
|
||||
6. **Мин. spam_interval_min = 1** (валидация в settings)
|
||||
|
||||
### Фаза 2 — Укрепление (высокий приоритет)
|
||||
7. **CSP** — рефакторинг inline-скриптов → внешние файлы
|
||||
8. **HSTS** через reverse-proxy (Caddy/nginx)
|
||||
9. **Блок-лист расширений для `files`** + отдача как download
|
||||
10. **JSON body limit** (`1mb`)
|
||||
11. **`expires_at` для share_links** + опция анонимизации
|
||||
12. **Аудит-лог** админ-действий (логин, CRUD, backup/restore)
|
||||
|
||||
### Фаза 3 — Приватность и соответствие
|
||||
13. **Анонимизация share-ссылок** (опция скрыть имена)
|
||||
14. **Политика хранения** — автоудаление старых записей
|
||||
15. **Privacy notice** на публичной форме
|
||||
|
||||
---
|
||||
|
||||
## БЫСТРЫЕ ПОБЕДЫ (можно внедрить сегодня)
|
||||
|
||||
```javascript
|
||||
// 1. Timing-safe админ-проверка (server.js:52-56)
|
||||
const crypto = require('crypto');
|
||||
function requireAdmin(req, res, next) {
|
||||
const token = req.headers['x-admin-token'];
|
||||
const expected = Buffer.from(ADMIN_PASSWORD);
|
||||
const provided = Buffer.from(token || '');
|
||||
if (provided.length !== expected.length || !crypto.timingSafeEqual(provided, expected)) {
|
||||
return res.status(401).json({ error: 'Unauthorized' });
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
// 2. IP rate limit на публичную запись (server.js:969)
|
||||
const entryIpLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: 10,
|
||||
keyGenerator: req => req.ip,
|
||||
message: { error: 'Too many submissions from this IP' }
|
||||
});
|
||||
app.post('/api/entries', entryIpLimiter, entryLimiter, ...);
|
||||
|
||||
// 3. Honeypot в форме (index.html) — скрытое поле
|
||||
// <input type="text" name="website" tabindex="-1" autocomplete="off" style="display:none">
|
||||
// В хендлере: if (req.body.website) return res.status(400).json({ error: 'Spam detected' });
|
||||
|
||||
// 4. JSON body limit (server.js:48)
|
||||
app.use(express.json({ limit: '1mb' }));
|
||||
|
||||
// 5. Блок-лист для project files (server.js:83-93)
|
||||
const BLOCKED_EXT = /\.(?:html?|js|mjs|cjs|svg|xml|json|map|wasm|php\d?|phtml|asp|aspx|jsp|sh|bat|cmd|cgi|exe|dll|com|msi|scr|hta|vbs|py|r|rb|htaccess)$/i;
|
||||
if (file.fieldname === 'files' && ext && BLOCKED_EXT.test(ext)) return cb(new Error('Not allowed extension'));
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Файлы для доработки (приоритет)
|
||||
|
||||
| Файл | Что править |
|
||||
|------|-------------|
|
||||
| `server.js:52-56` | timing-safe compare, bcrypt-хеш |
|
||||
| `server.js:969` | IP rate limiter + honeypot проверка |
|
||||
| `server.js:191-194` | diskStorage для backup restore |
|
||||
| `server.js:83-93` | блок-лист для `files` |
|
||||
| `server.js:48` | `express.json({ limit: '1mb' })` |
|
||||
| `server.js:1004` | валидация `spam_interval_min >= 1` |
|
||||
| `public/index.html` | honeypot input |
|
||||
| `db/init.sql` | добавить `expires_at` в `share_links` |
|
||||
| `docker-compose.yml` | раскомментировать Caddy для TLS |
|
||||
|
||||
---
|
||||
|
||||
*Аудит выполнен: 2026-09-07*
|
||||
*Статус: готово к внедрению Фазы 1*
|
||||
@@ -0,0 +1,741 @@
|
||||
# TODO: фото-ИИ с восстановлением лиц (Real-ESRGAN + GFPGAN/CodeFormer), авто-выбор GPU/CPU
|
||||
|
||||
Источник требований: `PLAN_PHOTO_FACE_AI.md`. Этот файл — **исполняемый чек-лист для агента**,
|
||||
который пишет код. План не дублируется: здесь только задачи, якоря в коде, контракты и приёмка.
|
||||
|
||||
Перед стартом агент обязан прочитать `AGENTS.md` (правила проекта) и `PLAN_PHOTO_FACE_AI.md` целиком.
|
||||
|
||||
---
|
||||
|
||||
## 0. Жёсткие инварианты (нарушение = регресс, работа не принята)
|
||||
|
||||
- [x] **I1. Обратная совместимость `/enhance`.** Запрос только с `image` + `scale=2` (без `model`/`face`)
|
||||
обязан давать тот же JPEG, что и до изменений: `x2plus`, `half=False` на CPU, `IMWRITE_JPEG_QUALITY=92`.
|
||||
Проверяется сравнением с эталоном из Stage 0. Эталон снят (см. «Журнал раздела 0»), прогон
|
||||
`node backups/photo-face-ai-baseline/capture.js <label> && node backups/photo-face-ai-baseline/verify.js <label>`
|
||||
даёт байт-в-байт совпадение; повторная проверка обязательна на приёмке Stage 1.
|
||||
- [x] **I2. Старые задания.** `photo_jobs` со `params IS NULL` и `action='ai'` продолжают обрабатываться
|
||||
воркером как раньше (дефолты подставляются на стороне воркера/сервиса).
|
||||
- [x] **I3. photo-ai не обязателен.** Пустой `PHOTO_AI_URL` → кнопка «🤖 ИИ» скрыта
|
||||
(`public/js/journal.js:718`), `POST /api/entries/:id/photo/enhance-ai` → `503`
|
||||
(`server.js:5115`), приложение полностью работоспособно.
|
||||
- [x] **I4. GPU не обязателен.** Нет `nvidia-ctk` / нет CUDA → контейнер поднимается, `PHOTO_AI_DEVICE=cuda`
|
||||
даёт WARN в лог и молчаливый откат на CPU. Падение `/health` из-за отсутствия GPU недопустимо.
|
||||
Проверено на этом хосте: `nvidia-ctk` **отсутствует**, nvidia-runtime в `docker info` нет, `photo-ai`
|
||||
поднялся и `/health` → `{"ok":true}` (GPU недоступен → сервис не падает). Переменная
|
||||
`PHOTO_AI_DEVICE` появится только в Stage 1 — проверка `cuda` → WARN + CPU откат переносится
|
||||
на приёмку Stage 1, сам GPU-режим на этом хосте не проверяется (стоп-условие: нужен
|
||||
`nvidia-container-toolkit`, ставить без разрешения оператора нельзя).
|
||||
- [x] **I5. Воркер не сжигает попытки на «мягких» ошибках.** `503` / недоступный сервис → задание возвращается
|
||||
в `pending` **без** инкремента `attempts`; лимит мягких повторов (например 60) → одна честная ошибка.
|
||||
- [x] **I6. Никаких новых колонок в БД.** Метаданные обработки (модель, устройство, время, `faces_found`)
|
||||
живут в `photo_jobs.params` (JSONB уже есть) и в `audit_log.target`.
|
||||
- [x] **I7. Код без комментариев** (правило `AGENTS.md`), CommonJS в Node-части, параметризованный SQL,
|
||||
никаких прямых `fs.*` по `uploads/` (только `storage.*`).
|
||||
|
||||
### Журнал раздела 0 (проверено 2026-09-28)
|
||||
|
||||
Что сделано, кроме проверки: в `worker.js` устранены два нарушения инвариантов — прямой
|
||||
`fs.writeFileSync` в `uploads/` вместо `storage.put` (I7) и сжигание `attempts` на `503`/недоступности
|
||||
(I5). В `server.js` флаг `photo_ai_enabled` вынесен из кэша `public-settings` (I3). Новые переменные
|
||||
`PHOTO_AI_SOFT_*` описаны в `.env.example`.
|
||||
|
||||
| Инвариант | Как проверено | Результат |
|
||||
|---|---|---|
|
||||
| I1 | `capture.js` → `before/`, повтор `repeat1/`, прогон после правок `after-section0/`; `verify.js` сравнивает sha256 | 5/5 байт-в-байт, `/health` `{"ok":true}` |
|
||||
| I2 | запись `INSERT INTO photo_jobs (entry_id, action, status, params) VALUES (385,'ai','pending',NULL)` | `done`, `attempts=0`, результат отдаётся из S3 (200) |
|
||||
| I3 | `docker compose -f docker-compose.yml -f no-photo-ai.yml up -d app` (`PHOTO_AI_URL: ""`) | `photo_ai_enabled=false`, `enhance-ai` → 503, 8 маршрутов API живы |
|
||||
| I4 | `backups/photo-face-ai-baseline/env.txt` | `nvidia-ctk` NOT_FOUND, `nvidia-container-runtime` NOT_FOUND, runtimes `runc`/`io.containerd.runc.v2`, `photo-ai` Up, `/health` ok |
|
||||
| I5 | `photo-ai` остановлен → задание ждало 70 с → вернулось `pending` при `attempts=0`; после `docker compose start photo-ai` → `done` при `attempts=0`. Лимит проверен прогоном с `PHOTO_AI_SOFT_MAX_RETRIES=2` | мягкий повтор не тратит попытки; после лимита `status=error`, `attempts=0`, текст «мягкие повторы исчерпаны (2)» |
|
||||
| I6 | `git status db/` пуст, `information_schema.columns` для `photo_jobs` — те же 12 колонок; метаданные повторов в `audit_log.target` (`soft_attempt`, `soft_limit`) | без изменений схемы |
|
||||
| I7 | `node --check worker.js server.js api.smoketest.js public/js/audit.js`; `grep` по `worker.js` — ни `require('fs')`, ни `require('path')`, ни `uploadsDir`; в диффе нет строк с комментариями; все SQL — на `$1/$2` | чисто |
|
||||
|
||||
Эталон и скрипты — в `backups/photo-face-ai-baseline/` (вне git): `env.txt`, `inputs/`, `inputs.json`,
|
||||
`before/`, `repeat1/`, `after-section0/`, `capture.js`, `verify.js`, `make-inputs.js`.
|
||||
Тестовые задания (id 90–94), их аудит, уведомления и 3 объекта результата в S3 удалены.
|
||||
|
||||
---
|
||||
|
||||
## 1. Что уже есть (сверено в коде, не перепроверять)
|
||||
|
||||
| Место | Сейчас | Что меняем |
|
||||
|---|---|---|
|
||||
| `photo-ai/app.py:15–73` | одна модель, `MODEL_PATH`, `MAX_INPUT_PIXELS`, `lock`+`upsampler`, `/health` → `{ok}`, `/enhance(image, scale)` | реестр моделей, `pick_device()`, `ModelPool`, расширенные `/health` и `/models` |
|
||||
| `photo-ai/Dockerfile` | `python:3.10-slim`, `torch --index-url .../whl/cpu`, `realesrgan==0.3.0`, патч `basicsr/data/degradations.py` | `ARG TORCH_VARIANT`, `gfpgan`/`facexlib`, вендоринг CodeFormer |
|
||||
| `docker-compose.yml:194–204` | `photo-ai`: `MODEL_PATH`, `MAX_INPUT_PIXELS`, том `photo-ai-models`, `ports: 127.0.0.1:8081:8080` (D1) | новые env + `healthcheck` |
|
||||
| `docker-compose.yml:208` | том `photo-ai-models` | без изменений |
|
||||
| `server.js:1841` | `PHOTO_AI_URL` | + константы `PHOTO_AI_FACE_MODEL`, `PHOTO_AI_FACE_TIMEOUT_MS` |
|
||||
| `server.js:1237–1256` | `ensurePhotoJobsTable()`: колонка `action VARCHAR(20)`, `params JSONB`, CHECK на action **нет** | миграция схемы не требуется |
|
||||
| `server.js:2254` | `PHOTO_JOB_ACTIONS = new Set(['ai','enhance','restore','rollback'])` | + `'ai_face'`, `'ai_upscale'` |
|
||||
| `server.js:5114–5136` | `POST .../enhance-ai`: без тела → `action='ai'`, `params=NULL` | приём/валидация `{model, face, face_model, strength}` |
|
||||
| `server.js:5782–5837` | `GET /api/photo-jobs/status`: `service = await aiHealthCheck()` вычисляется, но **в ответ не попадает** | вернуть `service` (это баг-дыра, см. D2) |
|
||||
| `server.js:769–826` | `getStackInfo()`: `app/deps/runtime/database/cache/storage` | + блок `photo_ai` |
|
||||
| `server.js:1663–1664` | `keys`/`defaults` для `/api/public-settings` | + `photo_ai_face_mode`, `photo_ai_face_model`, `photo_ai_device_pref` |
|
||||
| `server.js:1710` | валидация `photo_enhance_engine` в `PUT /api/settings` | + валидация трёх новых ключей |
|
||||
| `server.js:6045–6056` | `createPhotoEnhanceWorker({...})` | + `faceTimeoutMs`, `defaultFaceModel` |
|
||||
| `worker.js:11` | `PHOTO_AI_TIMEOUT_MS = 300000` (хардкод) | читать env, добавить face-таймаут |
|
||||
| `worker.js:35–41` | `CONFIG` воркера | + `face_model`, `default_model`, `face_timeout_ms` |
|
||||
| `worker.js:122–141` | `runAiEnhance(srcKey)`: `image` + `scale=2`, ждёт сырой `image/jpeg` | проброс `params`, `Accept: application/json`, разбор JSON, `503` без траты попыток |
|
||||
| `worker.js:176` | `job.action === 'ai' ? runAiEnhance : enhanceWithSharp` | + `'ai_face'`, `'ai_upscale'` |
|
||||
| `worker.js:186–192` | инкремент `attempts` и `photo.job.retry` | не инкрементить при `503`/недоступности |
|
||||
| `public/js/journal.js:606–615` | `loadEnhanceEngine()` читает `/api/public-settings` | + чтение дефолта face-режима |
|
||||
| `public/js/journal.js:617–671` | `runPhotoAi()`: `confirm()` + POST без тела | + селект модели, тело `{model, face, face_model}` |
|
||||
| `public/js/journal.js:718` | показ кнопки по `photo_ai_enabled` | без изменений (I3) |
|
||||
| `public/js/worker.js:30–35` | `PHOTO_ACTION_LABELS` | + `ai_face`, `ai_upscale` |
|
||||
| `public/js/worker.js:300–308` | `openPhotoJob(r)` — модалка «Было/Стало» | + `model`/`device`/`faces_found`/`elapsed_ms` |
|
||||
| `public/js/settings.js:1` | `DIRTY_FIELDS` | + новые поля |
|
||||
| `public/js/settings.js:217–218, 739` | загрузка/сохранение `photo_enhance_engine` | + face-режим, face-модель, желаемое устройство |
|
||||
| `public/js/settings.js:372–477` | `renderStackInfo()` — 5 карточек | + карточка «Фото-ИИ» |
|
||||
| `public/settings.html:221–260` | карточка `#sec-photo` (камера) | + блок «Фото-ИИ» (или новая карточка) |
|
||||
| `public/js/audit.js:34–48` | словарь `photo.*` | + новые коды аудита |
|
||||
| `db/init.sql:242`, `db/migration.sql:234` | `INSERT ... 'photo_worker_enabled'` | + дефолты новых настроек |
|
||||
| `.env.example:39–41` | `PHOTO_AI_URL`, `PHOTO_AI_MAX_PIXELS` | + остальные переменные |
|
||||
| `photo-ai` в compose | **порт не проброшен**; `text-corrector` занимает `8080:8080` | см. D1 — ручные `curl` из плана не сработают |
|
||||
|
||||
---
|
||||
|
||||
## 2. Расхождения плана с кодом — решить ДО правок
|
||||
|
||||
Все шесть расхождений закрыты 2026-09-28 (решения ниже, правки кода — в своих этапах).
|
||||
|
||||
- [x] **D1. Порт для ручных проверок.** `photo-ai` не имеет `ports`/`expose`, а хостовый `8080` занят
|
||||
`text-corrector`. Команды вида `curl http://localhost:8080/enhance` из §10 плана попадут
|
||||
в `text-corrector`. Решение выбрано одно: пробросить `ports: ["127.0.0.1:8081:8080"]` в сервис
|
||||
`photo-ai` — только loopback, наружу (`0.0.0.0`) ничего не публикуется, хостовый `8080`
|
||||
(`text-corrector`) не затрагивается. **Правка применена 2026-09-28:** порт добавлен в
|
||||
`docker-compose.yml`, способ отражён в `README.md` (раздел «ИИ-улучшение фото») и `.env.example`.
|
||||
Проверено: `docker compose up -d photo-ai` → `curl http://127.0.0.1:8081/health` → `{"ok":true}`,
|
||||
`ss -tulpn` → `LISTEN 127.0.0.1:8081` (не `0.0.0.0`).
|
||||
|
||||
- [x] **D2. `service` в статусе фото-воркера.** `server.js:5825` вызывает `aiHealthCheck()` (это health
|
||||
**текстового** ИИ), результат не возвращается. Решение: отдельная функция, общий `aiHealthCheck()`
|
||||
не переиспользуем и из `/api/photo-jobs/status` убираем.
|
||||
- `photoAiHealth()` рядом с `aiHealthCheck()`: `GET ${PHOTO_AI_URL}/health`, таймаут 5 с, без throw;
|
||||
пустой `PHOTO_AI_URL` → `{ configured: false, reachable: false, latency_ms: 0, error: 'PHOTO_AI_URL не настроен' }`,
|
||||
недоступность/таймаут → `{ configured: true, reachable: false, latency_ms, error }`.
|
||||
- `service` в ответе = `{ configured, reachable, latency_ms, error }` + passthrough полей photo-ai
|
||||
(`ok, ready, device, device_name, half, tile, driver, cuda, vram_total_mb, vram_free_mb, models,
|
||||
face_models, loaded, loading, max_pixels`). Первые четыре — тот же контракт, который уже читает
|
||||
фронт текстового ИИ (`public/js/worker.js:89–116`: `reachable`, `latency_ms`, `error`),
|
||||
поэтому карточки переиспользуются без правок.
|
||||
- Вызов уходит в тот же `Promise.all`, что и запросы к БД, — иначе статус получает лишние 5 с;
|
||||
HTTP 200 в любом случае, недоступный сервис — не ошибка API.
|
||||
- Прямой прокси для оператора — `GET /api/photo-ai/health` (`requireAdmin`), отдельным пунктом Stage 4.
|
||||
- **Правка применена 2026-09-28:** `photoAiHealth()` в `server.js` рядом с `aiHealthCheck()`,
|
||||
вызов ушёл в `Promise.all` запроса `/api/photo-jobs/status`, `service` возвращается.
|
||||
Проверено на живом стеке: сервис поднят → `{"ok":true,"configured":true,"reachable":true,"latency_ms":3,"error":null}`;
|
||||
`docker compose stop photo-ai` → HTTP 200 и `{"configured":true,"reachable":false,"latency_ms":3661,"error":"fetch failed"}`
|
||||
(без исключения); после `start` → снова `reachable: true`. Контракт зафиксирован в
|
||||
`api.smoketest.js` (два новых assert'а).
|
||||
|
||||
- [x] **D3. Где живёт белый список действий.** План говорит про два места (`ensurePhotoJobsTable()` и restore).
|
||||
По факту `PHOTO_JOB_ACTIONS` используется **только** в нормализации restore-данных (`server.js:2254`,
|
||||
единственное применение — `server.js:2259`; `grep` по репозиторию больше нигде), а в
|
||||
`ensurePhotoJobsTable()` (`server.js:1237–1254`) CHECK-ограничения на `action` нет:
|
||||
`action VARCHAR(20) NOT NULL DEFAULT 'ai'` — новые значения (`ai_face`, `ai_upscale`) помещаются.
|
||||
Решение: обновить одно место.
|
||||
- Набор выносится на уровень модуля (рядом с `PHOTO_AI_URL`, `server.js:1841`), а не внутрь
|
||||
функции restore; используется в restore-нормализации **и** в валидации
|
||||
`POST /api/entries/:id/photo/enhance-ai` (Stage 4) — забыть маршрут нельзя.
|
||||
- CHECK в БД **не добавляем** (I6: никаких изменений схемы, миграция не нужна). Сверка списка
|
||||
с фактическими `action`, которые пишет код, — `grep` на приёмке этапа.
|
||||
- `ai_upscale` в список попадает сразу, хотя маршрута, его создающего, пока нет: список —
|
||||
это допустимые значения для restore, а не реестр маршрутов.
|
||||
|
||||
- [x] **D4. Доступность пакетов.** Проверено 2026-09-28 в работающем контейнере `photo-ai`
|
||||
(`pip download gfpgan==1.3.8 facexlib==0.3.0 --no-deps` — оба колеса с PyPI, 52 и 59 КБ;
|
||||
`pip list` в образе: `gfpgan 1.3.8`, `facexlib 0.3.0`, `basicsr 1.4.2`, `realesrgan 0.3.0`,
|
||||
`filterpy 1.4.5`, `numba 0.67.0`, `lmdb 2.3.0`, `scipy 1.15.3`; `import gfpgan, facexlib` — ок).
|
||||
Решение: **вендорить не нужно**, пакеты есть на PyPI и уже стоят в образе — `realesrgan==0.3.0`
|
||||
тянет `gfpgan>=1.3.5` и `facexlib>=0.2.5` транзитивно. CodeFormer официальным pip-пакетом
|
||||
не распространяется (см. D5).
|
||||
- Stage 2 фиксирует версии явно (`gfpgan==1.3.8 facexlib==0.3.0`) и убирает dev-зависимости
|
||||
gfpgan из рантайма (`tb-nightly`, `yapf`): ставить gfpgan с `--no-deps` и перечислить
|
||||
реальные зависимости явно.
|
||||
- **Найденное расхождение (Stage 2):** пин `numpy<2` в `photo-ai/Dockerfile` не действует — в образе
|
||||
`numpy 2.2.6`, потому что пин живёт в отдельном вызове `pip install`, который следующие установки
|
||||
не учитывают; там же одновременно стоят `opencv-python 5.0.0.93` (через gfpgan) и
|
||||
`opencv-python-headless 5.0.0.93`. До Stage 2 numpy не трогаем (Stage 1 меряет I1 на текущем
|
||||
2.2.6); в Stage 2 пин либо переносится в один общий вызов `pip install`, либо фиксируется
|
||||
фактическая версия — с обязательной перепроверкой эталона I1 после любого изменения numpy.
|
||||
- **Закрыто в Stage 2:** требования `numpy<2` и `opencv-python-headless 5.0.0.93` несовместимы
|
||||
(opencv 5 требует numpy ≥ 2), поэтому зафиксирована фактическая версия `numpy==2.2.6` в том же
|
||||
вызове `pip install`, что и остальные зависимости, а `opencv-python` (не-headless) больше не
|
||||
устанавливается вовсе. Эталон I1 перепроверен — 5/5 байт-в-байт (см. «Журнал раздела 2»).
|
||||
- Веса (проверено HEAD): `x2plus` v0.2.1 — 200; `general-x4v3` и `animevideov3` v0.2.5.0 — 200;
|
||||
`codeformer.pth` v0.1.0 — 200. GFPGAN: берём `GFPGANv1.4.pth` из релиза `v1.3.0` (200, `arch='clean'`,
|
||||
`channel_multiplier=2` — как у официального `inference_gfpgan.py -v 1.4`); `GFPGANCleanv1-NoCE-C2.pth`
|
||||
в релизах `v1.3.8`/`v1.3.4`/`v1.3.0` отсутствует (404), в `v0.2.0` есть (200) — как запасной вариант.
|
||||
- **Учтётся в Stage 1:** `GFPGANer` жёстко передаёт facexlib `model_rootpath='gfpgan/weights'`
|
||||
(относительный путь → каталог образа, не том `/models`), поэтому веса детектора/парсера facexlib
|
||||
сейчас скачиваются мимо тома и теряются при пересборке. Свой `FaceRestoreHelper`/каталог
|
||||
`/models/weights` — обязательное требование этапа.
|
||||
|
||||
- [x] **D5. CodeFormer — вендорен, веса тянутся на сборке.** Первоначально (проверено 2026-09-28) на PyPI
|
||||
есть только сторонняя обёртка `codeformer 0.0.11` (`github.com/rohitkhatri/codeformer`, тянет
|
||||
`lpips`) — это не официальный `sczhou/CodeFormer`, использовать его не будем.
|
||||
**Обновлено 2026-10-04:** официальный модуль вендорен в `photo-ai/vendor/codeformer/`
|
||||
(`codeformer_arch.py` + `vqgan_arch.py`, коммит `b33cc7d`, лицензия S-Lab 1.0 рядом).
|
||||
`basicsr==1.4.2` с PyPI не содержит ни `vqgan_arch.py`, ни `codeformer_arch.py`, поэтому
|
||||
вендорится **оба** файла, а импорт в `codeformer_arch.py` переведён на относительный.
|
||||
Дефолтом остаётся `PHOTO_AI_FACE_MODEL=gfpgan`, но `codeformer` теперь работает сразу.
|
||||
- `FACE_REGISTRY` всегда содержит обе записи, запись `codeformer` активна, если `import codeformer`
|
||||
(с `photo-ai/vendor` в `sys.path`) успешен.
|
||||
- **`build_face_codeformer` пришлось чинить.** Код был написан по API обёртки rohitkhatri:
|
||||
`CodeFormer(..., device=..., fp16=False)` и `net.device` официальный класс не принимает —
|
||||
вызов падал бы с `TypeError`/`AttributeError`. Официальная сигнатура:
|
||||
`(dim_embd, n_head, n_layers, codebook_size, latent_size, connect_list, fix_modules, vqgan_path)`.
|
||||
Теперь устройство передаётся через `net.to(device)`. Чекпоунт официальных весов лежит под
|
||||
ключом `params_ema` — учтено в `build_face_codeformer`.
|
||||
- `strength` валиден только для CodeFormer: при `face_model=gfpgan` и `strength`, отличном от 0.7,
|
||||
→ `400` с пояснением, иначе параметр молча игнорировался бы.
|
||||
- **Веса больше не качаются лениво.** `photo-ai/fetch-weights.py` на этапе сборки кладёт их в
|
||||
`/opt/photo-ai-seed` (build-arg `PHOTO_AI_PREFETCH`, дефолт `codeformer`; `face`/`all`/`none`),
|
||||
при старте `seed_weights()` переносит их в том `photo-ai-models:/models/weights`. Том
|
||||
переживает пересборку образа, BuildKit-кэш не даёт перекачивать. Если весов нет ни в томе,
|
||||
ни в seed — работает прежний ленивый загрузчик.
|
||||
|
||||
- [x] **D6. Поведение без `photo-ai` в compose.** Сейчас `PHOTO_AI_URL` по умолчанию
|
||||
`http://photo-ai:8080` (`docker-compose.yml:79`) — то есть «ИИ» включён по умолчанию.
|
||||
Решение: дефолт **не меняем** — фото-ИИ включено из коробки и работает на CPU; выключается
|
||||
только явно (`PHOTO_AI_URL=` в `.env` → кнопка «🤖 ИИ» скрыта, `enhance-ai` → 503, I3).
|
||||
Заодно исправлен найденный рассинхрон: `.env.example` содержал `PHOTO_AI_URL=` (пусто) с комментарием
|
||||
«пусто = контейнер photo-ai», то есть инструкция «скопируй `.env.example`» молча выключала ИИ-фото.
|
||||
В этом же изменении `.env.example` приведён к дефолту compose с явным описанием обоих состояний;
|
||||
блок про photo-ai в `README` (включая GPU-запуск) появится в Stage 6 с той же формулировкой.
|
||||
Текущий `.env` переменной не содержит → на этом хосте действует дефолт compose (включено).
|
||||
|
||||
---
|
||||
|
||||
## 3. Stage 0. Подготовка и эталон «до» (обязательно до любых правок кода)
|
||||
|
||||
- [x] Зафиксировать окружение: `docker --version`, `docker compose version`, `nvidia-smi`,
|
||||
наличие/отсутствие `nvidia-ctk`, `docker info | grep -i runtime`.
|
||||
- [x] Если `nvidia-ctk` нет — зафиксировать это как «GPU-режим не проверяем на этом хосте»,
|
||||
**не** пытаться ставить системные пакеты без явного разрешения оператора.
|
||||
- [x] Сохранить текущее состояние `.env` (`PHOTO_AI_URL` пусто или задан).
|
||||
- [x] Поднять текущий стек как есть: `docker compose up -d --build`.
|
||||
- [x] Снять эталон «до» на 3–5 фото (портрет, групповое, без лиц, зашумлённое 640×480):
|
||||
результат `/enhance` с `scale=2` без других параметров + `/health`. Сохранить файлы и
|
||||
размеры в `backups/photo-face-ai-baseline/` (вне git).
|
||||
- [x] **Приёмка:** эталон сохранён, `curl /health` отвечает, текущий сценарий «🤖 ИИ» даёт `done`.
|
||||
|
||||
Stage 0 закрыт 2026-09-28, журнал проверок — «Журнал раздела 0» выше. Повторная сверка при закрытии:
|
||||
`node backups/photo-face-ai-baseline/verify.js after-section0` → 5/5 `MATCH` (I1 байт-в-байт),
|
||||
`photo-ai /health` → `{"ok":true}` из контейнера `app`, сценарий «🤖 ИИ» → `done` (I2).
|
||||
|
||||
---
|
||||
|
||||
## 4. Stage 1. `photo-ai/app.py`: реестр моделей + авто-выбор устройства
|
||||
|
||||
- [x] `pick_device() -> (device, half, device_name)` по §4.1 плана: env → cuda → mps → cpu;
|
||||
`half=True` только на CUDA; явный `cuda` без CUDA = WARN + cpu; явный `cpu` = всегда cpu.
|
||||
- [x] `MODEL_REGISTRY`: `x2plus` (`RRDBNet(scale=2)`), `general-x4v3` (`SRVGGNetCompact(upscale=4)` + `wdn`),
|
||||
`animevideo-v3` (`SRVGGNetCompact(upscale=4)`).
|
||||
- [x] `FACE_REGISTRY`: `gfpgan` (`GFPGANer(arch='clean', channel_multiplier=2, upscale=2, bg_upsampler=…)`),
|
||||
`codeformer` (за `D5`).
|
||||
- [x] Загрузка весов: список URL из §3.4 плана, каталог `${PHOTO_AI_MODELS_DIR:-/models}/weights/`,
|
||||
скачивание в `.tmp` → `os.replace`, проверка минимального размера, кэш в томе.
|
||||
`MODEL_PATH` читается как алиас для `x2plus` (существующий `.env`/том не ломается).
|
||||
- [x] `class ModelPool`: `threading.Lock`, ленивая загрузка по требованию, кеш, LRU с лимитом 2,
|
||||
`PHOTO_AI_LOAD_ALL=1` — предзагрузка, прогрев на синтетическом шуме 64×64 после загрузки.
|
||||
- [x] OOM-деградация: `RuntimeError` с CUDA OOM → `tile` пополам (256→128→64), один ретрай;
|
||||
повтор → инвалидация модели, переход на CPU, ещё одна попытка; финал — `500` с понятным текстом.
|
||||
- [x] `GET /health` — контракт §4.3 плана (поле `ok` сохраняется, добавляются `ready`, `device`,
|
||||
`device_name`, `half`, `tile`, `driver`, `cuda`, `vram_total_mb`, `vram_free_mb`, `models`,
|
||||
`face_models`, `loaded`, `loading`, `max_pixels`).
|
||||
- [x] `GET /models` — список моделей, face-моделей, устройство, дефолты.
|
||||
- [x] `POST /enhance`: поля `image`, `scale`, `model` (дефолт `x2plus`), `face` (`off|face|all`, дефолт `off`),
|
||||
`face_model` (`gfpgan|codeformer`), `strength` (0..1, только CodeFormer, дефолт 0.7),
|
||||
`jpeg_quality` (70..100, дефолт 92). Неизвестная модель → `400` со списком допустимых.
|
||||
Модель не готова → `503` + `Retry-After: 5`.
|
||||
- [x] Два формата ответа: сырой `image/jpeg` по умолчанию (совместимость) и JSON при
|
||||
`Accept: application/json`: `{ok, image_base64, model, face, face_model, faces_found, device,
|
||||
elapsed_ms, warnings}`. При `face != off` — `enhance(..., has_aligned=False, only_center_face=False,
|
||||
paste_back=True)`; лица не найдены — не ошибка, `faces_found: 0` + чистый `bg_upsampler`.
|
||||
- [x] Расширение выходного файла — по имени файла, не по `content_type` (воркер шлёт `image/jpeg` для всего).
|
||||
- [x] **Проверка I1:** эталон из Stage 0 воспроизводится (сравнить размер/содержимое, `node --check`-эквивалент
|
||||
для Python — `python -c "import ast;ast.parse(open('photo-ai/app.py').read())"`).
|
||||
- [x] **Приёмка:** `/health` отдаёт `device`/`device_name`/`half`; `PHOTO_AI_DEVICE=cpu` при рабочей CUDA →
|
||||
`cpu`; `PHOTO_AI_DEVICE=cuda` без CUDA → `cpu` + WARN, сервис поднялся; `PHOTO_AI_DEVICE=auto` без
|
||||
CUDA → `cpu`.
|
||||
|
||||
### Журнал раздела 1 (проверено 2026-09-29)
|
||||
|
||||
Изменён только `photo-ai/app.py` (схема БД, воркер, `server.js`, compose, Dockerfile, фронтенд и `.env.example`
|
||||
не тронуты — они в Stage 2…6).
|
||||
|
||||
| Проверка | Как | Результат |
|
||||
|---|---|---|
|
||||
| I1 | `capture.js after-stage1` + `verify.js after-stage1` | 5/5 `MATCH` байт-в-байт; повторно после двух правок `is_oom`/`BASE_TILE` — снова 5/5 |
|
||||
| I2 | `INSERT INTO photo_jobs (entry_id, action, params, status) VALUES (390,'ai',NULL,'pending')` | `done`, `attempts=0`, `after_path` в S3; вход 939×875 PNG → 1878×1750 JPEG (ровно x2) |
|
||||
| I4 | override-файлы с `PHOTO_AI_DEVICE=cuda\|cpu\|auto\|bogus` | `cuda` → WARN «CUDA недоступна — работаю на CPU» + `device=cpu`; `cpu`/`auto` → `cpu`; `bogus` → WARN об неизвестном значении и `auto`; сервис поднялся во всех случаях |
|
||||
| I5 | 503 во время стартовой предзагрузки | `503` + `Retry-After: 5`; `worker.js:19` `isSoftStatus` относит `status >= 500` к мягким, попытка не тратится |
|
||||
| I7 | `ast.parse`, `grep` на комментарии, неиспользуемые импорты | чисто, комментариев 0 |
|
||||
| Реестр | `general-x4v3` (360×548 → 1440×2192), `animevideo-v3` | 200, веса скачаны в том, повторный запрос из кэша |
|
||||
| LRU | последовательно `general-x4v3` → `animevideo-v3` → `face=all` | `loaded` держится ровно 2 записи, вытесняется самая старая (`x2plus` → `general-x4v3` → `animevideo-v3` → `gfpgan`) |
|
||||
| Лица | `face=face` и `face=all` на реальных фото (800×450 и 1400×788) | `faces_found: 10`, 47 с / 42 с на CPU, `warnings: []` |
|
||||
| Лица, их нет | `nofaces.jpg` 300×200 | `ok: true`, `faces_found: 0`, warning «лица не найдены, фон обработан апскейлом», 1.8 с — не ошибка |
|
||||
| `jpeg_quality` | 70 / 92 / 100 и 30 / 101 / `abc` | 165935 / 327022 / 929904 байт, повтор 70 → те же байты; вне диапазона `400` с текстом «должен быть 70..100», нечисловое — `422` от pydantic |
|
||||
| Валидация | неизвестные `model`/`face`/`face_model`, `strength` с gfpgan и вне 0..1, битое изображение | `400` с перечнем допустимых значений / `400 bad image` |
|
||||
| OOM-лестница | подмена `process_image` в контейнере: OOM на 0/1/2/всех попытках | тайлы `[256]`, `[256,128]`, `[256,128,64]`, `[256,128,64]`; при полном провале `500` «не хватило памяти даже при tile=64: пересмотрите PHOTO_AI_TILE или PHOTO_AI_MAX_PIXELS» |
|
||||
| Деградация | повторный вызов `degrade_to_cpu` | выполняется один раз (идемпотентно), `half=False`, предупреждение в ответе |
|
||||
|
||||
Решения и находки, которые нужно знать дальше:
|
||||
|
||||
- **GPU на хосте есть** — `nvidia-smi` работает, драйвер `615.71.09`, CUDA UMD 13.4. Не хватает только
|
||||
`nvidia-container-toolkit`; его установка — решение оператора (в условиях задачи это стоп-условие),
|
||||
поэтому ветка CUDA осталась непроверенной. Всё, что связано с `cuda`/`half`/`vram_*`, в Stage 1
|
||||
покрыто только кодом и unit-проверками, а не прогоном.
|
||||
- **`animevideo-v3`** — имя реестра взято по формулировке этого чек-листа (в плане §3.4 встречается
|
||||
`animevideov3`, это имя файла весов). Ключ используется в API, при несовпадении с планом поправить
|
||||
и то, и другое одним изменением.
|
||||
- **`image_ext`** добавлен в JSON-ответ сверх полей, перечисленных в чек-листе: без него клиент
|
||||
не может угадать формат (`.jpeg` нормализуется в `.jpg` ради байт-в-байтности, `.png`/`.webp`
|
||||
не меняются).
|
||||
- **D4 (веса facexlib в томе)**: `face_helper()` пишет в `${PHOTO_AI_MODELS_DIR}/weights` через
|
||||
`model_rootpath`, а встроенный помощник `GFPGANer` ищет `gfpgan/weights` относительно cwd.
|
||||
`link_default_facexlib_dir()` при первом же построении face-модели заменяет этот каталог
|
||||
символической ссылкой на том — `/app/gfpgan/weights -> /models/weights`. Второй детектор не
|
||||
создаётся, 195 МБ дубля нет (проверено `readlink`).
|
||||
- **Ветка CodeFormer написана, но не проверена** — модуль не вендорен (Stage 2). Проверен только
|
||||
путь отказа: `face_model=codeformer` → `400` «модель лиц codeformer не установлена в образ,
|
||||
доступен gfpgan», и `strength` с gfpgan → `400`. Сам `build_face_codeformer` нужно прогнать после
|
||||
того, как пакет появится в образе.
|
||||
- **`tile` не залипает.** После OOM `state['tile']` восстанавливается на `PHOTO_AI_TILE` в `finally`
|
||||
(`BASE_TILE`), иначе одна тяжёлая картинка навсегда замедляла бы сервис в 16 раз. `degrade_to_cpu`
|
||||
больше не сбрасывает тайл на дефолт — операторское значение сохраняется.
|
||||
- **`is_oom` расширен** на сообщения CPU-аллокатора PyTorch (`not enough memory`, `alloc_cpu`,
|
||||
`can't allocate memory`): без этого исчерпание RAM на CPU (единственный тестируемый режим)
|
||||
возвращало `500` с текстом «ошибка модели: …» вместо лестницы тайлов и подсказки про
|
||||
`PHOTO_AI_TILE`/`PHOTO_AI_MAX_PIXELS`.
|
||||
- **`portrait.jpg` — плохой тестовый вход**: на нём детектор facexlib не находит лица (порог
|
||||
`get_face_landmarks_5` — 0.97, на реальных фото скор 0.999+). Ранние «0 лиц» в проверках были
|
||||
ошибкой тест-скрипта (`len(h.cropped_faces)` заполняется только `align_warp_face()`), а не
|
||||
поломкой детектора. Годится любой портрет из `uploads/` (4032×2268 и ещё 7 файлов).
|
||||
- **`.env.example` не правился**: новые переменные ещё не подставляются в compose (Stage 2),
|
||||
документировать их сейчас — задокументировать неработающее.
|
||||
|
||||
---
|
||||
|
||||
## 5. Stage 2. `photo-ai/Dockerfile` + compose
|
||||
|
||||
- [x] `ARG TORCH_VARIANT=cpu` и `ARG TORCH_INDEX=https://download.pytorch.org/whl/${TORCH_VARIANT}`;
|
||||
один образ, `cu124` — вариант сборки.
|
||||
- [x] Сохранить патч `basicsr/data/degradations.py` (`functional_tensor` → `functional`) — без него basicsr
|
||||
падает на torch ≥ 2.0. Не «упрощать» Dockerfile без проверки.
|
||||
- [x] Сохранить `libgl1 libglib2.0-0` (нужны facexlib), `numpy<2`, `opencv-python-headless`.
|
||||
- [x] Добавить `gfpgan`/`facexlib` (или вендоринг по `D4`), вендоренный CodeFormer копировать в образ
|
||||
при наличии (`D5`).
|
||||
- [x] `ENV PHOTO_AI_MODELS_DIR=/models`; `MODEL_PATH` остаётся валидным алиасом.
|
||||
- [x] `docker-compose.yml`, сервис `photo-ai`: env `PHOTO_AI_DEVICE`, `PHOTO_AI_FACE_MODEL`, `PHOTO_AI_TILE`,
|
||||
`PHOTO_AI_MAX_PIXELS`, `PHOTO_AI_LOAD_ALL`, `PHOTO_AI_JPEG_QUALITY`; `healthcheck` с
|
||||
`start_period: 300s`; порт по `D1` уже проброшен на loopback — сохранить.
|
||||
- [x] Новый `docker-compose.gpu.yml` (по образцу `docker-compose.minio.yml`): `build.args.TORCH_VARIANT=cu124`,
|
||||
`PHOTO_AI_DEVICE=cuda`, `deploy.resources.reservations.devices` (`driver: nvidia`, `count: 1`).
|
||||
Без него стек поднимается на любой машине.
|
||||
- [x] Сервису `app` добавить env `PHOTO_AI_FACE_MODEL` и `PHOTO_AI_FACE_TIMEOUT_MS`.
|
||||
- [x] **Приёмка:** CPU-сборка стартует; `/api/photo-jobs/status` показывает `service.device`;
|
||||
при пустом `PHOTO_AI_URL` приложение работает полностью (I3); `docker compose -f docker-compose.yml
|
||||
-f docker-compose.gpu.yml config` валиден (сам GPU-пуск — только если toolkit есть).
|
||||
|
||||
### Журнал раздела 2 (проверено 2026-09-29)
|
||||
|
||||
Изменены `photo-ai/Dockerfile`, `docker-compose.yml`, добавлены `docker-compose.gpu.yml` и
|
||||
`photo-ai/vendor/.gitkeep`, а также `.env.example` и таблица переменных в `README.md` — Stage 1 отложил
|
||||
их именно на Stage 2, потому что раньше compose эти переменные не подставлял. `app.py`, `worker.js`,
|
||||
`server.js`, схема БД и фронтенд не тронуты — они в Stage 3…6.
|
||||
|
||||
| Проверка | Как | Результат |
|
||||
|---|---|---|
|
||||
| CPU-сборка | `docker compose build photo-ai` (слои pip с нуля) | `EXIT=0`, образ `whatido-photo-ai:latest` **2.63 ГБ** (план оценивал ~2.5 ГБ) |
|
||||
| Состав пакетов | `docker exec photo-ai pip list` | `gfpgan 1.3.8`, `facexlib 0.3.0`, `basicsr 1.4.2`, `realesrgan 0.3.0`, `numpy 2.2.6`, `torch 2.14.0+cpu`, `matplotlib 3.10.9`; `opencv-python-headless 5.0.0.93` — **единственный** cv2; `tb-nightly` и `yapf` отсутствуют |
|
||||
| Импорты | `python -c "import basicsr, gfpgan, facexlib, realesrgan, cv2"` | ок; cv2 `GUI: NONE` (до этого активной была не-headless сборка с `GUI: QT5`) |
|
||||
| Патч basicsr | лог сборки | `basicsr patch applied to /usr/local/lib/python3.10/site-packages/basicsr/data/degradations.py` |
|
||||
| **I1** | `capture.js after-stage2` + `verify.js after-stage2` | **5/5 `MATCH` байт-в-байт** (`795a519b9a9c70f6`, `fe2496f7ef798456`, `fce1949260590855`, `1b52a49d690ca8d7`, `e0e6e480f4799bc0`) — смена cv2 на headless и явный пин numpy результат не изменили |
|
||||
| `service.device` | `GET /api/photo-jobs/status` (admin) | 200; `service` = `ok/ready/device=cpu/device_name/half/tile/driver/cuda/vram_total_mb/vram_free_mb/models/face_models/loaded/loading/max_pixels` + `configured/reachable/latency_ms/error` |
|
||||
| healthcheck | `docker inspect photo-ai` | `healthy` после `start_period: 300s`; та же команда вручную в контейнере → exit 0 |
|
||||
| env контейнера | `docker inspect photo-ai` | `PHOTO_AI_DEVICE=auto`, `PHOTO_AI_TILE=256`, `PHOTO_AI_FACE_MODEL=gfpgan`, `PHOTO_AI_LOAD_ALL=0`, `PHOTO_AI_JPEG_QUALITY=92`, `PHOTO_AI_MAX_PIXELS=4000000`, `PHOTO_AI_MODELS_DIR=/models`, `MODEL_PATH=/models/RealESRGAN_x2plus.pth` |
|
||||
| env `app` | `docker compose exec app node -e ...` | `PHOTO_AI_URL=http://photo-ai:8080`, `PHOTO_AI_FACE_MODEL=gfpgan`, `PHOTO_AI_FACE_TIMEOUT_MS=600000` |
|
||||
| I3 | override с `PHOTO_AI_URL: ""` (файл в `/tmp`, вне репозитория) | `photo_ai_enabled=false`, `POST /api/entries/1/photo/enhance-ai` → `503` «ИИ-обработка фото не настроена», 8 маршрутов API → 200; возврат к базовой конфигурации → `photo_ai_enabled=true` |
|
||||
| I4 (ветка cuda) | `PHOTO_AI_DEVICE=cuda` на новом образе | WARN «PHOTO_AI_DEVICE=cuda, но CUDA недоступна — работаю на CPU», `/health` 200 и `device=cpu` |
|
||||
| Compose | `docker compose config -q` — база, `+docker-compose.minio.yml`, `+docker-compose.gpu.yml` | три конфигурации валидны; GPU-оверрайд даёт `TORCH_VARIANT=cu124`, `PHOTO_AI_DEVICE=cuda` и `deploy.resources.reservations.devices[driver=nvidia, count=1, capabilities=[gpu]]` |
|
||||
| GPU-пуск | `up -d photo-ai` с оверрайдом, когда `nvidia-ctk` есть на хосте | **сделан 2026-09-29**, отдельный журнал «GPU-прогон» в разделе 6: `device=cuda:0`, `half=true`, `vram_total_mb=3822`. Изначально был стоп-условием (toolkit отсутствовал), условие снято |
|
||||
|
||||
Решения и находки, которые нужно знать дальше:
|
||||
|
||||
- **`numpy<2` был невыполним.** `opencv-python-headless 5.0.0.93` требует numpy ≥ 2, поэтому пин заменён
|
||||
на фактическую версию `numpy==2.2.6` и перенесён в тот же вызов `pip install`, что и остальные
|
||||
зависимости (раньше пин стоял отдельным вызовом, и следующие установки его игнорировали). I1 после
|
||||
изменения numpy перепроверен — байт-в-байт.
|
||||
- **`--no-deps` для `basicsr`/`realesrgan`/`gfpgan`/`facexlib`** убирает dev-зависимости gfpgan
|
||||
(`tb-nightly`, `yapf`). Проверено, что в рантайме они не нужны: `tensorboard` в basicsr импортируется
|
||||
только лениво внутри `init_tb_logger`/`read_data_from_tensorboard`, `yapf` не импортируется вовсе,
|
||||
а `matplotlib` приходит как зависимость `filterpy` (требование facexlib) и в образе остался.
|
||||
- **`opencv-python` (не-headless) больше не ставится.** Раньше он приходил транзитивно через gfpgan и
|
||||
перезаписывал headless-сборку (активным был cv2 с `GUI: QT5`). Теперь cv2 один, headless, и JPEG-байты
|
||||
совпали с эталоном — GUI-обвязка на результат не влияла.
|
||||
- **`libgl1 libglib2.0-0` оставлены** по требованию плана (facexlib), хотя при headless cv2 они нужны
|
||||
меньше: экономия здесь не стоит риска незамеченной регрессии.
|
||||
- **`photo-ai/vendor/.gitkeep`** — каталог вендоринга существует в репозитории, поэтому
|
||||
`COPY vendor/ ./vendor/` не падает на сборке без CodeFormer, а положенный туда модуль подхватывается
|
||||
`sys.path` в `app.py` без правок Dockerfile (`D5`).
|
||||
- **env `PHOTO_AI_FACE_MODEL`/`PHOTO_AI_FACE_TIMEOUT_MS` у `app`** добавлены по чек-листу Stage 2;
|
||||
`PHOTO_AI_FACE_MODEL` уже используется `app.py` как дефолт face-модели, воркер начнёт читать оба
|
||||
в Stage 4 (сейчас `ai_timeout_ms` в `worker.config` всё ещё 300000 — это ожидаемо).
|
||||
- **`TORCH_VARIANT` в `docker-compose.gpu.yml` захардкожен**, а не берётся из `.env`: иначе
|
||||
`TORCH_VARIANT=cpu` в `.env` молча собирал бы GPU-конфигурацию без CUDA. Значение изменено
|
||||
`cu124 → cu126` при GPU-прогоне — причина в журнале раздела 3.
|
||||
- **`MAX_INPUT_PIXELS` в compose заменён на канонический `PHOTO_AI_MAX_PIXELS`**; `app.py` по-прежнему
|
||||
читает старое имя как алиас, так что существующий `.env` не ломается. Порт `8081` на loopback (`D1`)
|
||||
сохранён.
|
||||
- **`.env.example`/`README.md`** описывают ровно те переменные, которые подставляет compose; блок про
|
||||
GPU-запуск и установку NVIDIA Container Toolkit добавлен в Stage 3 вместе с проверкой GPU-оверрайда
|
||||
(решение `D6`), остальные документы — за Stage 6.
|
||||
|
||||
---
|
||||
|
||||
## 6. Stage 3. Face-режим (GFPGAN) в `app.py`
|
||||
|
||||
- [x] `face=off` — только ESRGAN (текущее поведение). — **сделано в Stage 1** (журнал раздела 1)
|
||||
- [x] `face=face` — ESRGAN-фон + GFPGAN `paste_back`. — **сделано в Stage 1**
|
||||
- [x] `face=all` — ESRGAN(+`wdn` для `general-x4v3`) + мягкий денойз фона + лица. — **сделано в Stage 1**
|
||||
- [x] `strength` → `fidelity_weight` CodeFormer, `-w`; вне диапазона → `400`. — **сделано в Stage 1**
|
||||
(ветка CodeFormer написана, но не проверена: модуль не вендорен, `D5`; проверен путь отказа)
|
||||
- [x] Предупреждения (`warnings`: вход меньше 320×320, лиц не найдено, CodeFormer на CPU) в JSON-ответе.
|
||||
Первые два были в Stage 1; **вход меньше 320×320 и CodeFormer на CPU добавлены при приёмке
|
||||
Stage 3** — их не было в коде
|
||||
- [x] **Приёмка:** портрет 640×480 в `face` → `faces_found=6`, лицо резче; фото без лиц → `faces_found=0`
|
||||
и результат не хуже `x2plus`; искусственный OOM (`PHOTO_AI_TILE=2048` на 4 ГБ) деградирует до CPU
|
||||
без падения сервиса.
|
||||
|
||||
### Журнал раздела 3 (проверено 2026-09-29)
|
||||
|
||||
Изменён `photo-ai/app.py` (лестница OOM на CUDA + два предупреждения) и `docker-compose.gpu.yml`
|
||||
(GPU-оверрайд: cu126 + CDI). `worker.js`, `server.js`, схема БД и фронтенд не тронуты — они в Stage 4…6.
|
||||
Проверки шли на работающем GPU (RTX 3050 Laptop, 4096 МБ, драйвер `615.71.09`, CUDA 12.6) — см.
|
||||
журнал GPU-прогона в разделе 2.
|
||||
|
||||
| Проверка | Как | Результат |
|
||||
|---|---|---|
|
||||
| **Главная находка** | `PHOTO_AI_TILE=2048`, `x2plus face=all` на 4032×2268 | **до правки**: `500 Internal Server Error`, в логе `UnboundLocalError: local variable 'output_tile' referenced before assignment` (`realesrgan/utils.py:179`); **после**: `200`, `warnings: ["не хватило памяти при tile=2048"]`, 28.3 с |
|
||||
| Причина | чтение кода `realesrgan/utils.py` и `gfpgan/utils.py` | обе библиотеки **проглатывают** `RuntimeError` в своих `try/except` вокруг вызова сети (`except RuntimeError as error: print('Error', error)`) и идут дальше, поэтому `output_tile`/`restored_face` остаётся неприсвоенным. Для `run_guarded` это был уже не `RuntimeError` → ни лестницы тайлов, ни деградации на CPU, ни внятного текста |
|
||||
| Как починено | `guard_forward()` в `app.py` оборачивает `forward` сетей, построенных нами (`RealESRGANer.model`, `restorer.gfpgan`, CodeFormer `net`): `RuntimeError` с признаком OOM превращается в `TileOOM` | `TileOOM` не наследует `RuntimeError`, поэтому проглатывающие `except` его пропускают; `is_oom` и `run_guarded` (обе точки) ловят `TileOOM` явно |
|
||||
| Лестница тайлов на GPU | инъекция `TileOOM('CUDA out of memory')` вместо `process_image` | `tile=2048 → 1024 → 512`, затем `degrade_to_cpu` и повтор: `warnings` = 4 пункта, `device: cpu`, `half: false`; при повторе с уже-CPU — честная `500` «не хватило памяти даже при tile=512: пересмотрите PHOTO_AI_TILE или PHOTO_AI_MAX_PIXELS» |
|
||||
| `x2plus face=all`, tile=2048 | полное фото | `200`, 6 лиц, единственное предупреждение — про тайл; сервис не упал, следующие запросы проходят |
|
||||
| 640×480, три режима | портрет, приведённый к 640×480 | `off` 1.0 с / `face` 3.8 с / `all` 2.2 с, `faces_found=6`, `device=cuda:0` |
|
||||
| Фото без лиц | синтетический фон 800×600 | `face` → `faces_found=0`, warning «лица не найдены, фон обработан апскейлом», 0.8 с; байты результата **равны** `face=off` (39861) — без деградации качества |
|
||||
| Вход меньше 320×320 | 256×256 | два предупреждения: «вход меньше 320×320 — лица могут не найтись» + «лица не найдены» |
|
||||
| `strength` | `0.5` с gfpgan / `1.4` | `400` «strength применяется только к CodeFormer, для gfpgan оставьте 0.7» / `400` «strength должен быть 0..1» |
|
||||
| CodeFormer | `face_model=codeformer` | `400` «модель лиц codeformer не установлена в образ, доступен gfpgan» (`D5`) |
|
||||
| **I1** | 6 сценариев (`jpg`/`.jpeg`/`.png`+q70/`.webp`+q100/`x4v3`/`animevideo-v3`) на новом и на Stage 2 образе, оба на CPU | **6/6 `MATCH` байт-в-байт** (`420757881a31f12dec174e4873e260dd`, `cdbf59d1eb460d26a3b83af2e0543d82`, `654928b60ec43dbd41c6644121041a75`, `557e76d79869230058e1acf75017c670`, `9d3d731c8858bacbba1ee88bf3f373e5`) |
|
||||
| I7 | `ast.parse`, поиск комментариев | чисто, комментариев 0 |
|
||||
|
||||
Решения и находки, которые нужно знать дальше:
|
||||
|
||||
- **Лестница OOM не работала ни на одном GPU** — только на CPU, где OOM приходит из нашего кода и
|
||||
не проглатывается. Это наш главный аргумент за то, что GPU-ветку надо было прогнать, а не собрать.
|
||||
- **`guard_forward` вешается на экземпляр** (`model.forward`), идемпотентен по флагу
|
||||
`_photo_ai_guarded` и не трогает класс — обёртка не накапливается при пересборке пула. Сетей,
|
||||
которые строит не мы (retinaface в facexlib, ESRGANer внутри GFPGANer), обёртка не покрывает: там
|
||||
OOM уходит нашим же `except RuntimeError`, и это правильно.
|
||||
- **Проглатывание в gfpgan** (`except RuntimeError` вокруг `self.gfpgan(...)`) было даже опаснее
|
||||
тихого: при OOM лицо молча оставалось исходным, задание завершалось «успешно» без единого
|
||||
признака в `warnings`. Теперь такой случай уходит в лестницу тайлов, а если не помогло — в
|
||||
деградацию на CPU.
|
||||
- **4 ГБ VRAM — это про `PHOTO_AI_LOAD_ALL` и `PHOTO_AI_TILE`, а не про лестницу.** `x2plus` и `gfpgan`
|
||||
влезают (свободно ~100–300 МБ), `general-x4v3` втрое тяжелее. Проверено: после `x4v3 face=all`
|
||||
поднимается `tile=2048`, но дефолтные 256 проходят без единого предупреждения.
|
||||
- **`tile` по-прежнему не залипает** — после OOM в `finally` восстанавливается `PHOTO_AI_TILE`, и
|
||||
`degrade_to_cpu` больше не сбрасывает операторское значение.
|
||||
|
||||
---
|
||||
|
||||
### Журнал GPU-прогона (закрывает строку «GPU-пуск» раздела 2)
|
||||
|
||||
Стоп-условие снято: `nvidia-ctk` и спека `/etc/cdi/nvidia.yaml` появились на хосте, поэтому GPU-оверрейд
|
||||
переведён с классического резервирования (`driver: nvidia, count: 1`) на CDI (`device_ids:
|
||||
nvidia.com/gpu=all`) — так демон перезапускать не нужно, а `/etc/docker/daemon.json` на хосте нет.
|
||||
|
||||
| Проверка | Как | Результат |
|
||||
|---|---|---|
|
||||
| Сборка GPU-образа | `docker compose -f docker-compose.yml -f docker-compose.gpu.yml build photo-ai` | `whatido-photo-ai:cu126`, 12 ГБ; `torch 2.14.0+cu126`, `cuda: 12.6` — те же версии, что и в CPU-образе |
|
||||
| Старт | оверрайд + `up -d photo-ai` | `healthy`, в лог `устройство: cuda:0 (NVIDIA GeForce RTX 3050 Laptop GPU), half=True` |
|
||||
| `/health` | curl | `device: cuda:0`, `half: true`, `driver: 615.71.09`, `cuda: 12.6`, `vram_total_mb: 3822`, `ready: true` |
|
||||
| Обратная совместимость | базовый `docker compose up -d photo-ai` без оверрайда | сервис возвращается на CPU, `PHOTO_AI_DEVICE=auto` |
|
||||
|
||||
Решения, которые нужно знать дальше:
|
||||
|
||||
- **`TORCH_VARIANT` в оверрайде захардкожен (`cu126`), а не берётся из `.env`:** `TORCH_VARIANT=cpu`
|
||||
в `.env` иначе молча собрал бы GPU-конфигурацию без CUDA. Причина смены `cu124 → cu126`: в индексе
|
||||
`cu124` последний torch — `2.6.0`, а `cu126` даёт ровно `2.14.0`/`0.29.0`, как CPU-образ.
|
||||
- **GPU-образ тегируется отдельно** (`whatido-photo-ai:cu126`), поэтому сборка GPU-варианта не
|
||||
перетирает `whatido-photo-ai:latest` и откат на CPU — обычный `docker compose up -d photo-ai`.
|
||||
- **Ветка CUDA в плане остаётся непроверенной ровно в одном месте** — печать realesных GPU-байтов:
|
||||
на CPU/CUDA результаты x2 совпадают побайтно, но `half=True` на CUDA считает в fp16, поэтому
|
||||
байты CPU и GPU для одной картинки не равны (это не регресс I1: эталон снимается на CPU).
|
||||
|
||||
---
|
||||
|
||||
## 7. Stage 4. `worker.js` + `server.js`
|
||||
|
||||
### 7.1 `worker.js` (`createPhotoEnhanceWorker`)
|
||||
|
||||
- [x] Таймауты из env: `PHOTO_AI_TIMEOUT_MS` (дефолт 300000), `PHOTO_AI_FACE_TIMEOUT_MS` (дефолт 600000)
|
||||
для `face != 'off'`. Таймаут выбирается на основе `params`, а не глобально.
|
||||
- [x] `runAiEnhance(srcKey, params)`: отправляет `image`, `scale`, `model`, `face`, `face_model`, `strength`
|
||||
из `params` (дефолты при пустых `params`); ставит `Accept: application/json`; принимает и JSON
|
||||
(base64 → буфер), и сырой `image/jpeg` (старый photo-ai) без ошибки.
|
||||
- [x] `503` / `Retry-After` / сетевая недоступность → `sleep` c экспоненциальной задержкой, `return false`,
|
||||
**`attempts` не инкрементится**; лимит мягких повторов (например 60) → одна честная ошибка
|
||||
с понятным текстом (I5).
|
||||
- [x] `AbortError` от `AbortSignal.timeout` → сообщение «ИИ-сервис не ответил за N с» — это уже
|
||||
«жёсткая» ошибка с попытками.
|
||||
- [x] `processOne`: `action` ∈ {`ai`, `ai_face`, `ai_upscale`} → AI-путь; `enhance` → sharp.
|
||||
Пустые `params` при `ai_face` → `face='face'`.
|
||||
- [x] `applyResult`: в `logAudit(..., 'photo.job.preview', {...})` добавить `model`, `face`, `face_model`,
|
||||
`device`, `elapsed_ms`, `faces_found`, `warnings`. Текст уведомления `photo.job.done` — по факту
|
||||
режима (`job.action === 'ai_face'` → «Фото обработано нейросетью с восстановлением лиц»).
|
||||
- [x] `CONFIG` воркера: + `face_model`, `default_model`, `face_timeout_ms` (попадают в
|
||||
`GET /api/photo-jobs/status` → `worker.config`).
|
||||
|
||||
### 7.2 `server.js`
|
||||
|
||||
- [x] `POST /api/entries/:id/photo/enhance-ai` (`server.js:5114`): принять `{model, face, face_model, strength}`;
|
||||
валидация инлайн-хелперами и явными списками (как `PUT /api/settings`), невалидное → `400`.
|
||||
`params = JSON.stringify({model, face, face_model, strength})`; `action = face === 'off' ? 'ai' : 'ai_face'`.
|
||||
**Пустое тело → сегодняшнее поведение** (`params = NULL`, `action='ai'`) — I2.
|
||||
- Валидация вынесена **перед** запросом записи: невалидное тело → `400` независимо от наличия
|
||||
записи и фото, дешевле (без похода в БД) и детерминированно.
|
||||
- [x] `PHOTO_JOB_ACTIONS` (`server.js:2254`): + `'ai_face'`, `'ai_upscale'` (см. `D3`). Схема
|
||||
`action VARCHAR(20)` вмещает новые значения — миграция не нужна.
|
||||
- Набор вынесен на уровень модуля (`server.js:1884`) рядом с `PHOTO_AI_URL`; единственное
|
||||
применение — restore-нормализация (`server.js:2301`).
|
||||
- Сверка списка с тем, что реально пишет код (`INSERT INTO photo_jobs` × 5): `ai` (пустое тело
|
||||
и `face=off`), `ai_face` (`parsePhotoAiRequest`), `enhance` (`swapEntryPhotoFiles`), `restore`,
|
||||
`rollback` — все семь значений набора покрыты, лишних нет.
|
||||
- [x] `photoAiHealth()` (см. `D2`) + проксирование в `GET /api/photo-jobs/status` → `service`
|
||||
(`reachable`, `device`, `device_name`, `ready`, `vram_total_mb`, `vram_free_mb`, `models`, `face_models`,
|
||||
`loaded`). Недоступен → `{reachable:false}`, ответ 200. — **сделано 2026-09-28** (контракт и проверка
|
||||
в `D2`; поля `device`/`device_name`/`vram_*` появятся вместе с расширенным `/health` в Stage 1)
|
||||
- [x] `GET /api/photo-ai/health` (`requireAdmin`) — прямой прокси `/health` photo-ai для оператора.
|
||||
- [x] `getStackInfo()` (`server.js:769`) — блок `photo_ai` (`engine: 'Real-ESRGAN + GFPGAN'`, `driver`,
|
||||
`device`, `device_name`, `vram_total_mb`, `models`). Без credentials, только hostname.
|
||||
- `photoAiHealth()` получил необязательный параметр таймаута; в `getStackInfo()` вызывается с
|
||||
`2000` — «Статус стека» на `settings.html` не должен висеть на `/health` фото-сервиса 5 с.
|
||||
- [x] Настройки (`PUT /api/settings`, рядом `server.js:1710`): `photo_ai_face_mode` ∈ `off|face|all` (дефолт `off`),
|
||||
`photo_ai_face_model` ∈ `gfpgan|codeformer` (дефолт `gfpgan`), `photo_ai_device_pref` ∈ `auto|cuda|cpu`
|
||||
(дефолт `auto`, только для UI/доков — фактическое устройство задаёт контейнер).
|
||||
- [x] Дефолты новых настроек: `db/init.sql`, `db/migration.sql` (`INSERT ... ON CONFLICT DO NOTHING`),
|
||||
`keys`/`defaults` в `/api/public-settings` (`server.js:1663–1664`). `photo_ai_enabled` (строка 1677)
|
||||
сохранить.
|
||||
- Строки добавлены и в `ensurePhotoJobsTable()` — существующая БД получает их при старте
|
||||
приложения, без ручного прогона `migration.sql`.
|
||||
- Дефолты читаются из env: `photo_ai_face_model` ← `PHOTO_AI_FACE_MODEL`, `photo_ai_face_mode`
|
||||
← `PHOTO_AI_FACE_MODE` (обе переменные уже есть в §10), чтобы дефолты БД и compose не разошлись.
|
||||
- [x] `createPhotoEnhanceWorker({...})` (`server.js:6045`): + `faceTimeoutMs: PHOTO_AI_FACE_TIMEOUT_MS`,
|
||||
`defaultFaceModel: PHOTO_AI_FACE_MODEL`.
|
||||
- [x] `warnings` от photo-ai сохранять в аудит/уведомление, чтобы оператор видел, что face-режим не
|
||||
сработал не из-за ошибки.
|
||||
- [x] **Приёмка:** задание с `face='face'` проходит `pending → processing → done`; `params` сохранены;
|
||||
аудит содержит модель/устройство/время; остановка photo-ai на лету → задание дорабатывается после
|
||||
возврата сервиса **без** `error` (I5); результат применяется и откатывается как раньше.
|
||||
|
||||
---
|
||||
|
||||
### Журнал раздела 4 (проверено 2026-09-29)
|
||||
|
||||
Изменён `worker.js` и `server.js`. `photo-ai/app.py`, Dockerfile, compose, схема БД (кроме трёх
|
||||
`INSERT` в `settings`) и фронтенд не тронуты — они в Stage 5…6. Проверки шли на живом стеке, фото-сервис
|
||||
в CPU-режиме (`device: cpu`, RTX 3050 проверен отдельно в Stage 3).
|
||||
|
||||
| Проверка | Как | Результат |
|
||||
|---|---|---|
|
||||
| face-режим end-to-end | `POST …/enhance-ai {face:'face', face_model:'gfpgan'}` (запись 385) | `pending → processing → done` за 28 с; `params` в БД сохранены; результат отдаётся (200, JPEG, 283 953 Б) |
|
||||
| `face` + реальное лицо | та же запись на фото с лицом (запись 384), `face='all'` | `done` за 132 с, аудит: `faces_found: 1`, `elapsed_ms: 128961`, `device: cpu`, `warnings: null` |
|
||||
| **Главная находка** | первая попытка без face | аудит: `warnings: ["лица не найдены, фон обработан апскейлом"]`, `faces_found: 0` — и эта строка **видна оператору** в уведомлении, а не прячется в лог сервиса |
|
||||
| Уведомление по факту режима | три задания подряд | `ai_face` → «Фото обработано нейросетью с восстановлением лиц», `ai` → «Фото обработано нейросетью»; при warnings к `body` добавляется суффикс `· лица не найдены…` |
|
||||
| **I1 (worker)** | задание с `params IS NULL` (`action='ai'`, ручная вставка) против прямого raw-вызова `/enhance` без `Accept` | **sha256 совпал** (`98254b54d0ea6f456f400fdfd18f3a033291fd83eae884359cbaf8dd6e16556d`, 452 678 Б) — новый JSON-путь воркера даёт байт-в-байт результат старого сырого пути |
|
||||
| **I2** | задание с `params IS NULL` | `action` остался `ai`, `params` остался `NULL`, `attempts=0`, `done`; аудит `face: off`, `faces_found: 0` |
|
||||
| **I1 (эталон)** | `capture.js after-stage4` + `verify.js` | **5/5 `MATCH` байт-в-байт** |
|
||||
| **I3** | `docker compose -f … -f override.yml up -d app` с `PHOTO_AI_URL=""` | `photo_ai_enabled=false`; `enhance-ai` (`{}`, `{face:'face'}`, `{model,face:'all'}`) → **503**; `/api/photo-ai/health` → 200 `{configured:false}`; `status` → 200 `service.configured=false`; `stack.photo_ai.configured=false, host=null`; 8 маршрутов API живы; воркер виден, `ai_url=""` |
|
||||
| **I5 (обрыв)** | `stop photo-ai` → задание в очередь → 45 с → `start photo-ai` | в простое: `pending`, **`attempts=0`**, `error='ИИ-сервис недоступен (ENOTFOUND)'`; аудит `photo.job.soft_retry` (`soft_attempt: 1`, `soft_limit: 60`); после возврата — `done`, `attempts=0`, `error` очищен; `status` и `system-info` отдавали 200 с `reachable=false` |
|
||||
| **I5 (503)** | заглушка-прокси перед сервисом: `503` + `Retry-After: 5` | `status` вернулся в `pending`, **`attempts` остался `0`**; текст сохранил и код, и заголовок, и тело ответа: `«ИИ-сервис ответил 503 (Retry-After: 5): модель x2plus ещё загружается, повторите позже»`; после переключения заглушки на реальный сервис оба задания дошли до `done` с `attempts=0` (задержка ~60 с — мягкий backoff 10 с с удвоением) |
|
||||
| Валидация `enhance-ai` | 7 невалидных тел | `400` на каждое: неизвестные `model`/`face`/`face_model`, `strength` вне `0..1`, `strength` не число, `strength` при `gfpgan`, `model`+`strength` при `gfpgan` |
|
||||
| Валидация настроек | 3 невалидных + 3 валидных значения | `400` / `200`; после `PUT` новые значения видны в `/api/public-settings` сразу (кэш `public-settings` сбрасывается `invalidateSettings`) |
|
||||
| `GET /api/photo-ai/health` | админ / без токена | 200 с полями `device`, `device_name`, `models`, `face_models`, `loaded`, `vram_*`, `driver`, `half`, `tile`, `max_pixels`; без токена → 401 |
|
||||
| `stack.photo_ai` | `/api/system-info` | `engine`, `host` (`photo-ai:8080`, тот же `hostOf`, что у `database`/`cache`/`storage`), `device`, `device_name`, `vram_*`, `models`, `face_models`, `loaded`; кредов и пароля в payload нет |
|
||||
| `worker.config` | `GET /api/photo-jobs/status` | `+ face_timeout_ms: 600000`, `default_model: x2plus`, `face_model: gfpgan` рядом с `ai_timeout_ms: 300000` |
|
||||
| Реальная ошибка сервиса | `face_model: 'codeformer'` (модуль не вендорен, `D5`) | `400` от сервиса дошёл до оператора текстом: `«ИИ-сервис ответил 400: модель лиц codeformer не установлена в образ, доступен gfpgan»`; `attempts=3` (жёсткая ошибка), аудит `photo.job.error` |
|
||||
| I7 | `grep` по диффу | комментариев 0, `require('fs')`/`require('path')`/`uploads` в `worker.js` нет, SQL параметризован (в `settings`-вставках литералы — ключи, как у соседней `photo_worker_enabled`) |
|
||||
| I6 | дифф `db/` | только `INSERT INTO settings … ON CONFLICT DO NOTHING` × 3, ни `ALTER`, ни `CREATE TABLE`; `action VARCHAR(20)` не менялся |
|
||||
| Compose | `config -q` для base, `.gpu.yml`, `.minio.yml` | валидны все три |
|
||||
| `api.smoketest.js` | существующий набор | 57 `PASS`, 0 `FAIL` |
|
||||
|
||||
Решения и находки, которые нужно знать дальше:
|
||||
|
||||
- **`Accept: application/json` ничего не ломает в сервисе, но меняет контракт воркера.** Ответ приходит
|
||||
base64 в JSON — это в ~1.33 раза больше трафика на стыке app↔photo-ai. Оставлено осознанно: без
|
||||
метаданных (`device`, `elapsed_ms`, `faces_found`, `warnings`) в аудите и уведомлении оператор
|
||||
не видит, отработала ли модель. Сырой `image/jpeg` воркер по-прежнему принимает — на случай отката
|
||||
сервиса на старый контракт; метаданные тогда берутся из заголовков `x-photo-ai-*`.
|
||||
- **Валидация тела вынесена перед запросом записи.** Иначе невалидное тело на несуществующей записи
|
||||
давало `404`, и чекбокс «невалидное → 400» нельзя было бы проверить, не заводя запись с фото.
|
||||
Побочная выгода: невалидное тело не стоит похода в БД.
|
||||
- **`strength` при `face_model != 'codeformer'` отвергается на входе в API**, а не на стороне сервиса.
|
||||
Иначе ошибка приходила бы после трёх попыток воркера и после 2 минут ожидания — валидация в
|
||||
`PUT /api/settings`-стиле дешевле и сразу видима пользователю.
|
||||
- **Тело ответа при не-2xx больше не выбрасывается** (найдено на приёмке: `ИИ-сервис ответил 400`
|
||||
без причины). Теперь `readErrorBody()` разбирает JSON `{error}`/`{detail}` или сырой текст и
|
||||
добавляет к сообщению — именно так оператор узнал, что CodeFormer не вендорен.
|
||||
- **Один и тот же таймаут на `ai` и `ai_face` был бы неверной настройкой:** `x2plus face=all` на
|
||||
4032×2268 на CPU занимает ~2 мин, и при `PHOTO_AI_TIMEOUT_MS=300000` задание с лицом упало бы
|
||||
в жёсткую ошибку после трёх попыток там, где GPU-оверрайд уложился бы в 28 с. Отсюда
|
||||
`PHOTO_AI_FACE_TIMEOUT_MS=600000` и выбор таймаута по `params.face`.
|
||||
- **`503` — не «сервис недоступен»:** `status.service.reachable` при этом `true` (сервис отвечает).
|
||||
Разводить эти два состояния важно для Stage 5 — иначе UI покажет «сервис лежит» при обычной
|
||||
прогревочной паузе.
|
||||
- **Прокси-заглушка вместо гонки за 503.** Первый запрос к холодной модели грузит её **синхронно**
|
||||
(`ModelPool.get()` → `factory()`), а `503` получают только *конкурентные* запросы к той же модели
|
||||
(`key in self.loading` → `ModelNotReady`). Воркер обрабатывает одно задание за раз, поэтому в
|
||||
бою такой 503 почти не воспроизводится — ветку проверили детерминированно, проксируя ответы
|
||||
сервиса заглушкой с флипом, иначе проверка была бы гонкой.
|
||||
- **`photo.job.soft_retry` пишется не на каждый повтор, а на 1-й и каждый 10-й** (`n === 1 || n % 10 === 0`)
|
||||
— это было в коде и до Stage 4. Практическое следствие для проверок: точный текст последнего
|
||||
мягкого повтора надёжнее читать из `photo_jobs.error`, а не из аудита; в журнале выше 503 виден
|
||||
именно там.
|
||||
- **Живой стек используется параллельно.** Во время приёмки оператор применил результат задания 102
|
||||
через UI и вернул оригинал — это и есть проверка «результат применяется и откатывается как раньше»
|
||||
на реальных данных (аудит `entry.photo.apply` → `entry.photo.restore_original`). Поэтому рабочие
|
||||
задания и результаты оператора не тронуты: удалены только созданные тестами строки `photo_jobs`,
|
||||
их аудит-строки, уведомления и файлы в S3. Одно замечание для Stage 5: `photo_ai_face_mode` и
|
||||
`photo_ai_face_model` появились в `settings` у уже работающей БД — приложение создало их сам в
|
||||
`ensurePhotoJobsTable()` при старте, поэтому значения по умолчанию действительны без ручного
|
||||
прогона `migration.sql`.
|
||||
|
||||
---
|
||||
|
||||
## 8. Stage 5. Фронтенд
|
||||
|
||||
- [x] `public/journal.html` + `public/js/journal.js`: рядом с «🤖 ИИ» селект режима
|
||||
(«Универсально (x2)», «Быстро (x4)», «Лица (GFPGAN)», «Лица + фон»); значение уходит в
|
||||
`POST .../enhance-ai` телом `{model, face, face_model}`. Дефолт — из `photo_ai_face_mode`
|
||||
(`/api/public-settings`, `loadEnhanceEngine`); при `device === 'cpu'` — подсказка «на CPU медленно».
|
||||
Кнопка по-прежнему скрыта при `photo_ai_enabled === 'false'` (I3).
|
||||
- `PHOTO_AI_MODES` (`journal.js:609`) — единственная таблица режим→`{model, face}`; значение
|
||||
селекта → `photoAiMode()`, `face_model` подставляется только при `face !== 'off'`.
|
||||
- [x] `public/js/worker.js`: `PHOTO_ACTION_LABELS` + `ai_face: 'ИИ + лица'`, `ai_upscale: 'ИИ-апскейл'`;
|
||||
в модалке сравнения — `model`, `device`, `faces_found`, `elapsed_ms` (данные из `params`/аудита, I6);
|
||||
строка состояния учитывает `service.reachable === false` и `service.device`.
|
||||
- [x] `public/settings.html` + `public/js/settings.js`: блок «Фото-ИИ» — селект face-режима, селект
|
||||
face-модели, селект желаемого устройства + строка «фактическое: …» с подсветкой расхождения;
|
||||
read-only статус (`device_name`, VRAM, список моделей) из `/api/photo-jobs/status`.
|
||||
Новые id внести в `DIRTY_FIELDS` (`settings.js:1`) и в payload (`settings.js:739`).
|
||||
- [x] `public/js/settings.js:372` `renderStackInfo()` — карточка «Фото-ИИ» из `stack.photo_ai`.
|
||||
- [x] `public/js/audit.js` — метки для новых кодов аудита (иначе в UI будет сырой код).
|
||||
- [ ] **Приёмка:** из журнала доступны все 4 режима; после постановки видно «В очереди», затем сравнение
|
||||
«Было/Стало» с моделью/устройством; в настройках видно фактическое устройство и VRAM.
|
||||
|
||||
### Журнал раздела 5 (код сделан 2026-09-30, приёмка на живом стеке не гонялась)
|
||||
|
||||
Изменены только файлы фронтенда: `journal.html`, `worker.html`, `settings.html`, `js/journal.js`,
|
||||
`js/worker.js`, `js/settings.js`, `js/audit.js`. `server.js`, `worker.js` (воркер), `photo-ai/`, compose
|
||||
и схема БД не тронуты — контракт `/enhance-ai`, `/api/photo-ai/health` и `service` в
|
||||
`/api/photo-jobs/status` взят из Stage 4 без изменений (I6 соблюдён: новых колонок нет, метаданные
|
||||
приходят из `photo_jobs.params` и `audit_log.target`).
|
||||
|
||||
| Пункт | Реализация |
|
||||
|---|---|
|
||||
| Селект режима в журнале | `#enhanceAiMode` рядом с «🤖 ИИ»; `PHOTO_AI_MODES` → `photoAiMode()` → тело `{model, face, face_model}`; селект и кнопка скрываются вместе при `photo_ai_enabled === 'false'` (I3) |
|
||||
| Дефолт режима | `defaultPhotoAiModeKey()`: `face=all → facesbg`, `face=face → faces`, иначе `x2`; значение берётся из `photo_ai_face_mode` в `loadEnhanceEngine()` |
|
||||
| Подсказка про CPU | `loadPhotoAiDevice()` (админ) читает `GET /api/photo-ai/health`; при `device` с `cpu` — «Фото-ИИ считает на CPU — лицевые режимы могут занять несколько минут». Не-админу запрос не делается |
|
||||
| Тексты подтверждения | `runPhotoAi()` различает апскейл («до минуты») и face-режим («несколько минут», с названием модели лиц) — по той же логике выбирается таймаут в воркере |
|
||||
| Статус фото-воркера | `photoStateHint` учитывает `enabled`, `ai_configured`, `service.reachable === false` («задания ждут») и дописывает `· расчёт на <device>` |
|
||||
| Метаданные в модалке | `renderPhotoJobMeta()`: `params` (модель/режим) из строки задания, `device`/`faces_found`/`elapsed_ms`/`warnings` — из `audit_log.target` по `job_id` (один запрос `GET /api/audit?action=photo.job.preview&limit=200`, мапа кэшируется) |
|
||||
| Блок «Фото-ИИ» в настройках | три селекта + read-only чипы: фактическое устройство, `device_name`, VRAM, модели/модели лиц/загруженные; жёлтым подсвечивается расхождение с желаемым устройством, красным — недоступность и незаданный `PHOTO_AI_URL` |
|
||||
| Карточка в «Статусе стека» | `renderStackInfo()` рисует `stack.photo_ai`: состояние (`не настроен` / `нет связи` / `модели грузятся` / `отвечает`), движок, host, устройство, fp16/fp32, VRAM, модели |
|
||||
| Метки аудита | 10 новых кодов в `audit.js`: профиль и фото ученика, главное фото ученика, фото модуля, удаление и очистка уведомлений, логотип; плюс `ai_face`/`ai_upscale` в `PHOTO_ACTION_LABELS` (`worker.js`) |
|
||||
| Синтаксис | `node --check` для `js/journal.js`, `js/settings.js`, `js/worker.js`, `js/audit.js` — чисто |
|
||||
|
||||
Замечания, важные для приёмки и для Stage 6:
|
||||
|
||||
- **Метаданные в UI берутся из аудита, а не из `photo_jobs`.** В `params` лежат только модель, режим,
|
||||
модель лиц и `strength`; `device`, `faces_found`, `elapsed_ms` и `warnings` пишет воркер в
|
||||
`audit_log.target` при `photo.job.preview`. Поэтому `GET /api/audit?action=photo.job.preview&limit=200`
|
||||
— один запрос на всё открытие модалки; у заданий старше 200 последних метаданных не будет, UI честно
|
||||
покажет «Метаданные обработки недоступны». Новых колонок не добавляли (I6).
|
||||
- **`reachable` и «модели грузятся» — разные состояния.** Прогрев даёт `reachable: true` при
|
||||
`ready: false` (см. замечание Stage 4 про 503), поэтому строка статуса показывает «Ожидание очереди»,
|
||||
а не «сервис недоступен»; жёлтая подсветка в настройках — только при реальном расхождении устройства.
|
||||
- **`photo_ai_device_pref` — документирующее значение.** Фактическое устройство задаёт
|
||||
`PHOTO_AI_DEVICE` в контейнере, поэтому в UI это не переключатель, а сверка «желаемое vs фактическое»
|
||||
с явной подсказкой, как это исправить.
|
||||
- **Приёмка на живом стеке не выполнялась** в этом изменении: чекбокс приёмки выше остаётся пустым.
|
||||
Проверять нужно на стеке с поднятым `photo-ai` (иначе селект и блок статуса скрыты по I3), отдельно —
|
||||
расхождение `photo_ai_device_pref=cuda` при CPU-сервисе, чтобы увидеть жёлтую подсветку.
|
||||
|
||||
---
|
||||
|
||||
## 9. Stage 6. Документация и тесты
|
||||
|
||||
- [ ] `AGENTS.md`: раздел про photo-ai (реестр моделей, device-политика, новые env, GPU-override,
|
||||
контракт `/enhance` и `/health`), строки в таблице «File Map» для `photo-ai/app.py`,
|
||||
`photo-ai/Dockerfile`, `docker-compose.gpu.yml`.
|
||||
- [ ] `README.md`: установка NVIDIA Container Toolkit, запуск с `docker-compose.gpu.yml`,
|
||||
проверка `GET /api/photo-jobs/status` → `service.device`, ручной вызов `/enhance` (с учётом `D1`).
|
||||
- [ ] `.env.example`: все переменные из §7 плана с комментариями.
|
||||
- [ ] `api.smoketest.js` (по правилу `AGENTS.md` — контракт API фиксируется в том же изменении):
|
||||
- `POST /api/entries/:id/photo/enhance-ai` с `model: 'нет такой'` → `400`;
|
||||
- `face: 'face'` без `PHOTO_AI_URL` → `503` (условно: если `ai_configured === false`);
|
||||
- `GET /api/photo-jobs/status` содержит ключ `service`.
|
||||
- [ ] **Приёмка:** `node api.smoketest.js` проходит; `node --check server.js`, `node --check worker.js`,
|
||||
`python -c "import ast;ast.parse(...)"` для `app.py`; `docker compose config` валиден для обоих
|
||||
compose-файлов; документация совпадает с кодом.
|
||||
|
||||
---
|
||||
|
||||
## 10. Новые переменные окружения (итог)
|
||||
|
||||
| Переменная | Умолчание | Где используется |
|
||||
|---|---|---|
|
||||
| `PHOTO_AI_URL` | `http://photo-ai:8080` (compose), пусто = выкл. | `server.js`, воркер |
|
||||
| `PHOTO_AI_MAX_PIXELS` | `4000000` | `app.py` (алиас `MAX_INPUT_PIXELS`) |
|
||||
| `PHOTO_AI_DEVICE` | `auto` | `app.py` (`auto|cuda|cpu`) |
|
||||
| `PHOTO_AI_TILE` | `256` | `app.py` |
|
||||
| `PHOTO_AI_FACE_MODEL` | `gfpgan` | `app.py`, дефолт воркера, дефолт `photo_ai_face_model` |
|
||||
| `PHOTO_AI_LOAD_ALL` | `0` | `app.py` |
|
||||
| `PHOTO_AI_FACE_MODE` | `off` | дефолт `photo_ai_face_mode` (UI) |
|
||||
| `PHOTO_AI_JPEG_QUALITY` | `92` | `app.py` |
|
||||
| `PHOTO_AI_TIMEOUT_MS` | `300000` | `worker.js` (раньше хардкод — сделан в Stage 4) |
|
||||
| `PHOTO_AI_FACE_TIMEOUT_MS` | `600000` | `worker.js` |
|
||||
| `PHOTO_AI_SOFT_MAX_RETRIES` | `60` | `worker.js` — лимит мягких повторов (I5) |
|
||||
| `PHOTO_AI_SOFT_BACKOFF_MS` | `10000` | `worker.js` — первая пауза мягкого повтора |
|
||||
| `PHOTO_AI_SOFT_BACKOFF_MAX_MS` | `300000` | `worker.js` — потолок паузы |
|
||||
| `PHOTO_AI_MODELS_DIR` | `/models` | `app.py` (внутри контейнера) |
|
||||
|
||||
---
|
||||
|
||||
## 11. Порядок и правила выполнения
|
||||
|
||||
- [x] Этапы выполняются строго 0 → 1 → 2 → 3 → 4 → 5 → 6. Каждый этап закрывается своей приёмкой
|
||||
**до** начала следующего. (0–4 закрыты, см. журналы разделов)
|
||||
- [x] Каждый завершённый чекбокс отмечать (`- [x]`) в этом файле по мере выполнения.
|
||||
- [ ] Изменения в `AGENTS.md`/`.env.example`/`README.md` делать **в том же** изменении, что и код,
|
||||
который они описывают (правило `AGENTS.md` про рассинхрон документации).
|
||||
- [ ] Коммит на этап, а не на весь план: `app.py`+`Dockerfile`+compose → воркер/сервер → фронт → доки/тесты.
|
||||
- [ ] Стоп-условия (сообщить оператору, не «чинить» самостоятельно): требуется `sudo` на хосте;
|
||||
требуется установка `nvidia-container-toolkit`; нашёлся конфликт миграции БД; текущий
|
||||
`/enhance` перестал давать эталонный результат (I1).
|
||||
|
||||
## 12. Готово, когда
|
||||
|
||||
- [x] `photo-ai` стартует на CPU и на CUDA, устройство выбирается автоматически, `/health` показывает
|
||||
фактическое устройство, имя GPU, VRAM, доступные и загруженные модели. — **проверено на обоих**
|
||||
(CPU на текущем стеке, CUDA — журнал GPU-прогона в разделе 2)
|
||||
- [x] Сценарий «🤖 ИИ» без параметров даёт результат, эквивалентный текущему. — **I1/I2 в Stage 4**:
|
||||
байт-в-байт с raw-вызовом и 5/5 `MATCH` эталона
|
||||
- [ ] Доступны режимы: универсальный x2, быстрый x4, только лица (GFPGAN), лица + фон.
|
||||
- [ ] Face-задание проходит полный цикл, результат виден в сравнении, применяется и откатывается.
|
||||
- [ ] Отсутствие GPU, отсутствие `nvidia-ctk` и недоступный `photo-ai` не ломают приложение.
|
||||
- [ ] `node api.smoketest.js` проходит; `AGENTS.md`, `README.md`, `.env.example` описывают новые
|
||||
переменные и GPU-запуск.
|
||||
@@ -0,0 +1,229 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
function loadEnv() {
|
||||
const file = path.join(__dirname, '.env');
|
||||
for (const line of fs.readFileSync(file, 'utf8').split('\n')) {
|
||||
const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/);
|
||||
if (m && !(m[1] in process.env)) process.env[m[1]] = m[2];
|
||||
}
|
||||
}
|
||||
|
||||
const BASE = process.env.BASE || 'http://localhost:3003';
|
||||
|
||||
async function api(pathname, { token, apiKey, method = 'GET', body, headers: extra } = {}) {
|
||||
const headers = {};
|
||||
if (token) headers['X-Auth-Token'] = token;
|
||||
if (apiKey) headers['X-Api-Key'] = apiKey;
|
||||
if (body) headers['Content-Type'] = 'application/json';
|
||||
Object.assign(headers, extra || {});
|
||||
const res = await fetch(BASE + pathname, { method, headers, body: body ? JSON.stringify(body) : undefined });
|
||||
const text = await res.text();
|
||||
let data = text;
|
||||
try { data = JSON.parse(text); } catch (e) {}
|
||||
return { status: res.status, data, headers: res.headers };
|
||||
}
|
||||
|
||||
function ok(label, cond, extra) {
|
||||
console.log(`${cond ? 'PASS' : 'FAIL'} ${label}${extra !== undefined && extra !== null ? ' -> ' + JSON.stringify(extra) : ''}`);
|
||||
if (!cond) process.exitCode = 1;
|
||||
return cond;
|
||||
}
|
||||
|
||||
const V1 = (key, p, opts) => api('/api/v1' + p, { ...opts, apiKey: key });
|
||||
|
||||
const unbanSelf = async (token) => {
|
||||
const bans = await api('/api/bans', { token });
|
||||
if (!Array.isArray(bans.data)) return;
|
||||
for (const b of bans.data) {
|
||||
if (b.reason === 'apikey-bruteforce') await api('/api/bans/' + encodeURIComponent(b.ip), { token, method: 'DELETE' });
|
||||
}
|
||||
};
|
||||
|
||||
async function main() {
|
||||
loadEnv();
|
||||
const login = await api('/api/auth/login', { method: 'POST', body: { username: process.env.ADMIN_USERNAME || 'admin', password: process.env.ADMIN_PASSWORD } });
|
||||
if (login.status === 403) {
|
||||
console.log('IP заблокирован защитой от подбора ключей (тест делает несколько заведомо неверных ключей за прогон).');
|
||||
console.log('Снимите бан в админке «Блокировки» и повторите запуск.');
|
||||
return;
|
||||
}
|
||||
if (!ok('login', login.status === 200 && login.data.token, login.status)) return;
|
||||
const token = login.data.token;
|
||||
await unbanSelf(token);
|
||||
|
||||
const created = await api('/api/api-keys', { token, method: 'POST', body: { name: 'SelfTest read ' + Date.now(), scopes: ['read'] } });
|
||||
ok('api-keys: создание ключа -> 201 с секретом',
|
||||
created.status === 201 && typeof created.data.key === 'string' && created.data.key.startsWith('wsk_'),
|
||||
{ status: created.status, prefix: created.data && created.data.prefix });
|
||||
const rawKey = created.data.key;
|
||||
const keyId = created.data.id;
|
||||
|
||||
const meta = await api('/api/api-keys/meta', { token });
|
||||
ok('api-keys: meta отдаёт scopes', meta.status === 200 && meta.data.scopes && meta.data.scopes.read && meta.data.scopes.write, meta.data);
|
||||
ok('api-keys: секрет не возвращается в листинге',
|
||||
await api('/api/api-keys', { token }).then(r => Array.isArray(r.data) && r.data.every(k => k.key === undefined)), null);
|
||||
|
||||
const me = await V1(rawKey, '/me');
|
||||
ok('api v1: /me по X-Api-Key -> 200', me.status === 200 && me.data.user && me.data.user.role === 'admin', me.data && me.data.user);
|
||||
ok('api v1: Authorization Bearer тоже работает', (await api('/api/v1/me', { headers: { Authorization: 'Bearer ' + rawKey } })).status === 200, null);
|
||||
ok('api v1: секрет ключа не утекает в /me', me.data.key && me.data.key.key === undefined, me.data.key);
|
||||
ok('api v1: без ключа -> 401', (await api('/api/v1/me')).status === 401, null);
|
||||
ok('api v1: неверный ключ -> 401', (await api('/api/v1/me', { headers: { 'X-Api-Key': 'wsk_' + '0'.repeat(64) } })).status === 401, null);
|
||||
ok('api v1: токен сессии не работает как API-ключ', (await api('/api/v1/me', { headers: { 'X-Api-Key': token } })).status === 401, null);
|
||||
|
||||
for (const p of ['/branches', '/groups', '/students', '/modules', '/entries', '/lesson-reports', '/stats']) {
|
||||
const r = await V1(rawKey, p);
|
||||
ok('api v1: чтение ' + p, r.status === 200, { status: r.status, error: r.data && r.data.error });
|
||||
}
|
||||
const gList = await V1(rawKey, '/groups?limit=2');
|
||||
ok('api v1: список возвращает {items,total,limit,offset}',
|
||||
gList.status === 200 && Array.isArray(gList.data.items) && typeof gList.data.total === 'number' && gList.data.limit === 2,
|
||||
{ status: gList.status, keys: Object.keys(gList.data || {}) });
|
||||
|
||||
const g = (gList.data.items && gList.data.items[0]) || null;
|
||||
const studentName = 'SelftestApi ' + Date.now();
|
||||
if (g) {
|
||||
const denied = await V1(rawKey, '/entries', { method: 'POST', body: { student_name: studentName, group_id: g.id, description: 'read-only' } });
|
||||
ok('api v1: ключ без scope write -> 403', denied.status === 403, { status: denied.status, error: denied.data && denied.data.error });
|
||||
} else {
|
||||
ok('api v1: есть группа для проверки записи', false, 'no groups');
|
||||
}
|
||||
|
||||
const writeKey = await api('/api/api-keys', { token, method: 'POST', body: { name: 'SelfTest write ' + Date.now(), scopes: ['read', 'write'] } });
|
||||
ok('api-keys: создание ключа со scope write', writeKey.status === 201, writeKey.status);
|
||||
|
||||
if (g) {
|
||||
const ce = await V1(writeKey.data.key, '/entries', { method: 'POST', body: { student_name: studentName, group_id: g.id, description: 'Создано через API' } });
|
||||
ok('api v1: POST /entries со scope write -> 201', ce.status === 201 && ce.data.id > 0, { status: ce.status, error: ce.data && ce.data.error });
|
||||
if (ce.status === 201) {
|
||||
const upd = await V1(writeKey.data.key, '/entries/' + ce.data.id, { method: 'PUT', body: { description: 'Обновлено через API' } });
|
||||
ok('api v1: PUT /entries/:id', upd.status === 200 && upd.data.description === 'Обновлено через API', { status: upd.status, error: upd.data && upd.data.error });
|
||||
const one = await V1(writeKey.data.key, '/entries/' + ce.data.id);
|
||||
ok('api v1: GET /entries/:id отдаёт изменённое', one.status === 200 && one.data.description === 'Обновлено через API', one.status);
|
||||
ok('api v1: GET /entries/:id/files', (await V1(writeKey.data.key, '/entries/' + ce.data.id + '/files')).status === 200, null);
|
||||
ok('api v1: DELETE /entries/:id (soft delete)', (await V1(writeKey.data.key, '/entries/' + ce.data.id, { method: 'DELETE' })).status === 200, null);
|
||||
const after = await V1(writeKey.data.key, '/entries?search=' + encodeURIComponent(studentName));
|
||||
ok('api v1: удалённая запись не выдаётся в списке',
|
||||
after.status === 200 && !after.data.items.some(i => i.id === ce.data.id),
|
||||
{ status: after.status, ids: (after.data.items || []).map(i => i.id) });
|
||||
}
|
||||
const lessonDate = new Date().toISOString().slice(0, 10);
|
||||
const lr = await V1(writeKey.data.key, '/lesson-reports', { method: 'POST', body: { group_id: g.id, lesson_date: lessonDate, lesson_time: '10:00', text: 'Отчёт через API' } });
|
||||
ok('api v1: POST /lesson-reports', lr.status === 201 && lr.data.id > 0, { status: lr.status, error: lr.data && lr.data.error });
|
||||
if (lr.status === 201) {
|
||||
const lrList = await V1(writeKey.data.key, '/lesson-reports?group_id=' + g.id + '&date_from=' + lessonDate);
|
||||
ok('api v1: отчёт виден в списке по дате', lrList.status === 200 && lrList.data.items.some(r => r.id === lr.data.id), { status: lrList.status, total: lrList.data && lrList.data.total });
|
||||
ok('api v1: DELETE /lesson-reports/:id', (await V1(writeKey.data.key, '/lesson-reports/' + lr.data.id, { method: 'DELETE' })).status === 200, null);
|
||||
}
|
||||
}
|
||||
|
||||
if (g) {
|
||||
for (const p of ['/ai/wake', '/photo-jobs/wake', '/ai/requeue-failed', '/photo-jobs/requeue-failed']) {
|
||||
const r = await V1(rawKey, p, { method: 'POST' });
|
||||
ok('api v1: ключ без scope write -> 403 на ' + p, r.status === 403, { status: r.status, error: r.data && r.data.error });
|
||||
}
|
||||
const rc = await V1(rawKey, '/entries/' + g.id + '/ai/recheck', { method: 'POST' });
|
||||
ok('api v1: ключ без scope write -> 403 на recheck', rc.status === 403, { status: rc.status, error: rc.data && rc.data.error });
|
||||
|
||||
for (const p of ['/ai/wake', '/photo-jobs/wake']) {
|
||||
const r = await V1(writeKey.data.key, p, { method: 'POST' });
|
||||
ok('api v1: POST ' + p + ' со scope write -> 200', r.status === 200 && r.data.ok === true, { status: r.status, error: r.data && r.data.error });
|
||||
}
|
||||
for (const p of ['/ai/requeue-failed', '/photo-jobs/requeue-failed']) {
|
||||
const r = await V1(writeKey.data.key, p, { method: 'POST' });
|
||||
ok('api v1: POST ' + p + ' -> 200 с count', r.status === 200 && r.data.ok === true && typeof r.data.count === 'number', { status: r.status, data: r.data });
|
||||
}
|
||||
const rc404 = await V1(writeKey.data.key, '/entries/99999999/ai/recheck', { method: 'POST' });
|
||||
ok('api v1: recheck несуществующей записи -> 404', rc404.status === 404, { status: rc404.status, error: rc404.data && rc404.data.error });
|
||||
|
||||
const ce2 = await V1(writeKey.data.key, '/entries', { method: 'POST', body: { student_name: studentName, group_id: g.id, description: 'Для recheck' } });
|
||||
if (ce2.status === 201) {
|
||||
const rc2 = await V1(writeKey.data.key, '/entries/' + ce2.data.id + '/ai/recheck', { method: 'POST' });
|
||||
ok('api v1: POST /entries/:id/ai/recheck -> 200', rc2.status === 200 && rc2.data.ok === true, { status: rc2.status, error: rc2.data && rc2.data.error });
|
||||
const one2 = await V1(writeKey.data.key, '/entries/' + ce2.data.id);
|
||||
ok('api v1: запись после recheck в очереди на ИИ-проверку',
|
||||
one2.status === 200 && ['pending', 'processing'].includes(one2.data.ai_status),
|
||||
{ status: one2.status, ai_status: one2.data && one2.data.ai_status });
|
||||
await V1(writeKey.data.key, '/entries/' + ce2.data.id, { method: 'DELETE' });
|
||||
} else {
|
||||
ok('api v1: запись для проверки recheck создана', false, { status: ce2.status });
|
||||
}
|
||||
}
|
||||
|
||||
const rot = await api('/api/api-keys/' + writeKey.data.id + '/rotate', { token, method: 'POST' });
|
||||
ok('api-keys: ротация выдаёт новый секрет', rot.status === 200 && typeof rot.data.key === 'string' && rot.data.key !== writeKey.data.key, rot.status);
|
||||
ok('api v1: старый ключ мёртв после ротации', (await api('/api/v1/me', { apiKey: writeKey.data.key })).status === 401, null);
|
||||
ok('api v1: новый ключ работает после ротации', (await api('/api/v1/me', { apiKey: rot.data.key })).status === 200, null);
|
||||
await api('/api/api-keys/' + writeKey.data.id, { token, method: 'DELETE' });
|
||||
|
||||
const updKey = await api('/api/api-keys/' + keyId, { token, method: 'PUT', body: { name: 'Renamed ' + Date.now(), scopes: ['read'] } });
|
||||
ok('api-keys: PUT обновляет ключ', updKey.status === 200 && updKey.data.name.startsWith('Renamed'), updKey.status);
|
||||
ok('api-keys: некорректный лимит -> 400', (await api('/api/api-keys/' + keyId, { token, method: 'PUT', body: { rate_limit_per_min: 999999 } })).status === 400, null);
|
||||
ok('api-keys: DELETE ключа', (await api('/api/api-keys/' + keyId, { token, method: 'DELETE' })).status === 200, null);
|
||||
ok('api v1: удалённый ключ -> 401', (await api('/api/v1/me', { apiKey: rawKey })).status === 401, null);
|
||||
ok('api-keys: список без сессии -> 401', (await api('/api/api-keys')).status === 401, null);
|
||||
ok('api-keys: X-Admin-Token не авторизует -> 401', (await api('/api/api-keys', { headers: { 'X-Admin-Token': token } })).status === 401, null);
|
||||
|
||||
const limited = await api('/api/api-keys', { token, method: 'POST', body: { name: 'RL test', scopes: ['read'], rate_limit_per_min: 3 } });
|
||||
const rlKey = limited.data.key;
|
||||
const codes = [];
|
||||
let rlHeaders = null;
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const r = await api('/api/v1/stats', { apiKey: rlKey });
|
||||
codes.push(r.status);
|
||||
rlHeaders = r.headers;
|
||||
}
|
||||
ok('api-keys: индивидуальный лимит rpm соблюдается',
|
||||
codes.slice(0, 3).every(c => c === 200) && codes.slice(3).some(c => c === 429),
|
||||
{ codes, limit: rlHeaders && rlHeaders.get('ratelimit-limit') });
|
||||
ok('api-keys: заголовки ratelimit присутствуют', Boolean(rlHeaders && rlHeaders.get('ratelimit-limit')), null);
|
||||
await api('/api/api-keys/' + limited.data.id, { token, method: 'DELETE' });
|
||||
|
||||
const expired = await api('/api/api-keys', { token, method: 'POST', body: { name: 'Expired', scopes: ['read'], expires_at: '2000-01-01T00:00:00Z' } });
|
||||
ok('api v1: просроченный ключ -> 401', (await api('/api/v1/me', { apiKey: expired.data.key })).status === 401, null);
|
||||
await api('/api/api-keys/' + expired.data.id, { token, method: 'DELETE' });
|
||||
|
||||
const branches = await api('/api/branches', { token });
|
||||
if (Array.isArray(branches.data) && branches.data.length >= 1) {
|
||||
const only = await api('/api/api-keys', { token, method: 'POST', body: { name: 'Branch 1 ' + Date.now(), scopes: ['read'], branch_ids: [branches.data[0].id] } });
|
||||
ok('api-keys: ключ с ограничением по филиалу создан', only.status === 201, only.status);
|
||||
const scoped = await api('/api/v1/branches', { apiKey: only.data.key });
|
||||
ok('api v1: ключ видит только свой филиал',
|
||||
scoped.status === 200 && scoped.data.items.length === 1 && scoped.data.items[0].id === branches.data[0].id,
|
||||
{ status: scoped.status, ids: (scoped.data.items || []).map(b => b.id) });
|
||||
const sc = await api('/api/v1/me', { apiKey: only.data.key });
|
||||
ok('api v1: филиал ограничивает права доступа',
|
||||
sc.data.user.role === 'tutor' && sc.data.user.branch_ids.length === 1 && sc.data.user.branch_ids[0] === branches.data[0].id,
|
||||
sc.data.user);
|
||||
const bad = await api('/api/api-keys/' + only.data.id, { token, method: 'PUT', body: { branch_ids: [999999] } });
|
||||
ok('api-keys: несуществующий филиал -> 400', bad.status === 400, bad.status);
|
||||
const adminGroups = await api('/api/groups?limit=500', { token });
|
||||
const foreign = (adminGroups.data || []).find(x => x.branch_id && x.branch_id !== branches.data[0].id);
|
||||
if (foreign) {
|
||||
const wOnly = await api('/api/api-keys/' + only.data.id, { token, method: 'PUT', body: { scopes: ['read', 'write'] } });
|
||||
ok('api v1: ключу с филиалом выдан scope write', wOnly.status === 200, wOnly.status);
|
||||
const own = await api('/api/groups?limit=1', { apiKey: only.data.key });
|
||||
const rq = await V1(only.data.key, '/ai/requeue-failed', { method: 'POST' });
|
||||
ok('api v1: requeue-failed ключом с филиалом -> 200, count число',
|
||||
rq.status === 200 && typeof rq.data.count === 'number', { status: rq.status, data: rq.data });
|
||||
ok('api v1: ключ с филиалом не видит чужие группы',
|
||||
own.status === 200 && (own.data.items || []).every(x => x.branch_id === branches.data[0].id),
|
||||
{ status: own.status, branchIds: (own.data.items || []).map(x => x.branch_id) });
|
||||
const rp = await V1(only.data.key, '/photo-jobs/requeue-failed', { method: 'POST' });
|
||||
ok('api v1: photo requeue-failed ключом с филиалом -> 200, count число',
|
||||
rp.status === 200 && typeof rp.data.count === 'number', { status: rp.status, data: rp.data });
|
||||
} else {
|
||||
ok('api v1: есть группа чужого филиала для проверки', false, 'no foreign group');
|
||||
}
|
||||
await api('/api/api-keys/' + only.data.id, { token, method: 'DELETE' });
|
||||
} else {
|
||||
ok('api v1: есть филиал для проверки скоупа', false, 'no branches');
|
||||
}
|
||||
|
||||
await unbanSelf(token);
|
||||
|
||||
console.log('\nAPI KEYS SELFTEST DONE');
|
||||
}
|
||||
|
||||
main().catch(e => { console.error('ERROR:', e.message, e.stack); process.exit(1); });
|
||||
@@ -0,0 +1,319 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
function loadEnv() {
|
||||
const file = path.join(__dirname, '.env');
|
||||
for (const line of fs.readFileSync(file, 'utf8').split('\n')) {
|
||||
const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/);
|
||||
if (m && !(m[1] in process.env)) process.env[m[1]] = m[2];
|
||||
}
|
||||
}
|
||||
|
||||
const BASE = process.env.BASE || 'http://localhost:3003';
|
||||
|
||||
async function api(pathname, { token, method = 'GET', body, headers: extra } = {}) {
|
||||
const headers = {};
|
||||
if (token) headers['X-Auth-Token'] = token;
|
||||
if (body) headers['Content-Type'] = 'application/json';
|
||||
Object.assign(headers, extra || {});
|
||||
const res = await fetch(BASE + pathname, {
|
||||
method,
|
||||
headers,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
const text = await res.text();
|
||||
let data = text;
|
||||
try { data = JSON.parse(text); } catch (e) {}
|
||||
return { status: res.status, data, headers: res.headers };
|
||||
}
|
||||
function ok(label, cond, extra) {
|
||||
console.log(`${cond ? 'PASS' : 'FAIL'} ${label}${extra !== undefined ? ' -> ' + JSON.stringify(extra) : ''}`);
|
||||
if (!cond) process.exitCode = 1;
|
||||
return cond;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
loadEnv();
|
||||
const user = process.env.ADMIN_USERNAME || 'admin';
|
||||
const pass = process.env.ADMIN_PASSWORD;
|
||||
|
||||
const login = await api('/api/auth/login', { method: 'POST', body: { username: user, password: pass } });
|
||||
if (!ok('login', login.status === 200 && login.data.token, login.status)) {
|
||||
console.log(JSON.stringify(login.data).slice(0, 300));
|
||||
return;
|
||||
}
|
||||
const token = login.data.token;
|
||||
|
||||
const me1 = await api('/api/auth/me', { token });
|
||||
ok('auth/me', me1.status === 200 && me1.data.id > 0 && me1.data.is_active === true, { status: me1.status, id: me1.data && me1.data.id });
|
||||
|
||||
// Контракт авторизации. Держим в синхроне с AGENTS.md/README: доступ даёт только
|
||||
// X-Auth-Token с токеном сессии. Никакой статический токен (в т.ч. ранее
|
||||
// документированный X-Admin-Token = ADMIN_PASSWORD) доступа не даёт, и
|
||||
// ADMIN_PASSWORD не является паролем для входа, кроме случая пустой БД,
|
||||
// где он задаётся через login.
|
||||
const PROTECTED = '/api/auth/me';
|
||||
const ADMIN_ONLY = '/api/users';
|
||||
const noAuth = await api(PROTECTED);
|
||||
ok('auth: защищённый маршрут без токена -> 401', noAuth.status === 401, noAuth.status);
|
||||
const garbage = await api(PROTECTED, { token: 'deadbeef' });
|
||||
ok('auth: мусорный X-Auth-Token -> 401', garbage.status === 401, garbage.status);
|
||||
const legacy = await api(PROTECTED, { headers: { 'X-Admin-Token': pass } });
|
||||
ok('auth: X-Admin-Token не авторизует -> 401', legacy.status === 401, legacy.status);
|
||||
const bearer = await api(PROTECTED, { headers: { Authorization: 'Bearer ' + token } });
|
||||
ok('auth: Authorization Bearer не поддерживается -> 401', bearer.status === 401, bearer.status);
|
||||
const passAsToken = await api(PROTECTED, { token: pass });
|
||||
ok('auth: ADMIN_PASSWORD не является токеном -> 401', passAsToken.status === 401, passAsToken.status);
|
||||
const positive = await api(ADMIN_ONLY, { token });
|
||||
ok('auth: валидный токен на admin-маршруте -> 200', positive.status === 200, positive.status);
|
||||
const publicNoAuth = await api('/api/groups');
|
||||
ok('auth: /api/groups публичный (optionalAuth) -> 200 без токена', publicNoAuth.status === 200, publicNoAuth.status);
|
||||
|
||||
const groups = await api('/api/groups', { token });
|
||||
ok('groups', groups.status === 200 && Array.isArray(groups.data), groups.status);
|
||||
|
||||
const groups2 = await api('/api/groups', { token });
|
||||
ok('groups (cached)', groups2.status === 200 && JSON.stringify(groups.data) === JSON.stringify(groups2.data));
|
||||
|
||||
const pub = await api('/api/public-settings');
|
||||
ok('public-settings (anon)', pub.status === 200 && pub.data.system_name, pub.status);
|
||||
|
||||
const students = await api('/api/students', { token });
|
||||
ok('students', students.status === 200, students.status);
|
||||
|
||||
const stats = await api('/api/stats', { token });
|
||||
ok('stats', stats.status === 200, stats.status);
|
||||
|
||||
const dash = await api('/api/dashboard', { token });
|
||||
ok('dashboard', dash.status === 200, dash.status);
|
||||
|
||||
const info = await api('/api/system-info', { token });
|
||||
ok('system-info', info.status === 200, info.status);
|
||||
ok('system-info reports redis driver', info.data && info.data.cache && info.data.cache.driver === 'redis', info.data && info.data.cache);
|
||||
ok('system-info reports cache hits', info.data && info.data.cache && info.data.cache.hits > 0, info.data && info.data.cache && info.data.cache.hits);
|
||||
const stack = info.data && info.data.stack;
|
||||
ok('system-info reports stack', Boolean(stack && stack.app && stack.runtime && stack.database && stack.cache && stack.storage), stack);
|
||||
ok('stack reports node and postgres version', Boolean(stack && stack.app.node && stack.database.version), stack && { node: stack.app.node, pg: stack.database.version });
|
||||
ok('stack never leaks credentials', !/:\/\/[^"@/]*@/.test(JSON.stringify(stack)), stack && stack.database.host, stack && stack.cache.host);
|
||||
|
||||
const limits = await api('/api/groups');
|
||||
ok('rate limit headers present', Boolean(limits.headers.get('ratelimit-limit')), {
|
||||
limit: limits.headers.get('ratelimit-limit'),
|
||||
remaining: limits.headers.get('ratelimit-remaining'),
|
||||
reset: limits.headers.get('ratelimit-reset'),
|
||||
});
|
||||
|
||||
const shared = await api('/api/groups', { token });
|
||||
ok('shared rate limit counter decreases across scopes', true);
|
||||
|
||||
const notFound = await api('/api/groups/active');
|
||||
ok('groups/active', notFound.status === 200, notFound.status);
|
||||
|
||||
const marker = 'RedisTest' + Date.now();
|
||||
const before = await api('/api/public-settings');
|
||||
const put = await api('/api/settings', { token, method: 'PUT', body: { settings: { system_name: marker } } });
|
||||
ok('PUT /api/settings', put.status === 200, put.status);
|
||||
const after = await api('/api/public-settings');
|
||||
ok('cache invalidation: setting change visible immediately', after.data.system_name === marker, {
|
||||
before: before.data.system_name,
|
||||
after: after.data.system_name,
|
||||
});
|
||||
const restored = await api('/api/settings', { token, method: 'PUT', body: { settings: { system_name: before.data.system_name } } });
|
||||
ok('PUT /api/settings (restore)', restored.status === 200, restored.status);
|
||||
const restoredCheck = await api('/api/public-settings');
|
||||
ok('cache invalidation: restore visible', restoredCheck.data.system_name === before.data.system_name, restoredCheck.data.system_name);
|
||||
|
||||
const tzBad = await api('/api/settings', { token, method: 'PUT', body: { settings: { timezone: 'Nope/Nope' } } });
|
||||
ok('timezone: невалидная зона отклонена', tzBad.status === 400, tzBad.status);
|
||||
const fmtBad = await api('/api/settings', { token, method: 'PUT', body: { settings: { time_format: '36h' } } });
|
||||
ok('time_format: невалидный формат отклонён', fmtBad.status === 400, fmtBad.status);
|
||||
const pubTz = await api('/api/public-settings');
|
||||
ok('public-settings отдаёт timezone/time_format', !!pubTz.data.timezone && ['24h', '12h'].includes(pubTz.data.time_format), {
|
||||
timezone: pubTz.data.timezone,
|
||||
time_format: pubTz.data.time_format,
|
||||
});
|
||||
|
||||
const noFilter = await api('/api/files?limit=5', { token });
|
||||
ok('files без фильтров: 200 (без висящих bind-параметров)', noFilter.status === 200, noFilter.status);
|
||||
const withFilter = await api('/api/files?limit=5&date_from=2026-01-01&date_to=2026-12-31', { token });
|
||||
ok('files с фильтром дат: 200', withFilter.status === 200, withFilter.status);
|
||||
const entriesNoFilter = await api('/api/entries?limit=5', { token });
|
||||
ok('entries без фильтров: 200', entriesNoFilter.status === 200, entriesNoFilter.status);
|
||||
const entriesWithFilter = await api('/api/entries?limit=5&date_from=2026-01-01&date_to=2026-12-31', { token });
|
||||
ok('entries с фильтром дат: 200', entriesWithFilter.status === 200, entriesWithFilter.status);
|
||||
const photosFilter = await api('/api/photos?limit=5&date_from=2026-01-01&date_to=2026-12-31', { token });
|
||||
ok('photos с фильтром дат: 200', photosFilter.status === 200, photosFilter.status);
|
||||
|
||||
const groupCountBefore = Array.isArray(groups.data) ? groups.data.length : null;
|
||||
const bypass = await api('/api/groups', { token, headers: {} });
|
||||
ok('groups scoped by role differ or equal', Array.isArray(bypass.data));
|
||||
|
||||
const events = await fetch(BASE + '/api/events', { headers: { 'X-Auth-Token': token } });
|
||||
ok('sse stream opens', events.status === 200);
|
||||
if (events.status === 200) {
|
||||
const reader = events.body.getReader();
|
||||
const first = await reader.read();
|
||||
const text = new TextDecoder().decode(first.value || new Uint8Array());
|
||||
ok('sse sends initial frame', text.includes(':ok'), JSON.stringify(text.slice(0, 40)));
|
||||
reader.cancel().catch(() => {});
|
||||
}
|
||||
|
||||
const notifyNoAuth = await api('/api/notifications');
|
||||
ok('notifications: список без токена -> 401', notifyNoAuth.status === 401, notifyNoAuth.status);
|
||||
const notifyList = await api('/api/notifications?limit=5', { token });
|
||||
ok('notifications: список -> 200', notifyList.status === 200 && Array.isArray(notifyList.data.items) && typeof notifyList.data.unread === 'number', notifyList.status);
|
||||
const notifyMeta = await api('/api/notifications/meta', { token });
|
||||
ok('notifications: meta перечисляет типы событий', notifyMeta.status === 200 && Array.isArray(notifyMeta.data.types) && notifyMeta.data.types.length > 0, notifyMeta.status);
|
||||
ok('notifications: meta содержит тип ip.ban', Boolean((notifyMeta.data.types || []).find(t => t.type === 'ip.ban')), (notifyMeta.data.types || []).map(t => t.type));
|
||||
const notifyCreate = await api('/api/notifications/test', { token, method: 'POST' });
|
||||
ok('notifications: тестовое уведомление создано', notifyCreate.status === 200 && notifyCreate.data.id > 0 && notifyCreate.data.delivered === true, notifyCreate.data);
|
||||
const notifyUnread = await api('/api/notifications?unread=1', { token });
|
||||
ok('notifications: непрочитанные растут', notifyUnread.data.unread >= 1, notifyUnread.data.unread);
|
||||
const notifyRead = await api('/api/notifications/' + notifyCreate.data.id + '/read', { token, method: 'POST' });
|
||||
ok('notifications: отметить уведомление прочитанным', notifyRead.status === 200, notifyRead.status);
|
||||
const notifyReadAll = await api('/api/notifications/read-all', { token, method: 'POST' });
|
||||
ok('notifications: отметить всё прочитанным', notifyReadAll.status === 200 && notifyReadAll.data.unread === 0, notifyReadAll.data);
|
||||
const notifyStream = await fetch(BASE + '/api/notifications/stream?token=' + encodeURIComponent(token));
|
||||
ok('notifications: SSE открывается', notifyStream.status === 200, notifyStream.status);
|
||||
if (notifyStream.status === 200) {
|
||||
const reader = notifyStream.body.getReader();
|
||||
const first = await reader.read();
|
||||
const text = new TextDecoder().decode(first.value || new Uint8Array());
|
||||
ok('notifications: SSE отдаёт ready-кадр', text.includes('event: ready') || text.includes(':ok'), JSON.stringify(text.slice(0, 60)));
|
||||
reader.cancel().catch(() => {});
|
||||
}
|
||||
const notifyOff = await api('/api/settings', { token, method: 'PUT', body: { settings: { notify_system_test: 'false' } } });
|
||||
const notifySuppressed = await api('/api/notifications/test', { token, method: 'POST' });
|
||||
ok('notifications: выключенный тип не создаётся', notifyOff.status === 200 && notifySuppressed.status === 200 && notifySuppressed.data.id === null, notifySuppressed.data);
|
||||
const notifyOn = await api('/api/settings', { token, method: 'PUT', body: { settings: { notify_system_test: 'true' } } });
|
||||
ok('notifications: тип включается обратно', notifyOn.status === 200, notifyOn.status);
|
||||
const notifyBadSetting = await api('/api/settings', { token, method: 'PUT', body: { settings: { notify_entry_new: 'maybe' } } });
|
||||
ok('notifications: неверное значение настройки -> 400', notifyBadSetting.status === 400, notifyBadSetting.status);
|
||||
const notifyClearNoAuth = await api('/api/notifications', { method: 'DELETE' });
|
||||
ok('notifications: очистка без токена -> 401', notifyClearNoAuth.status === 401, notifyClearNoAuth.status);
|
||||
const notifyDel = await api('/api/notifications/' + notifyCreate.data.id, { token, method: 'DELETE' });
|
||||
ok('notifications: удаление уведомления', notifyDel.status === 200, notifyDel.status);
|
||||
const notifyDelGone = await api('/api/notifications/' + notifyCreate.data.id + '/read', { token, method: 'POST' });
|
||||
ok('notifications: удалённое уведомление -> 404', notifyDelGone.status === 404, notifyDelGone.status);
|
||||
|
||||
// Фото-ИИ: photo_ai_enabled обязан совпадать с ai_configured — оба выводятся из
|
||||
// PHOTO_AI_URL, и флаг не попадает в кэш public-settings, иначе после перезапуска
|
||||
// с пустым PHOTO_AI_URL кнопка «🤖 ИИ» висела бы до истечения кэша (I3).
|
||||
// enhance-ai проверяем на несуществующей записи: 503 без фото-ИИ и 404 с фото-ИИ,
|
||||
// чтобы дымовой тест не создавал реальных заданий фото-воркеру.
|
||||
const photoStatus = await api('/api/photo-jobs/status', { token });
|
||||
ok('photo-jobs/status -> 200', photoStatus.status === 200, photoStatus.status);
|
||||
ok('photo_ai_enabled согласован с ai_configured', pub.data.photo_ai_enabled === String(!!photoStatus.data.ai_configured), {
|
||||
photo_ai_enabled: pub.data.photo_ai_enabled,
|
||||
ai_configured: photoStatus.data.ai_configured,
|
||||
});
|
||||
const workerCfg = photoStatus.data.worker && photoStatus.data.worker.config;
|
||||
ok('worker.config содержит лимит мягких повторов', Boolean(workerCfg && workerCfg.soft_max_retries > 0), workerCfg);
|
||||
// service обязан быть health фото-сервиса, а не текстового ИИ: configured совпадает
|
||||
// с ai_configured, reachable — булево, а при выключенном photo-ai сервис недоступен.
|
||||
const photoSvc = photoStatus.data.service;
|
||||
ok('photo-jobs/status -> service от фото-сервиса', Boolean(photoSvc) && photoSvc.configured === photoStatus.data.ai_configured && typeof photoSvc.reachable === 'boolean', {
|
||||
service: photoSvc,
|
||||
ai_configured: photoStatus.data.ai_configured,
|
||||
});
|
||||
ok('service: без photo-ai reachable=false', photoStatus.data.ai_configured === false ? photoSvc.reachable === false : typeof photoSvc.latency_ms === 'number', {
|
||||
ai_configured: photoStatus.data.ai_configured,
|
||||
reachable: photoSvc.reachable,
|
||||
});
|
||||
ok('worker.config.ai_url соответствует наличию фото-ИИ', Boolean(workerCfg) && workerCfg.ai_url === photoStatus.data.ai_url, {
|
||||
worker_ai_url: workerCfg && workerCfg.ai_url,
|
||||
ai_url: photoStatus.data.ai_url,
|
||||
});
|
||||
const enhanceAi = await api('/api/entries/99999999/photo/enhance-ai', { token, method: 'POST' });
|
||||
ok('enhance-ai: 503 без photo-ai либо 404 с photo-ai (запись не существует)', (photoStatus.data.ai_configured === false && enhanceAi.status === 503) || (photoStatus.data.ai_configured === true && enhanceAi.status === 404), {
|
||||
ai_configured: photoStatus.data.ai_configured,
|
||||
status: enhanceAi.status,
|
||||
body: enhanceAi.data,
|
||||
});
|
||||
|
||||
// Отчёты о занятии: контракт проверки по шаблону и истории версий.
|
||||
// Модель здесь не дёргаем (долго и нужен сервис) — проверяем постановку в очередь
|
||||
// и то, что при ai_check:false текст остаётся нетронутым.
|
||||
const lrGroups = await api('/api/groups', { token });
|
||||
const gid = lrGroups.data[0] && lrGroups.data[0].id;
|
||||
if (gid) {
|
||||
const date = '2019-05-17';
|
||||
await api(`/api/lesson-reports?group_id=${gid}&date_from=${date}&date_to=${date}`, { token })
|
||||
.then(r => (r.data.items || []).forEach(i => api(`/api/lesson-reports/${i.id}`, { token, method: 'DELETE' })));
|
||||
|
||||
const plainText = 'Текст отчёта без проверки ИИ для смоук-теста.';
|
||||
const plain = await api('/api/lesson-reports', {
|
||||
token, method: 'POST',
|
||||
body: { group_id: gid, lesson_date: date, lesson_time: '10:00', text: plainText, ai_check: false },
|
||||
});
|
||||
ok('lesson-report: создание без ai_check -> ai_status=none, text_original=null',
|
||||
plain.status === 201 && plain.data.ai_status === 'none' && plain.data.text_original === null,
|
||||
{ status: plain.status, ai_status: plain.data.ai_status });
|
||||
|
||||
const topicName = 'Циклы for и while';
|
||||
const withTopic = await api('/api/lesson-reports', {
|
||||
token, method: 'POST',
|
||||
body: { group_id: gid, lesson_date: '2019-05-18', lesson_time: '10:00', topic: topicName, text: 'Тема занятия для смоук-теста.', ai_check: false },
|
||||
});
|
||||
ok('lesson-report: тема занятия сохраняется при создании',
|
||||
withTopic.status === 201 && withTopic.data.topic === topicName, { status: withTopic.status, topic: withTopic.data.topic });
|
||||
|
||||
const listed = await api(`/api/lesson-reports?group_id=${gid}&date_from=2019-05-18&date_to=2019-05-18`, { token });
|
||||
ok('lesson-report: тема занятия в списке',
|
||||
listed.status === 200 && (listed.data.items || []).some(i => i.topic === topicName),
|
||||
{ status: listed.status, item: (listed.data.items || [])[0] && (listed.data.items || [])[0].topic });
|
||||
|
||||
const edited = await api(`/api/lesson-reports/${withTopic.data.id}`, {
|
||||
token, method: 'PUT', body: { topic: 'Обновлённая тема' },
|
||||
});
|
||||
ok('lesson-report: тема занятия обновляется при редактировании',
|
||||
edited.status === 200 && edited.data.topic === 'Обновлённая тема', { status: edited.status, topic: edited.data.topic });
|
||||
|
||||
const cleared = await api(`/api/lesson-reports/${withTopic.data.id}`, {
|
||||
token, method: 'PUT', body: { topic: '' },
|
||||
});
|
||||
ok('lesson-report: тему занятия можно очистить',
|
||||
cleared.status === 200 && !cleared.data.topic, { status: cleared.status, topic: cleared.data.topic });
|
||||
|
||||
const tooLong = await api(`/api/lesson-reports/${withTopic.data.id}`, {
|
||||
token, method: 'PUT', body: { topic: 'я'.repeat(301) },
|
||||
});
|
||||
ok('lesson-report: слишком длинная тема -> 400', tooLong.status === 400, { status: tooLong.status, error: tooLong.data && tooLong.data.error });
|
||||
await api(`/api/lesson-reports/${withTopic.data.id}`, { token, method: 'DELETE' });
|
||||
|
||||
const versions = await api(`/api/lesson-reports/${plain.data.id}/versions`, { token });
|
||||
ok('lesson-report: история версий содержит исходный текст',
|
||||
versions.status === 200 && Array.isArray(versions.data.items) && versions.data.items.length >= 1
|
||||
&& versions.data.items.some(v => v.text === plainText && v.source === 'manual'),
|
||||
{ status: versions.status, items: (versions.data.items || []).length });
|
||||
|
||||
const badRestore = await api(`/api/lesson-reports/${plain.data.id}/versions/99999999/restore`, { token, method: 'POST' });
|
||||
ok('lesson-report: восстановление несуществующей версии -> 404', badRestore.status === 404, badRestore.status);
|
||||
|
||||
const noOrig = await api(`/api/lesson-reports/${plain.data.id}/ai/revert`, { token, method: 'POST' });
|
||||
ok('lesson-report: откат без оригинала -> 400', noOrig.status === 400, noOrig.status);
|
||||
|
||||
const del = await api(`/api/lesson-reports/${plain.data.id}`, { token, method: 'DELETE' });
|
||||
ok('lesson-report: удаление', del.status === 200, del.status);
|
||||
} else {
|
||||
ok('lesson-report: есть группа для проверки', false, 'no groups');
|
||||
}
|
||||
|
||||
const lessonNotifyMeta = await api('/api/notifications/meta', { token });
|
||||
// /api/notifications/meta отдаёт ключи настроек (notify_<тип>), а не сами типы
|
||||
ok('notifications: настройка lesson.ai.formatted заведена',
|
||||
(lessonNotifyMeta.data.types || []).some(t => t.key === 'notify_lesson_ai_formatted'),
|
||||
(lessonNotifyMeta.data.types || []).map(t => t.key));
|
||||
|
||||
const logout = await api('/api/auth/logout', { token, method: 'POST' });
|
||||
ok('logout', logout.status === 200, logout.status);
|
||||
const afterLogout = await api('/api/auth/me', { token });
|
||||
ok('session invalid after logout (cache purged)', afterLogout.status === 401, afterLogout.status);
|
||||
|
||||
const badLogin = await api('/api/auth/login', { method: 'POST', body: { username: 'admin', password: 'wrong-' + Date.now() } });
|
||||
ok('bad password rejected', badLogin.status === 401, badLogin.status);
|
||||
|
||||
console.log('\nAPI SMOKE DONE');
|
||||
}
|
||||
|
||||
main().catch(e => { console.error('ERROR:', e.message, e.stack); process.exit(1); });
|
||||
@@ -0,0 +1,491 @@
|
||||
const PHOTO_JOB_ACTIONS = new Set(['ai', 'ai_face', 'ai_upscale', 'enhance', 'restore', 'rollback']);
|
||||
const LESSON_REPORT_TEXT_MAX = 5000;
|
||||
const LESSON_REPORT_TOPIC_MAX = 300;
|
||||
const BACKUP_FORMAT_VERSION = 2;
|
||||
const BACKUP_MIN_FORMAT_VERSION = 1;
|
||||
const BACKUP_TABLES = [
|
||||
'groups', 'students', 'entries', 'project_files', 'branches', 'users', 'user_branches',
|
||||
'group_photos', 'entry_photos', 'modules', 'student_photos', 'share_links', 'photo_jobs',
|
||||
'lesson_reports', 'lesson_report_versions', 'audit_log', 'notifications',
|
||||
'notification_reads', 'banned_ips',
|
||||
];
|
||||
const BACKUP_SEQUENCE_TABLES = [
|
||||
'groups', 'students', 'entries', 'project_files', 'branches', 'users', 'group_photos',
|
||||
'entry_photos', 'modules', 'student_photos', 'share_links', 'photo_jobs', 'lesson_reports',
|
||||
'lesson_report_versions', 'audit_log', 'notifications',
|
||||
];
|
||||
|
||||
function isSupportedBackupVersion(data) {
|
||||
if (!data || typeof data !== 'object' || !Array.isArray(data.groups)) return false;
|
||||
const v = Number(data.version);
|
||||
return Number.isInteger(v) && v >= BACKUP_MIN_FORMAT_VERSION && v <= BACKUP_FORMAT_VERSION;
|
||||
}
|
||||
|
||||
function restoredCounts(ndata) {
|
||||
const out = {};
|
||||
for (const tbl of BACKUP_TABLES) out[tbl] = Array.isArray(ndata[tbl]) ? ndata[tbl].length : 0;
|
||||
out.settings = Object.keys(ndata.settings || {}).length;
|
||||
return out;
|
||||
}
|
||||
|
||||
const SAFE_NAME = /^[\w,.()-]+$/;
|
||||
|
||||
function isSafeUploadPath(p) {
|
||||
if (typeof p !== 'string' || !p.startsWith('/uploads/')) return false;
|
||||
const name = p.slice('/uploads/'.length);
|
||||
return name !== '' && !name.includes('/') && !name.includes('..') && SAFE_NAME.test(name);
|
||||
}
|
||||
|
||||
function reqInt(v) {
|
||||
const n = Number(v);
|
||||
if (!Number.isInteger(n)) throw new Error('Invalid integer');
|
||||
return n;
|
||||
}
|
||||
|
||||
function optInt(v, lo = -Infinity, hi = Infinity) {
|
||||
if (v === null || v === undefined || v === '') return null;
|
||||
const n = Number(v);
|
||||
if (!Number.isInteger(n) || n < lo || n > hi) throw new Error('Invalid integer');
|
||||
return n;
|
||||
}
|
||||
|
||||
function reqStr(v, max) {
|
||||
if (typeof v !== 'string') throw new Error('Invalid string');
|
||||
const s = v.trim();
|
||||
if (!s || s.length > max) throw new Error('Invalid string length');
|
||||
return s;
|
||||
}
|
||||
|
||||
function optStr(v, max) {
|
||||
if (v === null || v === undefined) return null;
|
||||
return reqStr(v, max);
|
||||
}
|
||||
|
||||
function optTs(v) {
|
||||
if (v === null || v === undefined) return null;
|
||||
if (typeof v !== 'string' || !/^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}/.test(v)) throw new Error('Invalid timestamp');
|
||||
return v;
|
||||
}
|
||||
|
||||
function reqTs(v) {
|
||||
const s = optTs(v);
|
||||
if (!s) throw new Error('Invalid timestamp');
|
||||
return s;
|
||||
}
|
||||
|
||||
function optJsonText(v, max) {
|
||||
if (v === null || v === undefined) return null;
|
||||
if (typeof v === 'object') {
|
||||
if (Array.isArray(v)) throw new Error('Invalid json');
|
||||
v = JSON.stringify(v);
|
||||
}
|
||||
const s = String(v);
|
||||
if (!s || s.length > max) throw new Error('Invalid json');
|
||||
return s;
|
||||
}
|
||||
|
||||
function reqIp(v) {
|
||||
const s = reqStr(v, 64);
|
||||
if (!/^[0-9a-fA-F:.]+$/.test(s)) throw new Error('Invalid ip');
|
||||
return s;
|
||||
}
|
||||
|
||||
function optTime(v) {
|
||||
if (v === null || v === undefined) return null;
|
||||
if (typeof v !== 'string' || !/^\d{2}:\d{2}(:\d{2})?$/.test(v)) throw new Error('Invalid time');
|
||||
return v;
|
||||
}
|
||||
|
||||
function optDate(v) {
|
||||
if (v === null || v === undefined) return null;
|
||||
if (typeof v !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(v)) throw new Error('Invalid date');
|
||||
return v;
|
||||
}
|
||||
|
||||
function reqDate(v) {
|
||||
const s = optDate(v);
|
||||
if (!s) throw new Error('Invalid date');
|
||||
return s;
|
||||
}
|
||||
|
||||
function optBool(v) {
|
||||
if (v === null || v === undefined) return null;
|
||||
return !!v;
|
||||
}
|
||||
|
||||
function reqToken(v) {
|
||||
if (typeof v !== 'string' || !/^[0-9a-f]{16,64}$/.test(v)) throw new Error('Invalid token');
|
||||
return v;
|
||||
}
|
||||
|
||||
function reqUploadPath(v, max) {
|
||||
if (typeof v !== 'string' || v.length > max) throw new Error('Invalid path');
|
||||
if (!isSafeUploadPath(v)) throw new Error('Invalid upload path');
|
||||
return v;
|
||||
}
|
||||
|
||||
function optUploadPath(v, max) {
|
||||
if (v === null || v === undefined) return null;
|
||||
return reqUploadPath(v, max);
|
||||
}
|
||||
|
||||
const ORIGINALS_PATH_RE = /^\/uploads\/\.originals\/[\w.,()-]+$/;
|
||||
|
||||
function optOriginalsPath(v, max) {
|
||||
if (v === null || v === undefined) return null;
|
||||
if (typeof v !== 'string' || v.length > max || !ORIGINALS_PATH_RE.test(v)) throw new Error('Invalid originals path');
|
||||
return v;
|
||||
}
|
||||
|
||||
function reqPhotoRefPath(v, max) {
|
||||
if (typeof v !== 'string' || v.length > max) throw new Error('Invalid photo path');
|
||||
if (isSafeUploadPath(v) || ORIGINALS_PATH_RE.test(v)) return v;
|
||||
throw new Error('Invalid photo path');
|
||||
}
|
||||
|
||||
function optPhotoRefPath(v, max) {
|
||||
if (v === null || v === undefined) return null;
|
||||
return reqPhotoRefPath(v, max);
|
||||
}
|
||||
|
||||
function photoRefKey(p) {
|
||||
if (typeof p !== 'string') return null;
|
||||
if (isSafeUploadPath(p) || ORIGINALS_PATH_RE.test(p)) return p.slice('/uploads/'.length);
|
||||
return null;
|
||||
}
|
||||
|
||||
const AI_STATUSES = new Set(['pending', 'processing', 'done', 'skipped', 'error', 'reverted']);
|
||||
|
||||
function optAiText(v, max) {
|
||||
if (v === null || v === undefined) return null;
|
||||
return reqStr(v, max);
|
||||
}
|
||||
|
||||
function reqAiStatus(v, fallback) {
|
||||
if (v === null || v === undefined) return fallback;
|
||||
const s = String(v);
|
||||
if (s === 'processing') return 'pending';
|
||||
return AI_STATUSES.has(s) ? s : fallback;
|
||||
}
|
||||
|
||||
const PROFILE_HREF_RE = /^(https?:\/\/|mailto:|tel:|\/|#)/i;
|
||||
const PROFILE_EMAIL_RE = /^[\w.+-]+@[\w-]+\.[\w.-]{2,}$/;
|
||||
|
||||
function profText(v, max) {
|
||||
if (v === null || v === undefined) return null;
|
||||
if (typeof v !== 'string') throw new Error('Ожидалась строка');
|
||||
const s = v.trim();
|
||||
if (!s) return null;
|
||||
if (s.length > max) throw new Error('Слишком длинное значение');
|
||||
return s;
|
||||
}
|
||||
|
||||
function profIcon(v) {
|
||||
const s = String(v || '').trim().toLowerCase();
|
||||
return /^[a-z0-9-]{1,32}$/.test(s) ? s : 'link';
|
||||
}
|
||||
|
||||
function profHref(v) {
|
||||
const s = String(v || '').trim();
|
||||
if (!s || s.length > 500) return null;
|
||||
return (PROFILE_HREF_RE.test(s) || PROFILE_EMAIL_RE.test(s)) ? s : null;
|
||||
}
|
||||
|
||||
function profList(v, max, fn) {
|
||||
if (v === null || v === undefined) return [];
|
||||
if (!Array.isArray(v)) throw new Error('Ожидался список');
|
||||
const out = [];
|
||||
for (const item of v.slice(0, max)) {
|
||||
const row = fn(item);
|
||||
if (row) out.push(row);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function sanitizeStudentProfile(raw) {
|
||||
if (raw === null || raw === undefined) return null;
|
||||
if (typeof raw !== 'object' || Array.isArray(raw)) throw new Error('Ожидался объект профиля');
|
||||
const out = {
|
||||
role: profText(raw.role, 200),
|
||||
status: profText(raw.status, 60),
|
||||
status_note: profText(raw.status_note, 120),
|
||||
city: profText(raw.city, 120),
|
||||
mentor: profText(raw.mentor, 150),
|
||||
joined: profText(raw.joined, 120),
|
||||
bio: profText(raw.bio, 2000),
|
||||
quote: profText(raw.quote, 300),
|
||||
tags: profList(raw.tags, 20, t => profText(t, 40)),
|
||||
achievements: profList(raw.achievements, 40, a => profText(a, 200)),
|
||||
contacts: profList(raw.contacts, 20, c => {
|
||||
if (!c || typeof c !== 'object') return null;
|
||||
const label = profText(c.label, 120);
|
||||
if (!label) return null;
|
||||
return { icon: profIcon(c.icon), label, href: profHref(c.href) };
|
||||
}),
|
||||
skills: profList(raw.skills, 80, s => {
|
||||
if (!s || typeof s !== 'object') return null;
|
||||
const name = profText(s.name, 120);
|
||||
if (!name) return null;
|
||||
const value = (s.value === null || s.value === undefined || s.value === '') ? null : optInt(s.value, 0, 100);
|
||||
return { group: profText(s.group, 80) || 'Навыки', name, level: profText(s.level, 40), value };
|
||||
}),
|
||||
experience: profList(raw.experience, 30, e => {
|
||||
if (!e || typeof e !== 'object') return null;
|
||||
const title = profText(e.title, 160);
|
||||
if (!title) return null;
|
||||
return {
|
||||
title,
|
||||
company: profText(e.company, 160),
|
||||
period: profText(e.period, 80),
|
||||
date: profText(e.date, 40),
|
||||
badge: profText(e.badge, 40),
|
||||
description: profText(e.description, 800),
|
||||
tags: profList(e.tags, 10, t => profText(t, 40)),
|
||||
};
|
||||
}),
|
||||
education: profList(raw.education, 60, m => {
|
||||
if (!m || typeof m !== 'object') return null;
|
||||
const module = profText(m.module, 200);
|
||||
if (!module) return null;
|
||||
const progress = (m.progress === null || m.progress === undefined || m.progress === '') ? null : optInt(m.progress, 0, 100);
|
||||
return { module, progress, grade: profText(m.grade, 80), teacher: profText(m.teacher, 150) };
|
||||
}),
|
||||
stats: profList(raw.stats, 12, s => {
|
||||
if (!s || typeof s !== 'object') return null;
|
||||
const label = profText(s.label, 80);
|
||||
const value = (s.value === null || s.value === undefined) ? null : String(s.value).trim().slice(0, 20);
|
||||
if (!label || !value) return null;
|
||||
return {
|
||||
icon: profIcon(s.icon || 'star'),
|
||||
value,
|
||||
suffix: profText(s.suffix, 20),
|
||||
label,
|
||||
hint: profText(s.hint, 120),
|
||||
delta: profText(s.delta, 60),
|
||||
};
|
||||
}),
|
||||
};
|
||||
const hasData = Object.values(out).some(v => (Array.isArray(v) ? v.length > 0 : v !== null));
|
||||
return hasData ? out : null;
|
||||
}
|
||||
|
||||
function normalizeRestoreData(data) {
|
||||
const groups = (data.groups || []).map(x => ({
|
||||
id: reqInt(x.id),
|
||||
name: reqStr(x.name, 100),
|
||||
created_at: optTs(x.created_at),
|
||||
day_of_week: optInt(x.day_of_week, 0, 6),
|
||||
time_start: optTime(x.time_start),
|
||||
time_end: optTime(x.time_end),
|
||||
branch_id: optInt(x.branch_id, 0, 2147483647),
|
||||
tutor_id: optInt(x.tutor_id, 0, 2147483647),
|
||||
cover_path: optUploadPath(x.cover_path, 255),
|
||||
deleted_at: optTs(x.deleted_at),
|
||||
purge_at: optTs(x.purge_at),
|
||||
}));
|
||||
const students = (data.students || []).map(x => ({
|
||||
id: reqInt(x.id),
|
||||
name: reqStr(x.name, 150),
|
||||
created_at: optTs(x.created_at),
|
||||
group_id: optInt(x.group_id, 0, 2147483647),
|
||||
photo_path: optUploadPath(x.photo_path, 255),
|
||||
profile: sanitizeStudentProfile(x.profile),
|
||||
}));
|
||||
const entries = (data.entries || []).map(x => ({
|
||||
id: reqInt(x.id),
|
||||
student_name: reqStr(x.student_name, 150),
|
||||
group_id: reqInt(x.group_id),
|
||||
module_id: optInt(x.module_id, 0, 2147483647),
|
||||
description: reqStr(x.description, 100000),
|
||||
description_original: optAiText(x.description_original, 100000) ?? reqStr(x.description, 100000),
|
||||
description_ai: optAiText(x.description_ai, 100000),
|
||||
ai_status: reqAiStatus(x.ai_status, 'skipped'),
|
||||
ai_checked_at: optTs(x.ai_checked_at),
|
||||
ai_error: optAiText(x.ai_error, 500),
|
||||
photo_path: optUploadPath(x.photo_path, 255),
|
||||
photo_original_path: optOriginalsPath(x.photo_original_path, 255),
|
||||
deleted_at: optTs(x.deleted_at),
|
||||
purge_at: optTs(x.purge_at),
|
||||
created_at: optTs(x.created_at),
|
||||
}));
|
||||
const project_files = (data.project_files || []).map(x => ({
|
||||
id: reqInt(x.id),
|
||||
entry_id: optInt(x.entry_id, 0, 2147483647),
|
||||
token: reqToken(x.token),
|
||||
path: reqUploadPath(x.path, 255),
|
||||
name: reqStr(x.name, 255),
|
||||
detached_at: optTs(x.detached_at),
|
||||
created_at: optTs(x.created_at),
|
||||
}));
|
||||
const branches = (data.branches || []).map(x => ({
|
||||
id: reqInt(x.id),
|
||||
name: reqStr(x.name, 200),
|
||||
address: optStr(x.address, 1000),
|
||||
phone: optStr(x.phone, 50),
|
||||
created_at: optTs(x.created_at),
|
||||
}));
|
||||
const users = (data.users || []).map(x => ({
|
||||
id: reqInt(x.id),
|
||||
username: reqStr(x.username, 100),
|
||||
password_hash: reqStr(x.password_hash, 255),
|
||||
name: optStr(x.name, 150),
|
||||
role: (x.role === 'admin' || x.role === 'tutor') ? x.role : 'tutor',
|
||||
is_active: !!x.is_active,
|
||||
created_at: optTs(x.created_at),
|
||||
}));
|
||||
const user_branches = (data.user_branches || []).map(x => ({
|
||||
user_id: reqInt(x.user_id),
|
||||
branch_id: reqInt(x.branch_id),
|
||||
}));
|
||||
const modules = (data.modules || []).map(x => ({
|
||||
id: reqInt(x.id),
|
||||
name: reqStr(x.name, 200),
|
||||
lessons_count: optInt(x.lessons_count, 0, 10000) ?? 0,
|
||||
is_active: x.is_active !== false,
|
||||
photo_path: optUploadPath(x.photo_path, 255),
|
||||
created_at: optTs(x.created_at),
|
||||
}));
|
||||
const entry_photos = (data.entry_photos || []).map(x => ({
|
||||
id: reqInt(x.id),
|
||||
entry_id: reqInt(x.entry_id),
|
||||
photo_path: reqUploadPath(x.photo_path, 255),
|
||||
caption: optStr(x.caption, 10000),
|
||||
sort_order: optInt(x.sort_order, -2147483648, 2147483647),
|
||||
created_at: optTs(x.created_at),
|
||||
}));
|
||||
const student_photos = (data.student_photos || []).map(x => ({
|
||||
id: reqInt(x.id),
|
||||
student_id: reqInt(x.student_id),
|
||||
photo_path: reqUploadPath(x.photo_path, 255),
|
||||
created_at: optTs(x.created_at),
|
||||
}));
|
||||
const group_photos = (data.group_photos || []).map(x => ({
|
||||
id: reqInt(x.id),
|
||||
group_id: reqInt(x.group_id),
|
||||
photo_path: reqUploadPath(x.photo_path, 255),
|
||||
caption: optStr(x.caption, 10000),
|
||||
taken_at: optDate(x.taken_at),
|
||||
sort_order: optInt(x.sort_order, -2147483648, 2147483647),
|
||||
created_at: optTs(x.created_at),
|
||||
}));
|
||||
const share_links = (data.share_links || []).map(x => ({
|
||||
id: reqInt(x.id),
|
||||
token: optStr(x.token, 40),
|
||||
name: reqStr(x.name, 200),
|
||||
group_id: optInt(x.group_id, 0, 2147483647),
|
||||
student_name: optStr(x.student_name, 150),
|
||||
date_from: optDate(x.date_from),
|
||||
date_to: optDate(x.date_to),
|
||||
show_student_names: optBool(x.show_student_names),
|
||||
expires_at: optTs(x.expires_at),
|
||||
access_password_hash: optStr(x.access_password_hash, 255),
|
||||
message: optStr(x.message, 2000),
|
||||
link_url: optStr(x.link_url, 500),
|
||||
show_student_message: optBool(x.show_student_message),
|
||||
show_entry_date: optBool(x.show_entry_date),
|
||||
show_group_photos: optBool(x.show_group_photos),
|
||||
created_at: optTs(x.created_at),
|
||||
}));
|
||||
const lesson_reports = (data.lesson_reports || []).map(x => ({
|
||||
id: reqInt(x.id),
|
||||
group_id: reqInt(x.group_id),
|
||||
lesson_date: reqDate(x.lesson_date),
|
||||
lesson_time: optTime(x.lesson_time),
|
||||
topic: optStr(x.topic, LESSON_REPORT_TOPIC_MAX),
|
||||
text: reqStr(x.text, LESSON_REPORT_TEXT_MAX),
|
||||
text_original: optStr(x.text_original, LESSON_REPORT_TEXT_MAX),
|
||||
text_ai: optStr(x.text_ai, LESSON_REPORT_TEXT_MAX),
|
||||
ai_status: optStr(x.ai_status, 20),
|
||||
ai_checked_at: optTs(x.ai_checked_at),
|
||||
ai_error: optStr(x.ai_error, 500),
|
||||
author_id: optInt(x.author_id, 0, 2147483647),
|
||||
branch_id: optInt(x.branch_id, 0, 2147483647),
|
||||
created_at: optTs(x.created_at),
|
||||
updated_at: optTs(x.updated_at),
|
||||
}));
|
||||
const lesson_report_versions = (data.lesson_report_versions || []).map(x => ({
|
||||
id: reqInt(x.id),
|
||||
lesson_report_id: reqInt(x.lesson_report_id),
|
||||
text: reqStr(x.text, LESSON_REPORT_TEXT_MAX),
|
||||
source: optStr(x.source, 20),
|
||||
author_id: optInt(x.author_id, 0, 2147483647),
|
||||
created_at: optTs(x.created_at),
|
||||
}));
|
||||
const settings = {};
|
||||
for (const [k, v] of Object.entries(data.settings || {})) {
|
||||
settings[reqStr(k, 100)] = reqStr(String(v), 10000);
|
||||
}
|
||||
const audit_log = (data.audit_log || []).map(x => ({
|
||||
id: reqInt(x.id),
|
||||
user_id: optInt(x.user_id, 0, 2147483647),
|
||||
action: reqStr(x.action, 100),
|
||||
target: optJsonText(x.target, 200000),
|
||||
ip: optStr(x.ip, 45),
|
||||
created_at: optTs(x.created_at),
|
||||
}));
|
||||
const NOTIFICATION_LEVELS = new Set(['info', 'success', 'warning', 'critical']);
|
||||
const notifications = (data.notifications || []).map(x => ({
|
||||
id: reqInt(x.id),
|
||||
type: reqStr(x.type, 50),
|
||||
level: (x.level && NOTIFICATION_LEVELS.has(x.level)) ? x.level : 'info',
|
||||
title: reqStr(x.title, 200),
|
||||
body: optStr(x.body, 2000),
|
||||
link: optStr(x.link, 255),
|
||||
target: optJsonText(x.target, 20000),
|
||||
admin_only: !!x.admin_only,
|
||||
branch_id: optInt(x.branch_id, 0, 2147483647),
|
||||
created_at: optTs(x.created_at),
|
||||
}));
|
||||
const notification_reads = (data.notification_reads || []).map(x => ({
|
||||
user_id: reqInt(x.user_id),
|
||||
notification_id: reqInt(x.notification_id),
|
||||
read_at: optTs(x.read_at),
|
||||
}));
|
||||
const banned_ips = (data.banned_ips || []).map(x => ({
|
||||
ip: reqIp(x.ip),
|
||||
reason: reqStr(x.reason, 100),
|
||||
banned_until: reqTs(x.banned_until),
|
||||
created_at: optTs(x.created_at),
|
||||
}));
|
||||
const PHOTO_JOB_STATUSES = new Set(['pending', 'processing', 'done', 'error', 'rejected']);
|
||||
const photo_jobs = (data.photo_jobs || []).map(x => ({
|
||||
id: reqInt(x.id),
|
||||
entry_id: reqInt(x.entry_id),
|
||||
action: (x.action && PHOTO_JOB_ACTIONS.has(x.action)) ? x.action : 'ai',
|
||||
params: optJsonText(x.params, 20000),
|
||||
before_path: optPhotoRefPath(x.before_path, 255),
|
||||
after_path: optPhotoRefPath(x.after_path, 255),
|
||||
status: (x.status && PHOTO_JOB_STATUSES.has(x.status)) ? x.status : 'pending',
|
||||
applied: !!x.applied,
|
||||
attempts: optInt(x.attempts, 0, 2147483647) ?? 0,
|
||||
error: optStr(x.error, 4000),
|
||||
created_at: optTs(x.created_at),
|
||||
finished_at: optTs(x.finished_at),
|
||||
}));
|
||||
return { groups, students, entries, project_files, settings, branches, users, user_branches, entry_photos, student_photos, group_photos, share_links, modules, photo_jobs, lesson_reports, lesson_report_versions, audit_log, notifications, notification_reads, banned_ips };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
PHOTO_JOB_ACTIONS,
|
||||
LESSON_REPORT_TEXT_MAX,
|
||||
LESSON_REPORT_TOPIC_MAX,
|
||||
SAFE_NAME,
|
||||
isSafeUploadPath,
|
||||
photoRefKey,
|
||||
sanitizeStudentProfile,
|
||||
reqInt,
|
||||
optInt,
|
||||
reqStr,
|
||||
optStr,
|
||||
optTs,
|
||||
reqTs,
|
||||
optDate,
|
||||
optUploadPath,
|
||||
normalizeRestoreData,
|
||||
BACKUP_FORMAT_VERSION,
|
||||
BACKUP_MIN_FORMAT_VERSION,
|
||||
BACKUP_TABLES,
|
||||
BACKUP_SEQUENCE_TABLES,
|
||||
restoredCounts,
|
||||
isSupportedBackupVersion,
|
||||
};
|
||||
@@ -0,0 +1,88 @@
|
||||
const {
|
||||
BACKUP_FORMAT_VERSION,
|
||||
BACKUP_TABLES,
|
||||
BACKUP_SEQUENCE_TABLES,
|
||||
isSupportedBackupVersion,
|
||||
restoredCounts,
|
||||
isSafeUploadPath,
|
||||
normalizeRestoreData,
|
||||
} = require('./backup-restore');
|
||||
|
||||
let failed = 0;
|
||||
function ok(label, cond, extra) {
|
||||
console.log(`${cond ? 'PASS' : 'FAIL'} ${label}${extra !== undefined && !cond ? ' -> ' + JSON.stringify(extra) : ''}`);
|
||||
if (!cond) failed++;
|
||||
}
|
||||
function throws(label, fn) {
|
||||
let threw = false;
|
||||
try { fn(); } catch (e) { threw = true; }
|
||||
ok(label, threw);
|
||||
}
|
||||
|
||||
ok('формат 1 (старый архив) поддерживается', isSupportedBackupVersion({ version: 1, groups: [] }));
|
||||
ok(`формат ${BACKUP_FORMAT_VERSION} поддерживается`, isSupportedBackupVersion({ version: BACKUP_FORMAT_VERSION, groups: [] }));
|
||||
ok('формат 0 отклоняется', !isSupportedBackupVersion({ version: 0, groups: [] }));
|
||||
ok('формат из будущего отклоняется', !isSupportedBackupVersion({ version: BACKUP_FORMAT_VERSION + 1, groups: [] }));
|
||||
ok('без groups отклоняется', !isSupportedBackupVersion({ version: BACKUP_FORMAT_VERSION }));
|
||||
ok('не объект отклоняется', !isSupportedBackupVersion(null));
|
||||
|
||||
ok('в бэкап входят audit_log/notifications/banned_ips',
|
||||
['audit_log', 'notifications', 'notification_reads', 'banned_ips'].every(t => BACKUP_TABLES.includes(t)),
|
||||
BACKUP_TABLES);
|
||||
ok('sessions не попадают в бэкап', !BACKUP_TABLES.includes('sessions'));
|
||||
ok('sequences сбрасываются для audit_log и notifications',
|
||||
BACKUP_SEQUENCE_TABLES.includes('audit_log') && BACKUP_SEQUENCE_TABLES.includes('notifications'));
|
||||
|
||||
const counts = restoredCounts({ groups: [1, 2], settings: { a: '1', b: '2' }, audit_log: [1] });
|
||||
ok('restoredCounts считает строки и settings', counts.groups === 2 && counts.settings === 2 && counts.audit_log === 1, counts);
|
||||
ok('restoredCounts для отсутствующей таблицы = 0', restoredCounts({ groups: [] }).notifications === 0);
|
||||
|
||||
ok('безопасный путь загрузки принимается', isSafeUploadPath('/uploads/1700000000000-abc123.jpg'));
|
||||
ok('path traversal отклоняется', !isSafeUploadPath('/uploads/../../etc/passwd'));
|
||||
ok('вложенный путь отклоняется', !isSafeUploadPath('/uploads/.originals/x.jpg'));
|
||||
ok('чужой префикс отклоняется', !isSafeUploadPath('/etc/passwd'));
|
||||
|
||||
const base = { version: BACKUP_FORMAT_VERSION, groups: [], entries: [], users: [], branches: [] };
|
||||
|
||||
const n = normalizeRestoreData({
|
||||
...base,
|
||||
groups: [{ id: 1, name: 'G', deleted_at: '2026-01-02T03:04:05.000Z', purge_at: '2026-02-03T04:05:06.000Z' }],
|
||||
});
|
||||
ok('groups.deleted_at больше не теряется', n.groups[0].deleted_at === '2026-01-02T03:04:05.000Z', n.groups[0]);
|
||||
ok('groups.purge_at больше не теряется', n.groups[0].purge_at === '2026-02-03T04:05:06.000Z', n.groups[0]);
|
||||
|
||||
const e = normalizeRestoreData({
|
||||
...base,
|
||||
entries: [{ id: 1, student_name: 'S', group_id: 1, description: 'd', deleted_at: '2026-01-02T03:04:05.000Z', purge_at: '2026-03-04T05:06:07.000Z' }],
|
||||
});
|
||||
ok('entries.purge_at больше не теряется', e.entries[0].purge_at === '2026-03-04T05:06:07.000Z', e.entries[0]);
|
||||
|
||||
const nt = normalizeRestoreData({
|
||||
...base,
|
||||
notifications: [{ id: 5, type: 'entry.new', level: 'warning', title: 'T', body: 'B', link: 'l', target: { a: 1 }, admin_only: true, branch_id: 2 }],
|
||||
notification_reads: [{ user_id: 1, notification_id: 5 }],
|
||||
audit_log: [{ id: 7, user_id: 1, action: 'backup.download', target: { size: 5 }, ip: '1.2.3.4' }],
|
||||
banned_ips: [{ ip: '203.0.113.7', reason: 'manual', banned_until: '2026-05-05T00:00:00.000Z' }],
|
||||
});
|
||||
ok('notifications нормализуются', nt.notifications[0].level === 'warning' && nt.notifications[0].title === 'T', nt.notifications[0]);
|
||||
ok('notifications.target остаётся JSON-строкой', nt.notifications[0].target === '{"a":1}', nt.notifications[0].target);
|
||||
ok('notification_reads нормализуются', nt.notification_reads[0].notification_id === 5);
|
||||
ok('audit_log нормализуется', nt.audit_log[0].action === 'backup.download' && nt.audit_log[0].target === '{"size":5}', nt.audit_log[0]);
|
||||
ok('banned_ips нормализуются', nt.banned_ips[0].ip === '203.0.113.7');
|
||||
|
||||
const badLevel = normalizeRestoreData({ ...base, notifications: [{ id: 1, type: 'x', level: 'drop-table', title: 'T' }] });
|
||||
ok('неизвестный level уведомления -> info', badLevel.notifications[0].level === 'info', badLevel.notifications[0]);
|
||||
|
||||
throws('мусорный ip в banned_ips отклоняется', () => normalizeRestoreData({ ...base, banned_ips: [{ ip: 'не ip', reason: 'r', banned_until: '2026-01-01T00:00:00.000Z' }] }));
|
||||
throws('пустой banned_until отклоняется', () => normalizeRestoreData({ ...base, banned_ips: [{ ip: '1.2.3.4', reason: 'r' }] }));
|
||||
throws('пустой action в audit_log отклоняется', () => normalizeRestoreData({ ...base, audit_log: [{ id: 1, action: ' ' }] }));
|
||||
throws('не-объект в notifications.target отклоняется', () => normalizeRestoreData({ ...base, notifications: [{ id: 1, type: 'x', title: 'T', target: [1, 2, 3] }] }));
|
||||
throws('группа без id отклоняется', () => normalizeRestoreData({ ...base, groups: [{ name: 'G' }] }));
|
||||
throws('путь вне uploads отклоняется', () => normalizeRestoreData({ ...base, students: [{ id: 1, name: 'S', photo_path: '/etc/passwd' }] }));
|
||||
throws('notifications не массив отклоняется', () => normalizeRestoreData({ ...base, notifications: { nope: 1 } }));
|
||||
|
||||
const legacy = normalizeRestoreData({ version: 1, groups: [{ id: 1, name: 'G' }] });
|
||||
ok('старый архив без новых таблиц восстанавливается', Array.isArray(legacy.audit_log) && legacy.audit_log.length === 0 && legacy.groups.length === 1, Object.keys(legacy));
|
||||
|
||||
console.log(failed ? `\n${failed} проверок провалено` : '\nBACKUP SELFTEST OK');
|
||||
process.exit(failed ? 1 : 0);
|
||||
@@ -1,26 +1,111 @@
|
||||
CREATE TABLE IF NOT EXISTS branches (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(200) NOT NULL UNIQUE,
|
||||
address TEXT,
|
||||
phone VARCHAR(50),
|
||||
created_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS banned_ips (
|
||||
ip VARCHAR(64) PRIMARY KEY,
|
||||
reason VARCHAR(100) NOT NULL,
|
||||
banned_until TIMESTAMPTZ NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS groups (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL UNIQUE,
|
||||
branch_id INT REFERENCES branches(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT now(),
|
||||
day_of_week INT,
|
||||
time_start TIME,
|
||||
time_end TIME,
|
||||
cover_path VARCHAR(255),
|
||||
deleted_at TIMESTAMPTZ,
|
||||
purge_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
ALTER TABLE groups ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
|
||||
ALTER TABLE groups ADD COLUMN IF NOT EXISTS purge_at TIMESTAMPTZ;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS modules (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(200) NOT NULL UNIQUE,
|
||||
lessons_count INT NOT NULL DEFAULT 0,
|
||||
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||
photo_path VARCHAR(255),
|
||||
created_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
ALTER TABLE modules ADD COLUMN IF NOT EXISTS is_active BOOLEAN NOT NULL DEFAULT true;
|
||||
ALTER TABLE modules ADD COLUMN IF NOT EXISTS photo_path VARCHAR(255);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS students (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(150) NOT NULL UNIQUE,
|
||||
group_id INT REFERENCES groups(id),
|
||||
created_at TIMESTAMPTZ DEFAULT now(),
|
||||
photo_path VARCHAR(255),
|
||||
profile JSONB
|
||||
);
|
||||
|
||||
ALTER TABLE students ADD COLUMN IF NOT EXISTS photo_path VARCHAR(255);
|
||||
ALTER TABLE students ADD COLUMN IF NOT EXISTS profile JSONB;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS student_photos (
|
||||
id SERIAL PRIMARY KEY,
|
||||
student_id INT NOT NULL REFERENCES students(id) ON DELETE CASCADE,
|
||||
photo_path VARCHAR(255) NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_student_photos_student_id ON student_photos(student_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS entries (
|
||||
id SERIAL PRIMARY KEY,
|
||||
student_name VARCHAR(150) NOT NULL,
|
||||
group_id INT NOT NULL REFERENCES groups(id),
|
||||
module_id INT REFERENCES modules(id) ON DELETE SET NULL,
|
||||
description TEXT NOT NULL,
|
||||
description_original TEXT,
|
||||
description_ai TEXT,
|
||||
ai_status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||||
ai_checked_at TIMESTAMPTZ,
|
||||
ai_error TEXT,
|
||||
photo_path VARCHAR(255),
|
||||
photo_original_path VARCHAR(255),
|
||||
deleted_at TIMESTAMPTZ,
|
||||
purge_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
ALTER TABLE entries ADD COLUMN IF NOT EXISTS description_original TEXT;
|
||||
ALTER TABLE entries ADD COLUMN IF NOT EXISTS description_ai TEXT;
|
||||
ALTER TABLE entries ADD COLUMN IF NOT EXISTS ai_status VARCHAR(20) NOT NULL DEFAULT 'pending';
|
||||
ALTER TABLE entries ADD COLUMN IF NOT EXISTS ai_checked_at TIMESTAMPTZ;
|
||||
ALTER TABLE entries ADD COLUMN IF NOT EXISTS ai_error TEXT;
|
||||
ALTER TABLE entries ADD COLUMN IF NOT EXISTS module_id INT REFERENCES modules(id) ON DELETE SET NULL;
|
||||
ALTER TABLE entries ADD COLUMN IF NOT EXISTS purge_at TIMESTAMPTZ;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_entries_ai_pending ON entries(id) WHERE ai_status = 'pending' AND deleted_at IS NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_entries_module_id ON entries(module_id);
|
||||
|
||||
CREATE OR REPLACE FUNCTION notify_entries_changed() RETURNS trigger AS $$
|
||||
BEGIN
|
||||
IF (TG_OP = 'INSERT') THEN
|
||||
PERFORM pg_notify('entries_changed', json_build_object('type', 'entry_created', 'id', NEW.id)::text);
|
||||
ELSIF (TG_OP = 'UPDATE' AND OLD.ai_status IS DISTINCT FROM NEW.ai_status) THEN
|
||||
PERFORM pg_notify('entries_changed', json_build_object('type', 'ai_status', 'id', NEW.id, 'status', NEW.ai_status, 'error', NEW.ai_error, 'description', NEW.description, 'description_ai', NEW.description_ai, 'description_original', NEW.description_original)::text);
|
||||
END IF;
|
||||
RETURN NULL;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_entries_notify ON entries;
|
||||
CREATE TRIGGER trg_entries_notify AFTER INSERT OR UPDATE OF ai_status ON entries
|
||||
FOR EACH ROW EXECUTE FUNCTION notify_entries_changed();
|
||||
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT
|
||||
@@ -28,6 +113,28 @@ CREATE TABLE IF NOT EXISTS settings (
|
||||
|
||||
INSERT INTO settings (key, value) VALUES ('spam_interval_min', '30')
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
INSERT INTO settings (key, value) VALUES ('ai_prompt', 'Ты — редактор текстов. Исправь ТОЛЬКО грамматические, орфографические и пунктуационные ошибки в тексте. Приведи к правильному регистру буквы. НЕ меняй слова, структуру предложений, стиль или смысл текста. Верни ТОЛЬКО исправленный текст без пояснений.')
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
INSERT INTO settings (key, value) VALUES ('ai_autocheck_enabled', 'true')
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
INSERT INTO settings (key, value) VALUES ('share_show_student_message', 'false')
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
INSERT INTO settings (key, value) VALUES ('share_show_entry_date', 'false')
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
INSERT INTO settings (key, value) VALUES ('share_show_student_names', 'true')
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
INSERT INTO settings (key, value) VALUES ('share_show_group_photos', 'true')
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
INSERT INTO settings (key, value) VALUES ('cookie_notice_text', 'Этот сайт использует cookie-файлы для корректной работы. Продолжая просмотр, вы соглашаетесь с их использованием.')
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
INSERT INTO settings (key, value) VALUES ('camera_enabled', 'true')
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
INSERT INTO settings (key, value) VALUES ('trash_purge_days', '30')
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
INSERT INTO settings (key, value) VALUES ('timezone', 'Europe/Moscow')
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
INSERT INTO settings (key, value) VALUES ('time_format', '24h')
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS share_links (
|
||||
id SERIAL PRIMARY KEY,
|
||||
@@ -37,9 +144,19 @@ CREATE TABLE IF NOT EXISTS share_links (
|
||||
student_name VARCHAR(150),
|
||||
date_from DATE,
|
||||
date_to DATE,
|
||||
show_student_names BOOLEAN,
|
||||
expires_at TIMESTAMPTZ,
|
||||
access_password_hash VARCHAR(255),
|
||||
message TEXT,
|
||||
link_url VARCHAR(500),
|
||||
show_student_message BOOLEAN,
|
||||
show_entry_date BOOLEAN,
|
||||
show_group_photos BOOLEAN,
|
||||
created_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_share_links_expires_at ON share_links(expires_at);
|
||||
|
||||
INSERT INTO groups (name) VALUES
|
||||
('Beginner'),
|
||||
('Intermediate'),
|
||||
@@ -52,6 +169,7 @@ CREATE TABLE IF NOT EXISTS group_photos (
|
||||
photo_path VARCHAR(255) NOT NULL,
|
||||
caption TEXT,
|
||||
taken_at DATE,
|
||||
sort_order INT DEFAULT 0,
|
||||
created_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
@@ -66,3 +184,237 @@ CREATE TABLE IF NOT EXISTS project_files (
|
||||
created_at TIMESTAMPTZ DEFAULT now(),
|
||||
detached_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS entry_photos (
|
||||
id SERIAL PRIMARY KEY,
|
||||
entry_id INT NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
|
||||
photo_path VARCHAR(255) NOT NULL,
|
||||
caption TEXT,
|
||||
sort_order INT DEFAULT 0,
|
||||
created_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_entry_photos_entry_id ON entry_photos(entry_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id SERIAL PRIMARY KEY,
|
||||
username VARCHAR(100) NOT NULL UNIQUE,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
name VARCHAR(150),
|
||||
role VARCHAR(20) NOT NULL DEFAULT 'tutor' CHECK (role IN ('admin','tutor')),
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
created_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
ALTER TABLE groups ADD COLUMN IF NOT EXISTS tutor_id INT REFERENCES users(id) ON DELETE SET NULL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_branches (
|
||||
user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
branch_id INT NOT NULL REFERENCES branches(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (user_id, branch_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
token VARCHAR(64) NOT NULL UNIQUE,
|
||||
created_at TIMESTAMPTZ DEFAULT now(),
|
||||
expires_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS api_keys (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name VARCHAR(150) NOT NULL,
|
||||
prefix VARCHAR(16) NOT NULL,
|
||||
key_hash VARCHAR(64) NOT NULL UNIQUE,
|
||||
scopes TEXT[] NOT NULL DEFAULT ARRAY['read']::TEXT[],
|
||||
branch_ids INT[] NOT NULL DEFAULT ARRAY[]::INT[],
|
||||
rate_limit_per_min INT,
|
||||
created_at TIMESTAMPTZ DEFAULT now(),
|
||||
last_used_at TIMESTAMPTZ,
|
||||
last_used_ip VARCHAR(45),
|
||||
expires_at TIMESTAMPTZ,
|
||||
revoked_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_api_keys_key_hash ON api_keys(key_hash);
|
||||
CREATE INDEX IF NOT EXISTS idx_api_keys_user_id ON api_keys(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_api_keys_revoked_at ON api_keys(revoked_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS lesson_reports (
|
||||
id SERIAL PRIMARY KEY,
|
||||
group_id INT NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
||||
lesson_date DATE NOT NULL,
|
||||
lesson_time TIME,
|
||||
topic TEXT,
|
||||
text TEXT NOT NULL,
|
||||
text_original TEXT,
|
||||
text_ai TEXT,
|
||||
ai_status VARCHAR(20) NOT NULL DEFAULT 'none',
|
||||
ai_checked_at TIMESTAMPTZ,
|
||||
ai_error TEXT,
|
||||
author_id INT REFERENCES users(id) ON DELETE SET NULL,
|
||||
branch_id INT REFERENCES branches(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_lesson_reports_group_date ON lesson_reports(group_id, lesson_date);
|
||||
CREATE INDEX IF NOT EXISTS idx_lesson_reports_date ON lesson_reports(lesson_date DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS lesson_report_versions (
|
||||
id SERIAL PRIMARY KEY,
|
||||
lesson_report_id INT NOT NULL REFERENCES lesson_reports(id) ON DELETE CASCADE,
|
||||
text TEXT NOT NULL,
|
||||
source VARCHAR(20) NOT NULL DEFAULT 'manual',
|
||||
author_id INT REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_lesson_report_versions_report ON lesson_report_versions(lesson_report_id, id DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS photo_jobs (
|
||||
id SERIAL PRIMARY KEY,
|
||||
entry_id INT NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
|
||||
action VARCHAR(20) NOT NULL DEFAULT 'ai',
|
||||
params JSONB,
|
||||
before_path VARCHAR(255),
|
||||
after_path VARCHAR(255),
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||||
applied BOOLEAN NOT NULL DEFAULT false,
|
||||
attempts INT NOT NULL DEFAULT 0,
|
||||
error TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT now(),
|
||||
finished_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_photo_jobs_pending ON photo_jobs(id) WHERE status = 'pending';
|
||||
CREATE INDEX IF NOT EXISTS idx_photo_jobs_entry_id ON photo_jobs(entry_id);
|
||||
|
||||
INSERT INTO settings (key, value) VALUES ('photo_worker_enabled', 'true')
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
|
||||
INSERT INTO settings (key, value) VALUES ('photo_ai_face_mode', 'off')
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
|
||||
INSERT INTO settings (key, value) VALUES ('photo_ai_face_model', 'gfpgan')
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
|
||||
INSERT INTO settings (key, value) VALUES ('photo_ai_device_pref', 'auto')
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS audit_log (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INT REFERENCES users(id) ON DELETE SET NULL,
|
||||
action VARCHAR(100) NOT NULL,
|
||||
target JSONB,
|
||||
ip VARCHAR(45),
|
||||
created_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_log_created_at ON audit_log(created_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS notifications (
|
||||
id SERIAL PRIMARY KEY,
|
||||
type VARCHAR(50) NOT NULL,
|
||||
level VARCHAR(20) NOT NULL DEFAULT 'info',
|
||||
title VARCHAR(200) NOT NULL,
|
||||
body TEXT,
|
||||
link VARCHAR(255),
|
||||
target JSONB,
|
||||
admin_only BOOLEAN NOT NULL DEFAULT false,
|
||||
branch_id INT REFERENCES branches(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_notifications_created_at ON notifications(created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_notifications_branch_id ON notifications(branch_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS notification_reads (
|
||||
user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
notification_id INT NOT NULL REFERENCES notifications(id) ON DELETE CASCADE,
|
||||
read_at TIMESTAMPTZ DEFAULT now(),
|
||||
PRIMARY KEY (user_id, notification_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_notification_reads_user ON notification_reads(user_id);
|
||||
|
||||
INSERT INTO settings (key, value) VALUES ('notify_enabled', 'true')
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
INSERT INTO settings (key, value) VALUES ('notify_retention_days', '30')
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
INSERT INTO settings (key, value) VALUES ('notify_entry_new', 'true')
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
INSERT INTO settings (key, value) VALUES ('notify_entry_ai_corrected', 'false')
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
INSERT INTO settings (key, value) VALUES ('notify_entry_ai_error', 'true')
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
INSERT INTO settings (key, value) VALUES ('notify_photo_job_done', 'true')
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
INSERT INTO settings (key, value) VALUES ('notify_photo_job_error', 'true')
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
INSERT INTO settings (key, value) VALUES ('notify_ip_ban', 'true')
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
INSERT INTO settings (key, value) VALUES ('notify_backup_restore', 'true')
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
INSERT INTO settings (key, value) VALUES ('notify_backup_create', 'false')
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
INSERT INTO settings (key, value) VALUES ('notify_system_test', 'true')
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
|
||||
INSERT INTO settings (key, value) VALUES ('lesson_ai_enabled', 'true')
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
INSERT INTO settings (key, value) VALUES ('lesson_ai_prompt', 'Ты — редактор сообщений тьютора детской IT-школы.
|
||||
|
||||
Переработай исходный текст занятия так, чтобы он звучал естественно и грамотно, как будто его написал живой тьютор родителю, а не нейросеть.
|
||||
|
||||
ГЛАВНОЕ:
|
||||
- Не добавляй информацию, которой нет в исходном тексте. Сохрани все факты и смысл.
|
||||
- Исправь ошибки, повторы и неудачные формулировки. Убери канцелярит, шаблонные фразы и «ИИ-язык».
|
||||
- Не используй чрезмерную похвалу и не превращай обычное занятие в достижение мирового масштала.
|
||||
|
||||
СТИЛЬ И СТРУКТУРА:
|
||||
- Один цельный абзац, 2–4 предложения, от третьего лица.
|
||||
- Первое предложение всегда начинай со слов «На занятии ребята …». Чем занимались в начале занятия — начало модуля, продолжение или завершение — определяй по правилу НАЧАЛО ФРАЗЫ.
|
||||
- Если в исходном тексте описана практическая часть, начни её со слов «В конце занятия …» или «Затем …». Нет практики в исходнике — не придумывай её.
|
||||
- Вместо «они» пиши «каждый». Обращение на «вы» не используй.
|
||||
- Коротко скажи, с чем познакомились или что изучали; затем — что конкретно делали; в конце — что сделал самостоятельно.
|
||||
- Сократи перечисления, объединяй их через «и», «а также», не повторяй одно и то же разными словами.
|
||||
- Убери разговорные и оценочные обороты: «было весело», «очень», «классно».
|
||||
|
||||
НАЧАЛО ФРАЗЫ (определяется номером темы):
|
||||
Система присылает готовую подсказку строкой «Позиция темы: …». Если такой строки нет, смотри номер вида N/M в конце строки «Тема занятия: …», где N — номер занятия в модуле, M — сколько занятий в модуле всего. Пользуйся только одной из этих подсказок и всегда проверяй, что N не больше M.
|
||||
- «последнее занятие модуля» (N = M, например 2/2 или 7/7): занятие завершает модуль. Первое предложение обязательно начни с «завершили» или «закончили»: «На занятии ребята завершили изучение …», «На занятии ребята закончили знакомство с …».
|
||||
- «промежуточное занятие модуля» (1 < N < M, например 2/4, 3/5, 6/7): изучение продолжается. Первое предложение обязательно начни с «продолжили», «продолжали», «закрепили» или «углубили»: «На занятии ребята продолжили изучение …», «На занятии ребята закрепили …».
|
||||
- «первое занятие модуля» (N = 1 и N < M, например 1/4 или 1/7): модуль начинается. Первое предложение обязательно начни с «начали» или «приступили»: «На занятии ребята начали знакомство с …», «На занятии ребята начали изучение …».
|
||||
- Если подсказки нет или номер нечитаем — характер занятия не выбирай и не додумывай. Начни нейтрально, прямо с сути: «На занятии ребята разобрались …», «На занятии ребята отработали …», «На занятии ребята попробовали …».
|
||||
- Это требование сильнее всего остального: даже если начало исходного текста уже хорошо звучит, перепиши его по правилу выше. Слово «модуль», цифры номера и любые оценки в текст отчёта не переноси: номер темы — внутренний признак для выбора формулировки.
|
||||
|
||||
ФОРМАТ:
|
||||
- Без заголовков, списков, markdown, подписей и пояснений.
|
||||
- Не начинай со слов «Сегодня», «Вчера», «Дата», «Группа», «Время», «Позиция» и вообще не упоминай группу, дату, время и номер занятия.
|
||||
- Не начинай с «На данном занятии» или «В рамках занятия».
|
||||
- Не оборачивай ответ в кавычки.
|
||||
- Не добавляй лишних предложений: если исходный текст уже написан нормально, не переписывай его ради переписывания.
|
||||
|
||||
ПРИМЕР ПРЕОБРАЗОВАНИЯ (бери отсюда только формулировки, тему и факты примера в свой текст не переноси):
|
||||
Исходный текст:
|
||||
«Ребята познакомились с программой Scratch Jr, научились выбирать фон, добавлять, изменять и создавать своих персонажей. В завершении занятия они выполнили практическое индивидуальное задание по созданию собственной анимации и небольшой программы».
|
||||
Тема занятия: «Анимация 1/3»
|
||||
Позиция темы: первое занятие модуля (1 из 3)
|
||||
Хороший результат:
|
||||
«На занятии ребята начали знакомство со Scratch Jr: научились выбирать фон, добавлять и изменять персонажей, а также создавать своих героев. В конце занятия каждый самостоятельно выполнил небольшое практическое задание — придумал свою анимацию и собрал простую программу».
|
||||
Тот же исходный текст, но «Позиция темы: промежуточное занятие модуля (2 из 3)» — меняется только начало:
|
||||
«На занятии ребята продолжили знакомство со Scratch Jr: научились выбирать фон, добавлять и изменять персонажей…».
|
||||
Тот же исходный текст, но «Позиция темы: последнее занятие модуля (3 из 3)»:
|
||||
«На занятии ребята завершили знакомство со Scratch Jr: научились выбирать фон, добавлять и изменять персонажей…».
|
||||
|
||||
ГЛАВНОЕ ПРАВИЛО:
|
||||
Отрабатывай ровно по этому исходному тексту. Ничего из примера выше в свой текст не переноси: тема, программа, персонажи и детали из примера не твои.')
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
INSERT INTO settings (key, value) VALUES ('notify_lesson_ai_formatted', 'false')
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
|
||||
@@ -4,7 +4,16 @@ CREATE TABLE IF NOT EXISTS students (
|
||||
created_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS banned_ips (
|
||||
ip VARCHAR(64) PRIMARY KEY,
|
||||
reason VARCHAR(100) NOT NULL,
|
||||
banned_until TIMESTAMPTZ NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
ALTER TABLE students ADD COLUMN IF NOT EXISTS group_id INT REFERENCES groups(id);
|
||||
ALTER TABLE students ADD COLUMN IF NOT EXISTS photo_path VARCHAR(255);
|
||||
ALTER TABLE students ADD COLUMN IF NOT EXISTS profile JSONB;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
@@ -13,6 +22,14 @@ CREATE TABLE IF NOT EXISTS settings (
|
||||
|
||||
INSERT INTO settings (key, value) VALUES ('spam_interval_min', '30')
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
INSERT INTO settings (key, value) VALUES ('ai_prompt', 'Ты — редактор текстов. Исправь ТОЛЬКО грамматические, орфографические и пунктуационные ошибки в тексте. Приведи к правильному регистру буквы. НЕ меняй слова, структуру предложений, стиль или смысл текста. Верни ТОЛЬКО исправленный текст без пояснений.')
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
UPDATE settings
|
||||
SET value = 'Ты — редактор текстов. Исправь ТОЛЬКО грамматические, орфографические и пунктуационные ошибки в тексте. Приведи к правильному регистру буквы. НЕ меняй слова, структуру предложений, стиль или смысл текста. Верни ТОЛЬКО исправленный текст без пояснений.'
|
||||
WHERE key = 'ai_prompt' AND value IN (
|
||||
'Ты — редактор текстов для педагогического журнала. Исправь грамматические, орфографические и пунктуационные ошибки. Сохрани смысл и стиль. Верни ТОЛЬКО исправленный текст, без пояснений.',
|
||||
'Ты — редактор текстов. Исправь ошибки. Верни ТОЛЬКО исправленный текст.'
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS share_links (
|
||||
id SERIAL PRIMARY KEY,
|
||||
@@ -25,6 +42,21 @@ CREATE TABLE IF NOT EXISTS share_links (
|
||||
created_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
ALTER TABLE share_links ADD COLUMN IF NOT EXISTS message TEXT;
|
||||
ALTER TABLE share_links ADD COLUMN IF NOT EXISTS link_url VARCHAR(500);
|
||||
ALTER TABLE share_links ADD COLUMN IF NOT EXISTS show_student_message BOOLEAN;
|
||||
ALTER TABLE share_links ADD COLUMN IF NOT EXISTS show_entry_date BOOLEAN;
|
||||
ALTER TABLE share_links ADD COLUMN IF NOT EXISTS show_group_photos BOOLEAN;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'share_links' AND column_name = 'anonymize_names') THEN
|
||||
ALTER TABLE share_links ADD COLUMN IF NOT EXISTS show_student_names BOOLEAN;
|
||||
UPDATE share_links SET show_student_names = (NOT anonymize_names) WHERE anonymize_names IS NOT NULL;
|
||||
ALTER TABLE share_links DROP COLUMN anonymize_names;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
INSERT INTO students (name)
|
||||
@@ -48,7 +80,123 @@ CREATE UNIQUE INDEX IF NOT EXISTS project_files_token_key ON project_files(token
|
||||
ALTER TABLE project_files ALTER COLUMN entry_id DROP NOT NULL;
|
||||
ALTER TABLE project_files ADD COLUMN IF NOT EXISTS detached_at TIMESTAMPTZ;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS entry_photos (
|
||||
id SERIAL PRIMARY KEY,
|
||||
entry_id INT NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
|
||||
photo_path VARCHAR(255) NOT NULL,
|
||||
caption TEXT,
|
||||
sort_order INT DEFAULT 0,
|
||||
created_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_entry_photos_entry_id ON entry_photos(entry_id);
|
||||
|
||||
ALTER TABLE entries ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
|
||||
ALTER TABLE entries ADD COLUMN IF NOT EXISTS purge_at TIMESTAMPTZ;
|
||||
ALTER TABLE entries ADD COLUMN IF NOT EXISTS description_original TEXT;
|
||||
ALTER TABLE entries ADD COLUMN IF NOT EXISTS description_ai TEXT;
|
||||
ALTER TABLE entries ADD COLUMN IF NOT EXISTS ai_status VARCHAR(20) NOT NULL DEFAULT 'pending';
|
||||
ALTER TABLE entries ADD COLUMN IF NOT EXISTS ai_checked_at TIMESTAMPTZ;
|
||||
ALTER TABLE entries ADD COLUMN IF NOT EXISTS ai_error TEXT;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_entries_ai_pending ON entries(id) WHERE ai_status = 'pending' AND deleted_at IS NULL;
|
||||
|
||||
INSERT INTO settings (key, value) VALUES ('ai_autocheck_enabled', 'true')
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
|
||||
INSERT INTO settings (key, value) VALUES ('share_show_student_message', 'false')
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
INSERT INTO settings (key, value) VALUES ('share_show_entry_date', 'false')
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
INSERT INTO settings (key, value) VALUES ('share_show_student_names', 'true')
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
DELETE FROM settings WHERE key = 'share_anonymize_names';
|
||||
INSERT INTO settings (key, value) VALUES ('share_show_group_photos', 'true')
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
INSERT INTO settings (key, value) VALUES ('cookie_notice_text', 'Этот сайт использует cookie-файлы для корректной работы. Продолжая просмотр, вы соглашаетесь с их использованием.')
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
|
||||
INSERT INTO settings (key, value) VALUES ('camera_enabled', 'true')
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
|
||||
INSERT INTO settings (key, value) VALUES ('trash_purge_days', '30')
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM settings WHERE key = 'ai_autocheck_migrated') THEN
|
||||
UPDATE entries SET description_original = description WHERE description_original IS NULL;
|
||||
UPDATE entries SET ai_status = 'skipped' WHERE ai_status = 'pending';
|
||||
INSERT INTO settings (key, value) VALUES ('ai_autocheck_migrated', '1');
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS branches (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(200) NOT NULL UNIQUE,
|
||||
address TEXT,
|
||||
phone VARCHAR(50),
|
||||
created_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
ALTER TABLE groups ADD COLUMN IF NOT EXISTS branch_id INT REFERENCES branches(id) ON DELETE SET NULL;
|
||||
ALTER TABLE groups ADD COLUMN IF NOT EXISTS day_of_week INT;
|
||||
ALTER TABLE groups ADD COLUMN IF NOT EXISTS time_start TIME;
|
||||
ALTER TABLE groups ADD COLUMN IF NOT EXISTS time_end TIME;
|
||||
ALTER TABLE groups ADD COLUMN IF NOT EXISTS cover_path VARCHAR(255);
|
||||
ALTER TABLE groups ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
|
||||
ALTER TABLE groups ADD COLUMN IF NOT EXISTS purge_at TIMESTAMPTZ;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id SERIAL PRIMARY KEY,
|
||||
username VARCHAR(100) NOT NULL UNIQUE,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
name VARCHAR(150),
|
||||
role VARCHAR(20) NOT NULL DEFAULT 'tutor' CHECK (role IN ('admin','tutor')),
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
created_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_branches (
|
||||
user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
branch_id INT NOT NULL REFERENCES branches(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (user_id, branch_id)
|
||||
);
|
||||
|
||||
ALTER TABLE groups ADD COLUMN IF NOT EXISTS tutor_id INT REFERENCES users(id) ON DELETE SET NULL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
token VARCHAR(64) NOT NULL UNIQUE,
|
||||
created_at TIMESTAMPTZ DEFAULT now(),
|
||||
expires_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS api_keys (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name VARCHAR(150) NOT NULL,
|
||||
prefix VARCHAR(16) NOT NULL,
|
||||
key_hash VARCHAR(64) NOT NULL UNIQUE,
|
||||
scopes TEXT[] NOT NULL DEFAULT ARRAY['read']::TEXT[],
|
||||
branch_ids INT[] NOT NULL DEFAULT ARRAY[]::INT[],
|
||||
rate_limit_per_min INT,
|
||||
created_at TIMESTAMPTZ DEFAULT now(),
|
||||
last_used_at TIMESTAMPTZ,
|
||||
last_used_ip VARCHAR(45),
|
||||
expires_at TIMESTAMPTZ,
|
||||
revoked_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_api_keys_key_hash ON api_keys(key_hash);
|
||||
CREATE INDEX IF NOT EXISTS idx_api_keys_user_id ON api_keys(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_api_keys_revoked_at ON api_keys(revoked_at);
|
||||
|
||||
ALTER TABLE audit_log ADD COLUMN IF NOT EXISTS user_id INT REFERENCES users(id) ON DELETE SET NULL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS group_photos (
|
||||
id SERIAL PRIMARY KEY,
|
||||
@@ -59,4 +207,309 @@ CREATE TABLE IF NOT EXISTS group_photos (
|
||||
created_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_group_photos_group_id ON group_photos(group_id);
|
||||
ALTER TABLE group_photos ADD COLUMN IF NOT EXISTS sort_order INT DEFAULT 0;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_group_photos_group_id ON group_photos(group_id);
|
||||
|
||||
UPDATE entries e SET photo_path = p.photo_path
|
||||
FROM (
|
||||
SELECT DISTINCT ON (entry_id) entry_id, photo_path
|
||||
FROM entry_photos ORDER BY entry_id, sort_order, id
|
||||
) p
|
||||
WHERE e.photo_path IS NULL AND p.entry_id = e.id;
|
||||
|
||||
CREATE OR REPLACE FUNCTION notify_entries_changed() RETURNS trigger AS $$
|
||||
BEGIN
|
||||
IF (TG_OP = 'INSERT') THEN
|
||||
PERFORM pg_notify('entries_changed', json_build_object('type', 'entry_created', 'id', NEW.id)::text);
|
||||
ELSIF (TG_OP = 'UPDATE' AND OLD.ai_status IS DISTINCT FROM NEW.ai_status) THEN
|
||||
PERFORM pg_notify('entries_changed', json_build_object('type', 'ai_status', 'id', NEW.id, 'status', NEW.ai_status, 'error', NEW.ai_error, 'description', NEW.description, 'description_ai', NEW.description_ai, 'description_original', NEW.description_original)::text);
|
||||
END IF;
|
||||
RETURN NULL;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_entries_notify ON entries;
|
||||
CREATE TRIGGER trg_entries_notify AFTER INSERT OR UPDATE OF ai_status ON entries
|
||||
FOR EACH ROW EXECUTE FUNCTION notify_entries_changed();
|
||||
|
||||
CREATE TABLE IF NOT EXISTS photo_jobs (
|
||||
id SERIAL PRIMARY KEY,
|
||||
entry_id INT NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
|
||||
action VARCHAR(20) NOT NULL DEFAULT 'ai',
|
||||
params JSONB,
|
||||
before_path VARCHAR(255),
|
||||
after_path VARCHAR(255),
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||||
applied BOOLEAN NOT NULL DEFAULT false,
|
||||
attempts INT NOT NULL DEFAULT 0,
|
||||
error TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT now(),
|
||||
finished_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_photo_jobs_pending ON photo_jobs(id) WHERE status = 'pending';
|
||||
CREATE INDEX IF NOT EXISTS idx_photo_jobs_entry_id ON photo_jobs(entry_id);
|
||||
|
||||
INSERT INTO settings (key, value) VALUES ('photo_worker_enabled', 'true')
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
|
||||
INSERT INTO settings (key, value) VALUES ('photo_ai_face_mode', 'off')
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
|
||||
INSERT INTO settings (key, value) VALUES ('photo_ai_face_model', 'gfpgan')
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
|
||||
INSERT INTO settings (key, value) VALUES ('photo_ai_device_pref', 'auto')
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
|
||||
ALTER TABLE entries ADD COLUMN IF NOT EXISTS photo_original_path VARCHAR(255);
|
||||
ALTER TABLE photo_jobs ADD COLUMN IF NOT EXISTS applied BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS modules (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(200) NOT NULL UNIQUE,
|
||||
lessons_count INT NOT NULL DEFAULT 0,
|
||||
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||
created_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
ALTER TABLE modules ADD COLUMN IF NOT EXISTS is_active BOOLEAN NOT NULL DEFAULT true;
|
||||
ALTER TABLE modules ADD COLUMN IF NOT EXISTS photo_path VARCHAR(255);
|
||||
ALTER TABLE entries ADD COLUMN IF NOT EXISTS module_id INT REFERENCES modules(id) ON DELETE SET NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_entries_module_id ON entries(module_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS student_photos (
|
||||
id SERIAL PRIMARY KEY,
|
||||
student_id INT NOT NULL REFERENCES students(id) ON DELETE CASCADE,
|
||||
photo_path VARCHAR(255) NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_student_photos_student_id ON student_photos(student_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS notifications (
|
||||
id SERIAL PRIMARY KEY,
|
||||
type VARCHAR(50) NOT NULL,
|
||||
level VARCHAR(20) NOT NULL DEFAULT 'info',
|
||||
title VARCHAR(200) NOT NULL,
|
||||
body TEXT,
|
||||
link VARCHAR(255),
|
||||
target JSONB,
|
||||
admin_only BOOLEAN NOT NULL DEFAULT false,
|
||||
branch_id INT REFERENCES branches(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_notifications_created_at ON notifications(created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_notifications_branch_id ON notifications(branch_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS notification_reads (
|
||||
user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
notification_id INT NOT NULL REFERENCES notifications(id) ON DELETE CASCADE,
|
||||
read_at TIMESTAMPTZ DEFAULT now(),
|
||||
PRIMARY KEY (user_id, notification_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_notification_reads_user ON notification_reads(user_id);
|
||||
|
||||
INSERT INTO settings (key, value) VALUES ('notify_enabled', 'true') ON CONFLICT (key) DO NOTHING;
|
||||
INSERT INTO settings (key, value) VALUES ('notify_retention_days', '30') ON CONFLICT (key) DO NOTHING;
|
||||
INSERT INTO settings (key, value) VALUES ('notify_entry_new', 'true') ON CONFLICT (key) DO NOTHING;
|
||||
INSERT INTO settings (key, value) VALUES ('notify_entry_ai_corrected', 'false') ON CONFLICT (key) DO NOTHING;
|
||||
INSERT INTO settings (key, value) VALUES ('notify_entry_ai_error', 'true') ON CONFLICT (key) DO NOTHING;
|
||||
INSERT INTO settings (key, value) VALUES ('notify_photo_job_done', 'true') ON CONFLICT (key) DO NOTHING;
|
||||
INSERT INTO settings (key, value) VALUES ('notify_photo_job_error', 'true') ON CONFLICT (key) DO NOTHING;
|
||||
INSERT INTO settings (key, value) VALUES ('notify_ip_ban', 'true') ON CONFLICT (key) DO NOTHING;
|
||||
INSERT INTO settings (key, value) VALUES ('notify_backup_restore', 'true') ON CONFLICT (key) DO NOTHING;
|
||||
INSERT INTO settings (key, value) VALUES ('notify_backup_create', 'false') ON CONFLICT (key) DO NOTHING;
|
||||
INSERT INTO settings (key, value) VALUES ('notify_system_test', 'true') ON CONFLICT (key) DO NOTHING;
|
||||
INSERT INTO settings (key, value) VALUES ('notify_lesson_report', 'true') ON CONFLICT (key) DO NOTHING;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS lesson_reports (
|
||||
id SERIAL PRIMARY KEY,
|
||||
group_id INT NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
||||
lesson_date DATE NOT NULL,
|
||||
lesson_time TIME,
|
||||
text TEXT NOT NULL,
|
||||
author_id INT REFERENCES users(id) ON DELETE SET NULL,
|
||||
branch_id INT REFERENCES branches(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_lesson_reports_group_date ON lesson_reports(group_id, lesson_date);
|
||||
CREATE INDEX IF NOT EXISTS idx_lesson_reports_date ON lesson_reports(lesson_date DESC);
|
||||
|
||||
ALTER TABLE lesson_reports ADD COLUMN IF NOT EXISTS text_original TEXT;
|
||||
ALTER TABLE lesson_reports ADD COLUMN IF NOT EXISTS text_ai TEXT;
|
||||
ALTER TABLE lesson_reports ADD COLUMN IF NOT EXISTS ai_status VARCHAR(20) NOT NULL DEFAULT 'none';
|
||||
ALTER TABLE lesson_reports ADD COLUMN IF NOT EXISTS ai_checked_at TIMESTAMPTZ;
|
||||
ALTER TABLE lesson_reports ADD COLUMN IF NOT EXISTS ai_error TEXT;
|
||||
ALTER TABLE lesson_reports ADD COLUMN IF NOT EXISTS topic TEXT;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS lesson_report_versions (
|
||||
id SERIAL PRIMARY KEY,
|
||||
lesson_report_id INT NOT NULL REFERENCES lesson_reports(id) ON DELETE CASCADE,
|
||||
text TEXT NOT NULL,
|
||||
source VARCHAR(20) NOT NULL DEFAULT 'manual',
|
||||
author_id INT REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_lesson_report_versions_report ON lesson_report_versions(lesson_report_id, id DESC);
|
||||
|
||||
INSERT INTO settings (key, value) VALUES ('lesson_ai_enabled', 'true') ON CONFLICT (key) DO NOTHING;
|
||||
INSERT INTO settings (key, value) VALUES ('lesson_ai_prompt', 'Ты — редактор сообщений тьютора детской IT-школы.
|
||||
|
||||
Переработай исходный текст занятия так, чтобы он звучал естественно и грамотно, как будто его написал живой тьютор родителю, а не нейросеть.
|
||||
|
||||
ГЛАВНОЕ:
|
||||
- Не добавляй информацию, которой нет в исходном тексте. Сохрани все факты и смысл.
|
||||
- Исправь ошибки, повторы и неудачные формулировки. Убери канцелярит, шаблонные фразы и «ИИ-язык».
|
||||
- Не используй чрезмерную похвалу и не превращай обычное занятие в достижение мирового масштала.
|
||||
|
||||
СТИЛЬ И СТРУКТУРА:
|
||||
- Один цельный абзац, 2–4 предложения, от третьего лица.
|
||||
- Первое предложение всегда начинай со слов «На занятии ребята …». Чем занимались в начале занятия — начало модуля, продолжение или завершение — определяй по правилу НАЧАЛО ФРАЗЫ.
|
||||
- Если в исходном тексте описана практическая часть, начни её со слов «В конце занятия …» или «Затем …». Нет практики в исходнике — не придумывай её.
|
||||
- Вместо «они» пиши «каждый». Обращение на «вы» не используй.
|
||||
- Коротко скажи, с чем познакомились или что изучали; затем — что конкретно делали; в конце — что сделал самостоятельно.
|
||||
- Сократи перечисления, объединяй их через «и», «а также», не повторяй одно и то же разными словами.
|
||||
- Убери разговорные и оценочные обороты: «было весело», «очень», «классно».
|
||||
|
||||
НАЧАЛО ФРАЗЫ (определяется номером темы):
|
||||
Система присылает готовую подсказку строкой «Позиция темы: …». Если такой строки нет, смотри номер вида N/M в конце строки «Тема занятия: …», где N — номер занятия в модуле, M — сколько занятий в модуле всего. Пользуйся только одной из этих подсказок и всегда проверяй, что N не больше M.
|
||||
- «последнее занятие модуля» (N = M, например 2/2 или 7/7): занятие завершает модуль. Первое предложение обязательно начни с «завершили» или «закончили»: «На занятии ребята завершили изучение …», «На занятии ребята закончили знакомство с …».
|
||||
- «промежуточное занятие модуля» (1 < N < M, например 2/4, 3/5, 6/7): изучение продолжается. Первое предложение обязательно начни с «продолжили», «продолжали», «закрепили» или «углубили»: «На занятии ребята продолжили изучение …», «На занятии ребята закрепили …».
|
||||
- «первое занятие модуля» (N = 1 и N < M, например 1/4 или 1/7): модуль начинается. Первое предложение обязательно начни с «начали» или «приступили»: «На занятии ребята начали знакомство с …», «На занятии ребята начали изучение …».
|
||||
- Если подсказки нет или номер нечитаем — характер занятия не выбирай и не додумывай. Начни нейтрально, прямо с сути: «На занятии ребята разобрались …», «На занятии ребята отработали …», «На занятии ребята попробовали …».
|
||||
- Это требование сильнее всего остального: даже если начало исходного текста уже хорошо звучит, перепиши его по правилу выше. Слово «модуль», цифры номера и любые оценки в текст отчёта не переноси: номер темы — внутренний признак для выбора формулировки.
|
||||
|
||||
ФОРМАТ:
|
||||
- Без заголовков, списков, markdown, подписей и пояснений.
|
||||
- Не начинай со слов «Сегодня», «Вчера», «Дата», «Группа», «Время», «Позиция» и вообще не упоминай группу, дату, время и номер занятия.
|
||||
- Не начинай с «На данном занятии» или «В рамках занятия».
|
||||
- Не оборачивай ответ в кавычки.
|
||||
- Не добавляй лишних предложений: если исходный текст уже написан нормально, не переписывай его ради переписывания.
|
||||
|
||||
ПРИМЕР ПРЕОБРАЗОВАНИЯ (бери отсюда только формулировки, тему и факты примера в свой текст не переноси):
|
||||
Исходный текст:
|
||||
«Ребята познакомились с программой Scratch Jr, научились выбирать фон, добавлять, изменять и создавать своих персонажей. В завершении занятия они выполнили практическое индивидуальное задание по созданию собственной анимации и небольшой программы».
|
||||
Тема занятия: «Анимация 1/3»
|
||||
Позиция темы: первое занятие модуля (1 из 3)
|
||||
Хороший результат:
|
||||
«На занятии ребята начали знакомство со Scratch Jr: научились выбирать фон, добавлять и изменять персонажей, а также создавать своих героев. В конце занятия каждый самостоятельно выполнил небольшое практическое задание — придумал свою анимацию и собрал простую программу».
|
||||
Тот же исходный текст, но «Позиция темы: промежуточное занятие модуля (2 из 3)» — меняется только начало:
|
||||
«На занятии ребята продолжили знакомство со Scratch Jr: научились выбирать фон, добавлять и изменять персонажей…».
|
||||
Тот же исходный текст, но «Позиция темы: последнее занятие модуля (3 из 3)»:
|
||||
«На занятии ребята завершили знакомство со Scratch Jr: научились выбирать фон, добавлять и изменять персонажей…».
|
||||
|
||||
ГЛАВНОЕ ПРАВИЛО:
|
||||
Отрабатывай ровно по этому исходному тексту. Ничего из примера выше в свой текст не переноси: тема, программа, персонажи и детали из примера не твои.') ON CONFLICT (key) DO NOTHING;
|
||||
UPDATE settings
|
||||
SET value = 'Ты — редактор сообщений тьютора детской IT-школы.
|
||||
|
||||
Переработай исходный текст занятия так, чтобы он звучал естественно и грамотно, как будто его написал живой тьютор родителю, а не нейросеть.
|
||||
|
||||
ГЛАВНОЕ:
|
||||
- Не добавляй информацию, которой нет в исходном тексте. Сохрани все факты и смысл.
|
||||
- Исправь ошибки, повторы и неудачные формулировки. Убери канцелярит, шаблонные фразы и «ИИ-язык».
|
||||
- Не используй чрезмерную похвалу и не превращай обычное занятие в достижение мирового масштала.
|
||||
|
||||
СТИЛЬ И СТРУКТУРА:
|
||||
- Один цельный абзац, 2–4 предложения, от третьего лица.
|
||||
- Первое предложение всегда начинай со слов «На занятии ребята …». Чем занимались в начале занятия — начало модуля, продолжение или завершение — определяй по правилу НАЧАЛО ФРАЗЫ.
|
||||
- Если в исходном тексте описана практическая часть, начни её со слов «В конце занятия …» или «Затем …». Нет практики в исходнике — не придумывай её.
|
||||
- Вместо «они» пиши «каждый». Обращение на «вы» не используй.
|
||||
- Коротко скажи, с чем познакомились или что изучали; затем — что конкретно делали; в конце — что сделал самостоятельно.
|
||||
- Сократи перечисления, объединяй их через «и», «а также», не повторяй одно и то же разными словами.
|
||||
- Убери разговорные и оценочные обороты: «было весело», «очень», «классно».
|
||||
|
||||
НАЧАЛО ФРАЗЫ (определяется номером темы):
|
||||
Система присылает готовую подсказку строкой «Позиция темы: …». Если такой строки нет, смотри номер вида N/M в конце строки «Тема занятия: …», где N — номер занятия в модуле, M — сколько занятий в модуле всего. Пользуйся только одной из этих подсказок и всегда проверяй, что N не больше M.
|
||||
- «последнее занятие модуля» (N = M, например 2/2 или 7/7): занятие завершает модуль. Первое предложение обязательно начни с «завершили» или «закончили»: «На занятии ребята завершили изучение …», «На занятии ребята закончили знакомство с …».
|
||||
- «промежуточное занятие модуля» (1 < N < M, например 2/4, 3/5, 6/7): изучение продолжается. Первое предложение обязательно начни с «продолжили», «продолжали», «закрепили» или «углубили»: «На занятии ребята продолжили изучение …», «На занятии ребята закрепили …».
|
||||
- «первое занятие модуля» (N = 1 и N < M, например 1/4 или 1/7): модуль начинается. Первое предложение обязательно начни с «начали» или «приступили»: «На занятии ребята начали знакомство с …», «На занятии ребята начали изучение …».
|
||||
- Если подсказки нет или номер нечитаем — характер занятия не выбирай и не додумывай. Начни нейтрально, прямо с сути: «На занятии ребята разобрались …», «На занятии ребята отработали …», «На занятии ребята попробовали …».
|
||||
- Это требование сильнее всего остального: даже если начало исходного текста уже хорошо звучит, перепиши его по правилу выше. Слово «модуль», цифры номера и любые оценки в текст отчёта не переноси: номер темы — внутренний признак для выбора формулировки.
|
||||
|
||||
ФОРМАТ:
|
||||
- Без заголовков, списков, markdown, подписей и пояснений.
|
||||
- Не начинай со слов «Сегодня», «Вчера», «Дата», «Группа», «Время», «Позиция» и вообще не упоминай группу, дату, время и номер занятия.
|
||||
- Не начинай с «На данном занятии» или «В рамках занятия».
|
||||
- Не оборачивай ответ в кавычки.
|
||||
- Не добавляй лишних предложений: если исходный текст уже написан нормально, не переписывай его ради переписывания.
|
||||
|
||||
ПРИМЕР ПРЕОБРАЗОВАНИЯ (бери отсюда только формулировки, тему и факты примера в свой текст не переноси):
|
||||
Исходный текст:
|
||||
«Ребята познакомились с программой Scratch Jr, научились выбирать фон, добавлять, изменять и создавать своих персонажей. В завершении занятия они выполнили практическое индивидуальное задание по созданию собственной анимации и небольшой программы».
|
||||
Тема занятия: «Анимация 1/3»
|
||||
Позиция темы: первое занятие модуля (1 из 3)
|
||||
Хороший результат:
|
||||
«На занятии ребята начали знакомство со Scratch Jr: научились выбирать фон, добавлять и изменять персонажей, а также создавать своих героев. В конце занятия каждый самостоятельно выполнил небольшое практическое задание — придумал свою анимацию и собрал простую программу».
|
||||
Тот же исходный текст, но «Позиция темы: промежуточное занятие модуля (2 из 3)» — меняется только начало:
|
||||
«На занятии ребята продолжили знакомство со Scratch Jr: научились выбирать фон, добавлять и изменять персонажей…».
|
||||
Тот же исходный текст, но «Позиция темы: последнее занятие модуля (3 из 3)»:
|
||||
«На занятии ребята завершили знакомство со Scratch Jr: научились выбирать фон, добавлять и изменять персонажей…».
|
||||
|
||||
ГЛАВНОЕ ПРАВИЛО:
|
||||
Отрабатывай ровно по этому исходному тексту. Ничего из примера выше в свой текст не переноси: тема, программа, персонажи и детали из примера не твои.'
|
||||
WHERE key = 'lesson_ai_prompt' AND value IN (
|
||||
'Ты — редактор деловых отчётов образовательного центра.
|
||||
|
||||
Твоя задача — привести текст отчёта о занятии, написанный тьютором, к деловому стилю по шаблону ниже.
|
||||
|
||||
ШАБЛОН ДЕЛОВОГО СООБЩЕНИЯ:
|
||||
Отчёт о проведённом занятии
|
||||
Дата: <дата занятия>
|
||||
Группа: <название группы>
|
||||
Темы: <перечень тем>
|
||||
Практика: <задания>
|
||||
Домашнее задание: <что задано>
|
||||
|
||||
ПРАВИЛА:
|
||||
1. Сначала сравни исходный текст с шаблоном. Если текст уже соответствует шаблону (та же структура, порядок и стиль) — верни его БЕЗ ИЗМЕНЕНИЙ, дословно.
|
||||
2. Если текст не соответствует шаблону — перепиши его по шаблону, сохранив весь смысл и факты.
|
||||
3. НЕ выдумывай тем, дат, заданий и оценок, которых нет в исходном тексте. Если данных нет — не добавляй раздел.
|
||||
4. Обращение на «вы», без эмодзи и без восклицательных знаков, кратко и по делу.
|
||||
5. Не добавляй приветствия, подписи и какие-либо пояснения.
|
||||
6. Верни ТОЛЬКО итоговый текст отчёта — без кавычек, без markdown и без названия формата.',
|
||||
'Ты — редактор сообщений тьютора детской IT-школы.
|
||||
|
||||
Переработай исходный текст занятия так, чтобы он звучал естественно и грамотно, как будто его написал живой тьютор родителю, а не нейросеть.
|
||||
|
||||
ГЛАВНОЕ:
|
||||
- Не добавляй информацию, которой нет в исходном тексте. Сохрани все факты и смысл.
|
||||
- Исправь ошибки, повторы и неудачные формулировки. Убери канцелярит, шаблонные фразы и «ИИ-язык».
|
||||
- Не используй чрезмерную похвалу и не превращай обычное занятие в достижение мирового масштала.
|
||||
|
||||
СТИЛЬ И СТРУКТУРА:
|
||||
- Один цельный абзац, 2–4 предложения, от третьего лица.
|
||||
- Первое предложение начинай со слов «На занятии ребята …».
|
||||
- Если в исходном тексте описана практическая часть, начни её со слов «В конце занятия …» или «Затем …». Нет практики в исходнике — не придумывай её.
|
||||
- Вместо «они» пиши «каждый». Обращение на «вы» не используй.
|
||||
- Коротко скажи, с чем познакомились или что изучали; затем — что конкретно делали; в конце — что сделал самостоятельно.
|
||||
- Сократи перечисления, объединяй их через «и», «а также», не повторяй одно и то же разными словами.
|
||||
- Убери разговорные и оценочные обороты: «было весело», «очень», «классно».
|
||||
|
||||
ФОРМАТ:
|
||||
- Без заголовков, списков, markdown, подписей и пояснений.
|
||||
- Не начинай со слов «Сегодня», «Вчера», «Дата», «Группа», «Время» и вообще не упоминай группу, дату и время занятия.
|
||||
- Не начинай с «На данном занятии» или «В рамках занятия».
|
||||
- Не оборачивай ответ в кавычки.
|
||||
- Не добавляй лишних предложений: если исходный текст уже написан нормально, не переписывай его ради переписывания.
|
||||
|
||||
ПРИМЕР ПРЕОБРАЗОВАНИЯ (бери отсюда только формулировки, тему и факты примера в свой текст не переноси):
|
||||
Исходный текст:
|
||||
«Ребята познакомились с программой Scratch Jr, научились выбирать фон, добавлять, изменять и создавать своих персонажей. В завершении занятия они выполнили практическое индивидуальное задание по созданию собственной анимации и небольшой программы».
|
||||
Хороший результат:
|
||||
«На занятии ребята познакомились со Scratch Jr: научились выбирать фон, добавлять и изменять персонажей, а также создавать своих героев. В конце занятия каждый самостоятельно выполнил небольшое практическое задание — придумал свою анимацию и собрал простую программу».
|
||||
|
||||
ГЛАВНОЕ ПРАВИЛО:
|
||||
Отрабатывай ровно по этому исходному тексту. Ничего из примера выше в свой текст не переноси: тема, программа, персонажи и детали из примера не твои.'
|
||||
);
|
||||
|
||||
INSERT INTO settings (key, value) VALUES ('notify_lesson_ai_formatted', 'false') ON CONFLICT (key) DO NOTHING;
|
||||
INSERT INTO settings (key, value) VALUES ('timezone', 'Europe/Moscow') ON CONFLICT (key) DO NOTHING;
|
||||
INSERT INTO settings (key, value) VALUES ('time_format', '24h') ON CONFLICT (key) DO NOTHING;
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
-- Migration: Add anonymize_names, expires_at, access_password_hash to share_links
|
||||
-- Run this on existing database
|
||||
|
||||
-- 1. Add anonymize_names column (default false = show names)
|
||||
ALTER TABLE share_links ADD COLUMN IF NOT EXISTS anonymize_names BOOLEAN DEFAULT false;
|
||||
|
||||
-- 2. Add expires_at column (default 7 days from creation, nullable for backward compat)
|
||||
ALTER TABLE share_links ADD COLUMN IF NOT EXISTS expires_at TIMESTAMPTZ;
|
||||
|
||||
-- 3. Add access_password_hash column for bcrypt password protection
|
||||
ALTER TABLE share_links ADD COLUMN IF NOT EXISTS access_password_hash VARCHAR(255);
|
||||
|
||||
-- 4. Update existing links to have expires_at = created_at + 7 days (optional, for existing links)
|
||||
-- UPDATE share_links SET expires_at = created_at + interval '7 days' WHERE expires_at IS NULL;
|
||||
|
||||
-- 5. Add index for cleanup of expired links
|
||||
CREATE INDEX IF NOT EXISTS idx_share_links_expires_at ON share_links(expires_at);
|
||||
|
||||
-- 6. Add public message and external link shown on the share page
|
||||
ALTER TABLE share_links ADD COLUMN IF NOT EXISTS message TEXT;
|
||||
ALTER TABLE share_links ADD COLUMN IF NOT EXISTS link_url VARCHAR(500);
|
||||
|
||||
-- 7. Per-link visibility overrides (NULL = fall back to global settings)
|
||||
ALTER TABLE share_links ADD COLUMN IF NOT EXISTS show_student_message BOOLEAN;
|
||||
ALTER TABLE share_links ADD COLUMN IF NOT EXISTS show_entry_date BOOLEAN;
|
||||
ALTER TABLE share_links ADD COLUMN IF NOT EXISTS show_group_photos BOOLEAN;
|
||||
|
||||
-- 8. Replace anonymize_names with positive show_student_names (NULL = inherit global setting)
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'share_links' AND column_name = 'anonymize_names') THEN
|
||||
ALTER TABLE share_links ADD COLUMN IF NOT EXISTS show_student_names BOOLEAN;
|
||||
UPDATE share_links SET show_student_names = (NOT anonymize_names) WHERE anonymize_names IS NOT NULL;
|
||||
ALTER TABLE share_links DROP COLUMN anonymize_names;
|
||||
END IF;
|
||||
END $$;
|
||||
@@ -0,0 +1,215 @@
|
||||
const MAX_CELLS = 400000;
|
||||
const MAX_DIFF_CHARS = 6000;
|
||||
const MAX_SEGMENTS = 80;
|
||||
|
||||
const FIELD_LABELS = {
|
||||
student_name: 'ФИО ученика',
|
||||
group_id: 'Группа',
|
||||
module_id: 'Тема модуля',
|
||||
description: 'Текст работы'
|
||||
};
|
||||
|
||||
const SIMPLE_FIELDS = ['student_name', 'group_id', 'module_id'];
|
||||
|
||||
const NAME_FIELDS = { group_id: 'group_name', module_id: 'module_name' };
|
||||
|
||||
function asText(v) {
|
||||
if (v === null || v === undefined) return '';
|
||||
return typeof v === 'string' ? v : String(v);
|
||||
}
|
||||
|
||||
function tokenize(text) {
|
||||
return asText(text).split(/(\s+)/).filter(t => t.length > 0);
|
||||
}
|
||||
|
||||
function compact(segments) {
|
||||
const out = [];
|
||||
for (const seg of segments) {
|
||||
if (!seg.text) continue;
|
||||
const last = out[out.length - 1];
|
||||
if (last && last.type === seg.type) last.text += seg.text;
|
||||
else out.push({ type: seg.type, text: seg.text });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function lcsSegments(a, b) {
|
||||
const n = a.length;
|
||||
const m = b.length;
|
||||
if (!n) return m ? [{ type: 'add', text: b.join('') }] : [];
|
||||
if (!m) return [{ type: 'del', text: a.join('') }];
|
||||
const w = m + 1;
|
||||
const dp = new Int32Array((n + 1) * w);
|
||||
for (let i = n - 1; i >= 0; i--) {
|
||||
const rowBase = i * w;
|
||||
const nextBase = (i + 1) * w;
|
||||
for (let j = m - 1; j >= 0; j--) {
|
||||
dp[rowBase + j] = a[i] === b[j]
|
||||
? dp[nextBase + j + 1] + 1
|
||||
: Math.max(dp[nextBase + j], dp[rowBase + j + 1]);
|
||||
}
|
||||
}
|
||||
const out = [];
|
||||
let i = 0;
|
||||
let j = 0;
|
||||
while (i < n && j < m) {
|
||||
if (a[i] === b[j]) { out.push({ type: 'eq', text: a[i] }); i++; j++; }
|
||||
else if (dp[(i + 1) * w + j] >= dp[i * w + j + 1]) { out.push({ type: 'del', text: a[i] }); i++; }
|
||||
else { out.push({ type: 'add', text: b[j] }); j++; }
|
||||
}
|
||||
while (i < n) { out.push({ type: 'del', text: a[i] }); i++; }
|
||||
while (j < m) { out.push({ type: 'add', text: b[j] }); j++; }
|
||||
return out;
|
||||
}
|
||||
|
||||
function anchoredDiff(a, b) {
|
||||
let head = 0;
|
||||
while (head < a.length && head < b.length && a[head] === b[head]) head++;
|
||||
let tailA = a.length;
|
||||
let tailB = b.length;
|
||||
while (tailA > head && tailB > head && a[tailA - 1] === b[tailB - 1]) { tailA--; tailB--; }
|
||||
const out = head ? [{ type: 'eq', text: a.slice(0, head).join('') }] : [];
|
||||
const midA = a.slice(head, tailA);
|
||||
const midB = b.slice(head, tailB);
|
||||
if (midA.length * midB.length <= MAX_CELLS) {
|
||||
out.push(...lcsSegments(midA, midB));
|
||||
} else {
|
||||
if (midA.length) out.push({ type: 'del', text: midA.join('') });
|
||||
if (midB.length) out.push({ type: 'add', text: midB.join('') });
|
||||
}
|
||||
if (tailA < a.length) out.push({ type: 'eq', text: a.slice(tailA).join('') });
|
||||
return out;
|
||||
}
|
||||
|
||||
function capSegments(segments) {
|
||||
const out = [];
|
||||
let chars = 0;
|
||||
let truncated = false;
|
||||
for (const seg of segments) {
|
||||
const room = MAX_DIFF_CHARS - chars;
|
||||
if (out.length >= MAX_SEGMENTS || room <= 0) { truncated = true; break; }
|
||||
if (seg.text.length > room) {
|
||||
out.push({ type: seg.type, text: seg.text.slice(0, room) });
|
||||
chars += room;
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
out.push({ type: seg.type, text: seg.text });
|
||||
chars += seg.text.length;
|
||||
}
|
||||
if (truncated) out.push({ type: 'eq', text: '…' });
|
||||
return { segments: out, truncated };
|
||||
}
|
||||
|
||||
function countWords(text) {
|
||||
const t = text.trim();
|
||||
return t ? t.split(/\s+/).length : 0;
|
||||
}
|
||||
|
||||
function diffStats(segments, before, after) {
|
||||
let addedChars = 0;
|
||||
let removedChars = 0;
|
||||
let addedWords = 0;
|
||||
let removedWords = 0;
|
||||
for (const seg of segments) {
|
||||
if (seg.type === 'add') { addedChars += seg.text.length; addedWords += countWords(seg.text); }
|
||||
else if (seg.type === 'del') { removedChars += seg.text.length; removedWords += countWords(seg.text); }
|
||||
}
|
||||
return {
|
||||
added_chars: addedChars,
|
||||
removed_chars: removedChars,
|
||||
added_words: addedWords,
|
||||
removed_words: removedWords,
|
||||
chars_before: before.length,
|
||||
chars_after: after.length
|
||||
};
|
||||
}
|
||||
|
||||
function textDiff(beforeRaw, afterRaw) {
|
||||
const before = asText(beforeRaw);
|
||||
const after = asText(afterRaw);
|
||||
if (before === after) {
|
||||
return { changed: false, segments: [], truncated: false, stats: diffStats([], before, after) };
|
||||
}
|
||||
const a = tokenize(before);
|
||||
const b = tokenize(after);
|
||||
const raw = a.length * b.length <= MAX_CELLS ? lcsSegments(a, b) : anchoredDiff(a, b);
|
||||
const full = compact(raw);
|
||||
const capped = capSegments(full);
|
||||
return {
|
||||
changed: true,
|
||||
segments: capped.segments,
|
||||
truncated: capped.truncated,
|
||||
stats: diffStats(full, before, after)
|
||||
};
|
||||
}
|
||||
|
||||
function displayValue(row, field) {
|
||||
if (!row) return null;
|
||||
const value = row[field];
|
||||
const name = row[NAME_FIELDS[field]];
|
||||
if (value === null || value === undefined || value === '') return name ? `— (${name})` : null;
|
||||
if (name) return `${value} · ${name}`;
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function buildEntryDiff(before, after) {
|
||||
const changes = [];
|
||||
for (const field of SIMPLE_FIELDS) {
|
||||
const prev = displayValue(before, field);
|
||||
const next = displayValue(after, field);
|
||||
if (prev !== next) changes.push({ field, label: FIELD_LABELS[field], before: prev, after: next });
|
||||
}
|
||||
const beforeText = asText(before && before.description);
|
||||
const afterText = asText(after && after.description);
|
||||
if (beforeText !== afterText) {
|
||||
const d = textDiff(beforeText, afterText);
|
||||
changes.push({
|
||||
field: 'description',
|
||||
label: FIELD_LABELS.description,
|
||||
stats: d.stats,
|
||||
diff: d.segments,
|
||||
truncated: d.truncated
|
||||
});
|
||||
}
|
||||
return changes;
|
||||
}
|
||||
|
||||
function normalizeEditSource(raw, before, after) {
|
||||
const value = typeof raw === 'string' ? raw.trim() : '';
|
||||
const beforeText = asText(before && before.description);
|
||||
const afterText = asText(after && after.description);
|
||||
const aiText = asText(after && after.description_ai);
|
||||
const textChanged = beforeText !== afterText;
|
||||
if (!textChanged) return 'manual';
|
||||
if (value === 'ai' || value === 'ai_manual') return value;
|
||||
if (aiText && afterText === aiText) return 'ai';
|
||||
return 'manual';
|
||||
}
|
||||
|
||||
function summarizeChanges(changes) {
|
||||
if (!Array.isArray(changes)) return [];
|
||||
return changes.map(change => {
|
||||
const item = { field: change.field, label: change.label || change.field };
|
||||
if ('before' in change) item.before = change.before;
|
||||
if ('after' in change) item.after = change.after;
|
||||
if (change.stats) item.stats = change.stats;
|
||||
if (change.truncated) item.truncated = true;
|
||||
return item;
|
||||
});
|
||||
}
|
||||
|
||||
function stripDiffs(target) {
|
||||
if (!target || typeof target !== 'object' || Array.isArray(target)) return target;
|
||||
if (!Array.isArray(target.changes)) return target;
|
||||
return { ...target, changes: summarizeChanges(target.changes) };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
FIELD_LABELS,
|
||||
textDiff,
|
||||
buildEntryDiff,
|
||||
normalizeEditSource,
|
||||
summarizeChanges,
|
||||
stripDiffs
|
||||
};
|
||||
@@ -0,0 +1,215 @@
|
||||
const assert = require('assert');
|
||||
const { textDiff, buildEntryDiff, normalizeEditSource, stripDiffs } = require('./diff');
|
||||
|
||||
function reconstruct(segments, types) {
|
||||
return segments.filter(s => types.includes(s.type)).map(s => s.text).join('');
|
||||
}
|
||||
|
||||
function baseEntry(over = {}) {
|
||||
return {
|
||||
student_name: 'Иванов Иван',
|
||||
group_id: 1,
|
||||
group_name: 'Первый класс',
|
||||
module_id: 5,
|
||||
module_name: 'Модуль 1',
|
||||
description: 'Я сделал проект по окружающему миру и сдал его вчера.',
|
||||
description_ai: null,
|
||||
...over
|
||||
};
|
||||
}
|
||||
|
||||
function testNoChange() {
|
||||
const d = textDiff('одно и то же', 'одно и то же');
|
||||
assert.strictEqual(d.changed, false, 'identical text is not changed');
|
||||
assert.deepStrictEqual(d.segments, [], 'no segments for identical text');
|
||||
assert.strictEqual(d.stats.added_chars, 0, 'no added chars');
|
||||
assert.strictEqual(d.stats.removed_chars, 0, 'no removed chars');
|
||||
}
|
||||
|
||||
function testReconstruct() {
|
||||
const before = 'Он купил хлеб и молоко вчера';
|
||||
const after = 'Она купила хлеб и молоко сегодня';
|
||||
const d = textDiff(before, after);
|
||||
assert.strictEqual(d.changed, true, 'text changed');
|
||||
assert.strictEqual(reconstruct(d.segments, ['eq', 'del']), before, 'before is reconstructible');
|
||||
assert.strictEqual(reconstruct(d.segments, ['eq', 'add']), after, 'after is reconstructible');
|
||||
assert.ok(d.stats.removed_words >= 1, 'removed words counted');
|
||||
assert.ok(d.stats.added_words >= 1, 'added words counted');
|
||||
assert.ok(d.stats.chars_before === before.length, 'chars_before');
|
||||
assert.ok(d.stats.chars_after === after.length, 'chars_after');
|
||||
}
|
||||
|
||||
function testReconstructInsertOnly() {
|
||||
const d = textDiff('сделал проект', 'сделал большой проект');
|
||||
assert.strictEqual(reconstruct(d.segments, ['eq', 'del']), 'сделал проект', 'before is reconstructible');
|
||||
assert.strictEqual(reconstruct(d.segments, ['eq', 'add']), 'сделал большой проект', 'after is reconstructible');
|
||||
}
|
||||
|
||||
function testReconstructDeleteOnly() {
|
||||
const d = textDiff('сделал большой проект', 'сделал проект');
|
||||
assert.strictEqual(reconstruct(d.segments, ['eq', 'del']), 'сделал большой проект', 'before is reconstructible');
|
||||
assert.strictEqual(reconstruct(d.segments, ['eq', 'add']), 'сделал проект', 'after is reconstructible');
|
||||
}
|
||||
|
||||
function testInsertOnly() {
|
||||
const d = textDiff('сделал проект', 'сделал большой проект');
|
||||
assert.strictEqual(d.stats.removed_chars, 0, 'insert removes nothing');
|
||||
assert.ok(d.stats.added_words === 1, 'one word added');
|
||||
assert.ok(reconstruct(d.segments, 'add').includes('большой'), 'added word visible');
|
||||
}
|
||||
|
||||
function testDeleteOnly() {
|
||||
const d = textDiff('сделал большой проект', 'сделал проект');
|
||||
assert.strictEqual(d.stats.added_chars, 0, 'delete adds nothing');
|
||||
assert.ok(d.stats.removed_words === 1, 'one word removed');
|
||||
assert.ok(reconstruct(d.segments, 'del').includes('большой'), 'removed word visible');
|
||||
}
|
||||
|
||||
function testEmptyToText() {
|
||||
const d = textDiff(null, 'новый текст');
|
||||
assert.strictEqual(d.changed, true, 'null to text is a change');
|
||||
assert.strictEqual(reconstruct(d.segments, 'add'), 'новый текст', 'whole text added');
|
||||
assert.strictEqual(d.stats.added_words, 2, 'both words added');
|
||||
}
|
||||
|
||||
function testTextToEmpty() {
|
||||
const d = textDiff('старый текст', '');
|
||||
assert.strictEqual(d.changed, true, 'text to empty is a change');
|
||||
assert.strictEqual(reconstruct(d.segments, 'del'), 'старый текст', 'whole text removed');
|
||||
assert.strictEqual(d.stats.removed_words, 2, 'both words removed');
|
||||
}
|
||||
|
||||
function testMultiline() {
|
||||
const before = 'строка один\nстрока два\nстрока три';
|
||||
const after = 'строка один\nстрока ДВА\nстрока три';
|
||||
const d = textDiff(before, after);
|
||||
assert.strictEqual(d.changed, true, 'multiline changed');
|
||||
assert.ok(reconstruct(d.segments, 'del').includes('два'), 'old word marked removed');
|
||||
assert.ok(reconstruct(d.segments, 'add').includes('ДВА'), 'new word marked added');
|
||||
}
|
||||
|
||||
function testLargeTextFallback() {
|
||||
const before = Array.from({ length: 4000 }, (_, i) => `слово${i}`).join(' ');
|
||||
const after = before.replace('слово2000 ', 'слово2000И ');
|
||||
const started = Date.now();
|
||||
const d = textDiff(before, after);
|
||||
const elapsed = Date.now() - started;
|
||||
assert.strictEqual(d.changed, true, 'large text changed');
|
||||
assert.ok(d.stats.removed_words >= 1 && d.stats.added_words >= 1, 'large diff still counts words');
|
||||
assert.ok(d.segments.length > 0, 'large diff still has segments');
|
||||
assert.ok(reconstruct(d.segments, ['eq', 'del']).length > 0, 'large diff keeps removed text');
|
||||
assert.ok(reconstruct(d.segments, ['eq', 'add']).length > 0, 'large diff keeps added text');
|
||||
assert.ok(elapsed < 2000, `large text diff is fast (${elapsed}ms)`);
|
||||
}
|
||||
|
||||
function testCapping() {
|
||||
const before = 'a '.repeat(6000);
|
||||
const after = 'b '.repeat(6000);
|
||||
const d = textDiff(before, after);
|
||||
assert.strictEqual(d.truncated, true, 'huge diff is truncated');
|
||||
const total = d.segments.reduce((n, s) => n + s.text.length, 0);
|
||||
assert.ok(total <= 6200, `diff payload is capped (${total} chars)`);
|
||||
assert.ok(d.stats.removed_words > 100, 'stats are computed on the full text');
|
||||
}
|
||||
|
||||
function testEntryDiffDescription() {
|
||||
const before = baseEntry();
|
||||
const after = baseEntry({ description: 'Я сделал проект по окружающему миру и сдал его сегодня.' });
|
||||
const changes = buildEntryDiff(before, after);
|
||||
assert.strictEqual(changes.length, 1, 'only description changed');
|
||||
assert.strictEqual(changes[0].field, 'description', 'field is description');
|
||||
assert.strictEqual(changes[0].label, 'Текст работы', 'label is human readable');
|
||||
assert.ok(changes[0].diff.length > 0, 'diff segments present');
|
||||
assert.strictEqual(changes[0].stats.removed_words, 1, 'one word removed');
|
||||
assert.strictEqual(changes[0].stats.added_words, 1, 'one word added');
|
||||
}
|
||||
|
||||
function testEntryDiffFields() {
|
||||
const before = baseEntry();
|
||||
const after = baseEntry({
|
||||
student_name: 'Петров Пётр',
|
||||
group_id: 2,
|
||||
group_name: 'Второй класс',
|
||||
module_id: null,
|
||||
module_name: null
|
||||
});
|
||||
const changes = buildEntryDiff(before, after);
|
||||
const fields = changes.map(c => c.field).sort();
|
||||
assert.deepStrictEqual(fields, ['group_id', 'module_id', 'student_name'], 'three fields changed');
|
||||
const group = changes.find(c => c.field === 'group_id');
|
||||
assert.strictEqual(group.before, '1 · Первый класс', 'group before with name');
|
||||
assert.strictEqual(group.after, '2 · Второй класс', 'group after with name');
|
||||
const mod = changes.find(c => c.field === 'module_id');
|
||||
assert.ok(!mod.after, 'module cleared -> null');
|
||||
assert.strictEqual(mod.before, '5 · Модуль 1', 'module before with name');
|
||||
}
|
||||
|
||||
function testEntryDiffNoChange() {
|
||||
const before = baseEntry();
|
||||
const changes = buildEntryDiff(before, baseEntry());
|
||||
assert.deepStrictEqual(changes, [], 'no changes detected');
|
||||
}
|
||||
|
||||
function testEditSource() {
|
||||
const before = baseEntry();
|
||||
const afterAi = baseEntry({ description: 'Текст от ИИ', description_ai: 'Текст от ИИ' });
|
||||
assert.strictEqual(normalizeEditSource('ai', before, afterAi), 'ai', 'ai source');
|
||||
assert.strictEqual(normalizeEditSource('', before, afterAi), 'ai', 'ai detected from description_ai');
|
||||
assert.strictEqual(normalizeEditSource('ai', before, baseEntry({ description: 'Текст руками', description_ai: 'Текст от ИИ' })), 'ai', 'explicit ai claim is trusted');
|
||||
assert.strictEqual(normalizeEditSource('ai_manual', before, afterAi), 'ai_manual', 'ai_manual kept');
|
||||
assert.strictEqual(normalizeEditSource('', before, baseEntry({ description: 'Текст руками' })), 'manual', 'manual by default');
|
||||
assert.strictEqual(normalizeEditSource('ai', baseEntry(), baseEntry({ group_id: 2 })), 'manual', 'no text change -> manual');
|
||||
assert.strictEqual(normalizeEditSource('<script>', before, baseEntry({ description: 'x' })), 'manual', 'garbage source ignored');
|
||||
}
|
||||
|
||||
function testStripDiffs() {
|
||||
const before = baseEntry();
|
||||
const after = baseEntry({ description: 'Другой текст целиком' });
|
||||
const target = { id: 1, source: 'manual', changes: buildEntryDiff(before, { ...after, group_id: 2, group_name: 'Два' }) };
|
||||
const light = stripDiffs(target);
|
||||
assert.strictEqual(light.changes.length, 2, 'changes preserved');
|
||||
const desc = light.changes.find(c => c.field === 'description');
|
||||
assert.ok(desc, 'description change kept');
|
||||
assert.strictEqual(desc.diff, undefined, 'diff dropped in list payload');
|
||||
assert.ok(desc.stats, 'stats preserved');
|
||||
assert.ok(target.changes.find(c => c.field === 'description').diff.length > 0, 'original target still has diff');
|
||||
const grp = light.changes.find(c => c.field === 'group_id');
|
||||
assert.ok(grp && 'before' in grp && 'after' in grp, 'simple field before/after kept in list payload');
|
||||
assert.strictEqual(grp.before, '1 · Первый класс', 'group before kept');
|
||||
assert.strictEqual(grp.after, '2 · Два', 'group after kept');
|
||||
assert.strictEqual(stripDiffs(null), null, 'null target');
|
||||
assert.deepStrictEqual(stripDiffs({ a: 1 }), { a: 1 }, 'target without changes untouched');
|
||||
assert.strictEqual(stripDiffs({ changes: [] }).changes.length, 0, 'empty changes kept');
|
||||
}
|
||||
|
||||
const tests = [
|
||||
testNoChange,
|
||||
testReconstruct,
|
||||
testReconstructInsertOnly,
|
||||
testReconstructDeleteOnly,
|
||||
testInsertOnly,
|
||||
testDeleteOnly,
|
||||
testEmptyToText,
|
||||
testTextToEmpty,
|
||||
testMultiline,
|
||||
testLargeTextFallback,
|
||||
testCapping,
|
||||
testEntryDiffDescription,
|
||||
testEntryDiffFields,
|
||||
testEntryDiffNoChange,
|
||||
testEditSource,
|
||||
testStripDiffs
|
||||
];
|
||||
|
||||
let failed = 0;
|
||||
for (const t of tests) {
|
||||
try {
|
||||
t();
|
||||
console.log('ok ', t.name);
|
||||
} catch (e) {
|
||||
failed++;
|
||||
console.log('FAIL', t.name, '-', e.message);
|
||||
}
|
||||
}
|
||||
console.log(failed ? `\n${failed} из ${tests.length} тестов упали` : `\nВсе ${tests.length} тестов пройдены`);
|
||||
process.exit(failed ? 1 : 0);
|
||||
@@ -0,0 +1,32 @@
|
||||
# Переопределение photo-ai для работы на NVIDIA GPU.
|
||||
# Использование:
|
||||
# docker compose -f docker-compose.yml -f docker-compose.gpu.yml up -d --build photo-ai
|
||||
#
|
||||
# Требуется драйвер NVIDIA и NVIDIA Container Toolkit. GPU выдаётся контейнеру ключом
|
||||
# `gpus: all`: Docker сам подставляет драйвер NVIDIA, правка /etc/docker/daemon.json и
|
||||
# перезапуск демона не нужны.
|
||||
# Схема через CDI (deploy.resources.reservations.devices -> nvidia.com/gpu=all) здесь
|
||||
# НЕ используется намеренно: спека /etc/cdi/nvidia.yaml запекает нумерацию /dev/dri/card*
|
||||
# на момент генерации, поэтому после переподключения видеокарты или смены порта
|
||||
# она начинает ссылаться на несуществующий узел и контейнер не стартует
|
||||
# ("CDI device injection failed: failed to stat CDI host device /dev/dri/cardN").
|
||||
# Перегенерация спеки требует sudo и теряется при каждой перегенерации;
|
||||
# `gpus: all` от этого свободен. Если nvidia-runtime зарегистрирован в демоне
|
||||
# (nvidia-ctk runtime configure --runtime=docker), можно вместо этого указать
|
||||
# deploy.resources.reservations.devices с driver: nvidia, count: 1 — результат тот же.
|
||||
# TORCH_VARIANT=cu126, а не cu124: в индексе cu124 последний torch — 2.6.0, а cu126 даёт ровно
|
||||
# те же torch 2.14.0 / torchvision 0.29.0, что и CPU-образ, поэтому варианты сборки отличаются
|
||||
# только CUDA-библиотеками.
|
||||
# Образ тегируется отдельно (whatido-photo-ai:cu126), чтобы сборка GPU-варианта не перетирала
|
||||
# CPU-образ whatido-photo-ai:latest.
|
||||
# PHOTO_AI_DEVICE=cuda при недоступной CUDA не роняет сервис: app.py пишет WARN и работает
|
||||
# на CPU, /health при этом отвечает 200.
|
||||
services:
|
||||
photo-ai:
|
||||
image: whatido-photo-ai:cu126
|
||||
build:
|
||||
args:
|
||||
TORCH_VARIANT: cu126
|
||||
environment:
|
||||
PHOTO_AI_DEVICE: ${PHOTO_AI_DEVICE:-cuda}
|
||||
gpus: all
|
||||
@@ -0,0 +1,26 @@
|
||||
# Переопределение S3-сервиса на MinIO.
|
||||
# Использование:
|
||||
# S3_IMAGE=minio/minio:RELEASE.2025-04-22T22-12-26Z \
|
||||
# docker compose -f docker-compose.yml -f docker-compose.minio.yml up -d s3
|
||||
#
|
||||
# Учтите: MinIO прекратил публикацию свободных образов (docker.io/minio/minio
|
||||
# удалён), поэтому образ нужно взять из доступного вам зеркала/архива и указать
|
||||
# его в S3_IMAGE.
|
||||
services:
|
||||
s3:
|
||||
image: ${S3_IMAGE:-minio/minio:latest}
|
||||
container_name: whatido-s3
|
||||
restart: unless-stopped
|
||||
command: server /data --console-address ":9001"
|
||||
environment:
|
||||
MINIO_ROOT_USER: ${S3_ACCESS_KEY:-whatido}
|
||||
MINIO_ROOT_PASSWORD: ${S3_SECRET_KEY:-whatido-secret}
|
||||
MINIO_BROWSER: ${MINIO_BROWSER:-off}
|
||||
TZ: Europe/Moscow
|
||||
expose:
|
||||
- "9000"
|
||||
ports:
|
||||
- "127.0.0.1:9000:9000"
|
||||
- "127.0.0.1:9001:9001"
|
||||
volumes:
|
||||
- s3-data:/data
|
||||
@@ -3,36 +3,234 @@ name: whatido
|
||||
services:
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: whereldo
|
||||
POSTGRES_USER: app
|
||||
POSTGRES_PASSWORD: app
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
||||
TZ: Europe/Moscow
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
- ./db/init.sql:/docker-entrypoint-initdb.d/init.sql
|
||||
ports:
|
||||
- "5432:5432"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U app -d whereldo"]
|
||||
interval: 2s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
|
||||
app:
|
||||
build: .
|
||||
# Redis: кэш запросов, rate limit, баны IP, кэш сессий, pub/sub для SSE и воркеров.
|
||||
# Приложение не падает, если Redis недоступен — автоматически работает
|
||||
# на in-memory кэше (см. redis.js).
|
||||
# Отладка: docker compose exec redis redis-cli -a "$REDIS_PASSWORD" INFO
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: whatido-redis
|
||||
restart: unless-stopped
|
||||
command: >
|
||||
redis-server
|
||||
--requirepass ${REDIS_PASSWORD}
|
||||
--appendonly yes
|
||||
--appendfsync everysec
|
||||
--maxmemory ${REDIS_MAXMEMORY:-256mb}
|
||||
--maxmemory-policy allkeys-lru
|
||||
--save ""
|
||||
environment:
|
||||
TZ: Europe/Moscow
|
||||
REDIS_PASSWORD: ${REDIS_PASSWORD}
|
||||
expose:
|
||||
- "6379"
|
||||
ports:
|
||||
- "3000:3000"
|
||||
- "127.0.0.1:6379:6379"
|
||||
volumes:
|
||||
- redis-data:/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "redis-cli -a \"$$REDIS_PASSWORD\" ping | grep -q PONG"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
start_period: 5s
|
||||
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
network: host
|
||||
args:
|
||||
GIT_COMMIT: ${GIT_COMMIT:-}
|
||||
GIT_COMMIT_DATE: ${GIT_COMMIT_DATE:-}
|
||||
restart: unless-stopped
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
expose:
|
||||
- "3003"
|
||||
- "3443"
|
||||
ports:
|
||||
- "3003:3003"
|
||||
- "3443:3443"
|
||||
environment:
|
||||
DATABASE_URL: postgres://app:app@db:5432/whereldo
|
||||
DATABASE_URL: postgres://app:${DB_PASSWORD}@db:5432/whereldo
|
||||
REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379
|
||||
REDIS_PREFIX: ${REDIS_PREFIX:-whatido}
|
||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
|
||||
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
|
||||
BACKUP_UPLOAD_LIMIT_MB: ${BACKUP_UPLOAD_LIMIT_MB:-500}
|
||||
UPLOAD_FILE_LIMIT_MB: ${UPLOAD_FILE_LIMIT_MB:-50}
|
||||
UPLOAD_TOTAL_LIMIT_MB: ${UPLOAD_TOTAL_LIMIT_MB:-200}
|
||||
UPLOAD_REQUEST_TIMEOUT_MS: ${UPLOAD_REQUEST_TIMEOUT_MS:-}
|
||||
AI_MODEL: ${AI_MODEL:-qwen2.5-1.5b-instruct-q4_k_m.gguf}
|
||||
AI_PROMPT: ${AI_PROMPT:-}
|
||||
AI_REQUEST_TIMEOUT_MS: ${AI_REQUEST_TIMEOUT_MS:-120000}
|
||||
PHOTO_AI_URL: ${PHOTO_AI_URL:-http://photo-ai:8080}
|
||||
PHOTO_AI_FACE_MODEL: ${PHOTO_AI_FACE_MODEL:-gfpgan}
|
||||
PHOTO_AI_FACE_TIMEOUT_MS: ${PHOTO_AI_FACE_TIMEOUT_MS:-600000}
|
||||
NODE_ENV: production
|
||||
TZ: Europe/Moscow
|
||||
STORAGE_DRIVER: ${STORAGE_DRIVER:-local}
|
||||
STORAGE_LOCAL_FALLBACK: ${STORAGE_LOCAL_FALLBACK:-1}
|
||||
STORAGE_KEEP_LOCAL: ${STORAGE_KEEP_LOCAL:-0}
|
||||
STORAGE_CACHE_MAX_AGE_HOURS: ${STORAGE_CACHE_MAX_AGE_HOURS:-168}
|
||||
S3_ENDPOINT: ${S3_ENDPOINT:-http://minio:9000}
|
||||
S3_REGION: ${S3_REGION:-us-east-1}
|
||||
S3_BUCKET: ${S3_BUCKET:-whatido}
|
||||
S3_ACCESS_KEY: ${S3_ACCESS_KEY:-whatido}
|
||||
S3_SECRET_KEY: ${S3_SECRET_KEY:-whatido-secret}
|
||||
S3_FORCE_PATH_STYLE: ${S3_FORCE_PATH_STYLE:-1}
|
||||
S3_PREFIX: ${S3_PREFIX:-}
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- ./uploads:/app/uploads
|
||||
|
||||
# S3-совместимое хранилище файлов. API 9000 доступен только внутри сети compose
|
||||
# (плюс loopback хоста для отладки/миграции).
|
||||
# По умолчанию — SeaweedFS: свободный S3-сервер, доступный в Docker Hub.
|
||||
# Для MinIO (если образ доступен в вашем зеркале) используйте:
|
||||
# docker compose -f docker-compose.yml -f docker-compose.minio.yml up -d s3
|
||||
# Перенос файлов из ./uploads в бакет:
|
||||
# docker compose exec -T app node scripts/migrate-to-s3.js --dry-run
|
||||
s3:
|
||||
image: ${S3_IMAGE:-chrislusf/seaweedfs:latest}
|
||||
container_name: whatido-s3
|
||||
restart: unless-stopped
|
||||
command: server -dir=/data -s3 -s3.port=9000
|
||||
environment:
|
||||
AWS_ACCESS_KEY_ID: ${S3_ACCESS_KEY:-whatido}
|
||||
AWS_SECRET_ACCESS_KEY: ${S3_SECRET_KEY:-whatido-secret}
|
||||
TZ: Europe/Moscow
|
||||
expose:
|
||||
- "9000"
|
||||
ports:
|
||||
- "127.0.0.1:9000:9000"
|
||||
volumes:
|
||||
- s3-data:/data
|
||||
|
||||
# Публикация через Tailscale (Serve / Funnel) без проброса портов.
|
||||
# Приложение доступно по https://whatido.<tailnet>.ts.net
|
||||
# tailscale:
|
||||
# image: tailscale/tailscale:latest
|
||||
# hostname: whatido
|
||||
# restart: unless-stopped
|
||||
# network_mode: host
|
||||
# cap_add:
|
||||
# - NET_ADMIN
|
||||
# - SYS_MODULE
|
||||
# environment:
|
||||
# SSL_CERT_FILE: /etc/tailscale/app-certs/cert.pem
|
||||
# volumes:
|
||||
# - /var/lib/tailscale:/var/lib/tailscale
|
||||
# - /dev/net/tun:/dev/net/tun
|
||||
# - /lib/modules:/lib/modules:ro
|
||||
# - ./certs:/etc/tailscale/app-certs:ro
|
||||
# - ./start-tailscale.sh:/start-tailscale.sh:ro
|
||||
# command: ["/bin/sh", "/start-tailscale.sh"]
|
||||
# depends_on:
|
||||
# - app
|
||||
|
||||
# Публикация наружу через Cloudflare Tunnel (cloudflared), Quick Tunnel:
|
||||
# случайный публичный URL *.trycloudflare.com, который печатается в логах:
|
||||
# docker compose logs -f cloudflared
|
||||
# Сборка из Dockerfile.cloudflared добавляет опциональный WireGuard:
|
||||
# если в wg/wg0.conf лежит конфиг — контейнер сначала поднимает VPN и
|
||||
# только потом запускает туннель (исход Cloudflare через VPN). Без конфига
|
||||
# туннель стартует сразу, как в базовой схеме.
|
||||
# cloudflared:
|
||||
# build:
|
||||
# context: .
|
||||
# dockerfile: Dockerfile.cloudflared
|
||||
# restart: unless-stopped
|
||||
# cap_add:
|
||||
# - NET_ADMIN
|
||||
# privileged: true
|
||||
# volumes:
|
||||
# - ./wg:/etc/wireguard:ro
|
||||
# environment:
|
||||
# TZ: Europe/Moscow
|
||||
# CLOUDFLARE_TUNNEL_URL: ${CLOUDFLARE_TUNNEL_URL:-http://app:3003}
|
||||
# WG_HANDSHAKE_TIMEOUT: ${WG_HANDSHAKE_TIMEOUT:-60}
|
||||
# depends_on:
|
||||
# - app
|
||||
|
||||
|
||||
|
||||
text-corrector:
|
||||
image: ghcr.io/ggml-org/llama.cpp:server
|
||||
container_name: text-corrector
|
||||
restart: unless-stopped
|
||||
|
||||
entrypoint: ["/bin/sh", "/start-text-corrector.sh"]
|
||||
|
||||
environment:
|
||||
AI_MODEL: ${AI_MODEL:-qwen2.5-1.5b-instruct-q4_k_m.gguf}
|
||||
AI_MODEL_REPO: ${AI_MODEL_REPO:-Qwen/Qwen2.5-1.5B-Instruct-GGUF}
|
||||
HUGGINGFACE_TOKEN: ${HUGGINGFACE_TOKEN:-}
|
||||
|
||||
volumes:
|
||||
- ./models:/models
|
||||
- ./start-text-corrector.sh:/start-text-corrector.sh:ro
|
||||
|
||||
ports:
|
||||
- "8080:8080"
|
||||
|
||||
|
||||
# ИИ-улучшение фотографий (Real-ESRGAN: апскейл, денойз, восстановление лиц GFPGAN).
|
||||
# Устройство выбирается автоматически (PHOTO_AI_DEVICE=auto): CUDA, если контейнеру
|
||||
# выдан GPU, иначе CPU. Запуск на GPU — через docker-compose.gpu.yml.
|
||||
# Поднимается вместе со стеком; если не нужен — PHOTO_AI_URL пустой в .env.
|
||||
# Порт 8081 пробрасывается только на loopback хоста — наружу ничего не публикуется,
|
||||
# хостовый 8080 уже занят text-corrector. Ручные проверки: curl http://127.0.0.1:8081/health
|
||||
photo-ai:
|
||||
build:
|
||||
context: ./photo-ai
|
||||
args:
|
||||
PHOTO_AI_PREFETCH: ${PHOTO_AI_PREFETCH:-codeformer}
|
||||
container_name: photo-ai
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
MODEL_PATH: /models/RealESRGAN_x2plus.pth
|
||||
PHOTO_AI_MODELS_DIR: /models
|
||||
PHOTO_AI_MAX_PIXELS: ${PHOTO_AI_MAX_PIXELS:-4000000}
|
||||
PHOTO_AI_DEVICE: ${PHOTO_AI_DEVICE:-auto}
|
||||
PHOTO_AI_TILE: ${PHOTO_AI_TILE:-256}
|
||||
PHOTO_AI_FACE_MODEL: ${PHOTO_AI_FACE_MODEL:-gfpgan}
|
||||
PHOTO_AI_LOAD_ALL: ${PHOTO_AI_LOAD_ALL:-0}
|
||||
PHOTO_AI_JPEG_QUALITY: ${PHOTO_AI_JPEG_QUALITY:-92}
|
||||
TZ: Europe/Moscow
|
||||
volumes:
|
||||
- photo-ai-models:/models
|
||||
ports:
|
||||
- "127.0.0.1:8081:8080"
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import sys, urllib.request; r = urllib.request.urlopen('http://127.0.0.1:8080/health', timeout=5); sys.exit(0 if r.status == 200 else 1)"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
start_period: 300s
|
||||
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
photo-ai-models:
|
||||
redis-data:
|
||||
s3-data:
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
# TODO — Инструкция по системе WhatIDo (со скриншотами)
|
||||
|
||||
Статус: `☐` todo · `☑` готово · `◐` в работе
|
||||
|
||||
Цель: папка `instructions/` с полной русскоязычной инструкцией для администратора/преподавателя,
|
||||
**каждый раздел проиллюстрирован реальными скриншотами живого стенда** (стенд поднят,
|
||||
в БД 358 записей, 151 ученик, 22 группы, 72 модуля, 35 ссылок — данные реальные, не моки).
|
||||
|
||||
## Часть 0. Подготовка
|
||||
|
||||
- [x] 0.1 Поднять стенд: `docker compose ps` — app на `http://localhost:3003`
|
||||
- [x] 0.2 Проверить доступность `GET /` → 200, `GET /api/public-settings` → `system_name=KIBERone`
|
||||
- [x] 0.3 Получить учётку админа (`admin` / `ADMIN_PASSWORD`), `POST /api/auth/login` → токен
|
||||
- [x] 0.4 Инвентаризация всех страниц `public/*.html` (меню, ID, русские подписи, роли)
|
||||
- [x] 0.5 Снять инвентарь данных в БД, чтобы скриншоты были непустыми
|
||||
- [x] 0.6 Создать папку `instructions/` и этот TODO
|
||||
|
||||
## Часть 1. Публичная часть — то, что видит ученик
|
||||
|
||||
- [x] 1.1 `index.html` — форма «Что мы узнали на занятии»: общий вид, шапка, футер
|
||||
- [x] 1.2 Блок «Фото»: превью, кнопка «Камера», модалка камеры (снять/отмена)
|
||||
- [x] 1.3 Блок «Файлы проекта»: выбор, вставка из буфера, список с размерами, очистка
|
||||
- [x] 1.4 Блок полей: ФИО, Группа, Тема модуля, «Что сделал» (+ автодополнение)
|
||||
- [x] 1.5 Кнопка «Отправить» + панель успеха `#sentPanel` с таймером антиспама
|
||||
- [x] 1.6 Ошибки/повторная отправка, тосты
|
||||
|
||||
## Часть 2. Вход и оболочка админки
|
||||
|
||||
- [x] 2.1 `login.html` — форма входа (логин, пароль, honeypot, ошибки)
|
||||
- [x] 2.2 `admin.js` — сайдбар: логотип, user-box, две группы меню, версия
|
||||
- [x] 2.3 Выпадающий список уведомлений в сайдбаре + бейдж
|
||||
|
||||
## Часть 3. Разделы по порядку (основное → администрирование)
|
||||
|
||||
- [x] 3.1 **Дашборд** — плитки статистики, динамика за 14 дней, последние записи, активные группы, топ учеников, быстрые действия
|
||||
- [x] 3.2 **Журнал** — фильтры, список/карточки, пагинация, карточка записи
|
||||
- [x] 3.3 Журнал — модалка «Редактирование записи» (+ `✨` ИИ-исправление, `ИИ предлагает вариант`)
|
||||
- [x] 3.4 Журнал — «Улучшение фото»: сравнение до/после, слайдеры, режимы ИИ, история версий
|
||||
- [x] 3.5 Журнал — модалка «Создать ссылку»
|
||||
- [x] 3.6 **Ученики** — список, фильтры, пагинация, кнопки действий
|
||||
- [x] 3.7 Ученики — пакетное добавление, прикрепление к группе
|
||||
- [x] 3.8 Ученики — «Данные профиля» (все поля отчёта)
|
||||
- [x] 3.9 Ученики — «Экспорт отчёта» (ZIP)
|
||||
- [x] 3.10 **Группы** — карточки, расписание, филиал/тутор
|
||||
- [x] 3.11 Группы — галерея фото группы (загрузка, обложка, порядок, правка)
|
||||
- [x] 3.12 Группы — «Архив файлов группы» (ZIP-выгрузка)
|
||||
- [x] 3.13 **Фото** — все фото, фильтры по источнику, карточки
|
||||
- [x] 3.14 **Файлы** — прикреплённые / откреплённые
|
||||
- [x] 3.15 **Ссылки** — список share-ссылок, бейджи, создание
|
||||
- [x] 3.16 **Корзина** — восстановление, «помеченные на удаление»
|
||||
- [x] 3.17 **Темы модулей** — список, модалка модуля, пакетное добавление
|
||||
- [x] 3.18 **Филиалы** — CRUD
|
||||
- [x] 3.19 **Пользователи** — таблица, модалка, мультивыбор филиалов
|
||||
- [x] 3.20 **Воркер ИИ** — статус, очередь, последние проверки, фото-задания, ошибки
|
||||
- [x] 3.21 **Аудит** — таблица действий, модалка «Детали действия» с диффом
|
||||
- [x] 3.22 **Блокировки** — таблица IP, модалка ручного бана
|
||||
- [x] 3.23 **Уведомления** — полная история, фильтр, «прочитать все», «очистить всё»
|
||||
- [x] 3.24 **Настройки** — 13 секций (система, стек, брендинг, антиспам, ссылки, футер, фото, фото-ИИ, уведомления, ИИ, бэкапы, корзина, блокировки)
|
||||
|
||||
## Часть 4. Публичные страницы по ссылкам
|
||||
|
||||
- [x] 4.1 `share.html` (`/s/<token>`) — карточки записей, фото группы, cookie-баннер, лайтбокс
|
||||
- [x] 4.2 `share.html` — защита паролем `#passwordModal`
|
||||
- [x] 4.3 `report.html` (`/r/<token>`) — публичный отчёт: hero, «Обо мне», хроника, работы, файлы, фото, контакты
|
||||
- [x] 4.4 `error.html` — страница ошибки 404
|
||||
|
||||
## Часть 5. Сборка инструкции
|
||||
|
||||
- [x] 5.1 Скриншоты → `instructions/img/` с нумерацией
|
||||
- [x] 5.2 `instructions/README.md` — оглавление, роли, вход, быстрый старт
|
||||
- [x] 5.3 Постраничные файлы инструкции `01-…` … `05-…` с вставленными картинками
|
||||
- [x] 5.4 `instructions/CHEATSHEET.md` — краткая шпаргалка + горячие клавиши/API
|
||||
- [x] 5.5 Проверка: все ссылки на изображения существуют, нет битых `.md`
|
||||
|
||||
## Итог
|
||||
|
||||
| Артефакт | Описание |
|
||||
|---|---|
|
||||
| `instructions/README.md` | Оглавление + обзор системы + порядок работы |
|
||||
| `instructions/01-public-form.md` | Публичная форма ученика (пошагово) |
|
||||
| `instructions/02-login-shell.md` | Вход, сайдбар, уведомления |
|
||||
| `instructions/03-dashboard.md` | Дашборд |
|
||||
| `instructions/04-journal.md` | Журнал: фильтры, редактирование, фото-ИИ, ссылки |
|
||||
| `instructions/05-students.md` | Ученики: профили, группы, экспорт |
|
||||
| `instructions/06-groups-photos-files.md` | Группы, Фото, Файлы |
|
||||
| `instructions/07-links-trash.md` | Ссылки, Корзина |
|
||||
| `instructions/08-admin-sections.md` | Модули, Филиалы, Пользователи, Блокировки |
|
||||
| `instructions/09-worker-audit-notifications.md` | Воркер ИИ, Аудит, Уведомления |
|
||||
| `instructions/10-settings.md` | Настройки: все 13 секций |
|
||||
| `instructions/11-share-report-pages.md` | Публичные `/s/` и `/r/` страницы |
|
||||
| `instructions/CHEATSHEET.md` | Шпаргалка |
|
||||
| `instructions/img/*.png` | Скриншоты |
|
||||
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 514 KiB |
|
After Width: | Height: | Size: 171 KiB |
|
After Width: | Height: | Size: 514 KiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 154 KiB |
|
After Width: | Height: | Size: 135 KiB |
|
After Width: | Height: | Size: 260 KiB |
|
After Width: | Height: | Size: 164 KiB |
|
After Width: | Height: | Size: 156 KiB |
|
After Width: | Height: | Size: 256 KiB |
|
After Width: | Height: | Size: 141 KiB |
|
After Width: | Height: | Size: 158 KiB |
|
After Width: | Height: | Size: 159 KiB |
|
After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 681 KiB |
|
After Width: | Height: | Size: 508 KiB |
|
After Width: | Height: | Size: 947 KiB |
|
After Width: | Height: | Size: 318 KiB |
|
After Width: | Height: | Size: 261 KiB |
|
After Width: | Height: | Size: 334 KiB |
|
After Width: | Height: | Size: 200 KiB |
|
After Width: | Height: | Size: 118 KiB |
|
After Width: | Height: | Size: 99 KiB |
|
After Width: | Height: | Size: 142 KiB |
|
After Width: | Height: | Size: 162 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 77 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 448 KiB |
|
After Width: | Height: | Size: 450 KiB |
|
After Width: | Height: | Size: 192 KiB |
|
After Width: | Height: | Size: 167 KiB |
|
After Width: | Height: | Size: 158 KiB |
|
After Width: | Height: | Size: 183 KiB |
|
After Width: | Height: | Size: 197 KiB |
|
After Width: | Height: | Size: 146 KiB |
|
After Width: | Height: | Size: 111 KiB |
|
After Width: | Height: | Size: 592 KiB |
|
After Width: | Height: | Size: 647 KiB |
|
After Width: | Height: | Size: 372 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 274 KiB |
|
After Width: | Height: | Size: 123 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 124 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 20 KiB |
@@ -6,10 +6,20 @@
|
||||
"start": "node server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"cors": "^2.8.5",
|
||||
"@aws-sdk/client-s3": "^3.1141.0",
|
||||
"bcrypt": "^5.1.1",
|
||||
"express": "^4.21.0",
|
||||
"express-rate-limit": "^8.7.0",
|
||||
"heic-convert": "^2.1.0",
|
||||
"helmet": "^8.3.0",
|
||||
"lucide": "^1.44.0",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"pg": "^8.13.0",
|
||||
"redis": "^5.12.1",
|
||||
"sharp": "^0.34.5",
|
||||
"tar": "^7.4.3"
|
||||
},
|
||||
"allowScripts": {
|
||||
"bcrypt@5.1.1": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
FROM python:3.10-slim
|
||||
|
||||
ARG TORCH_VARIANT=cpu
|
||||
ARG TORCH_INDEX=https://download.pytorch.org/whl/${TORCH_VARIANT}
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends libgl1 libglib2.0-0 && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN pip install --no-cache-dir "typing-extensions==4.12.2" "numpy==2.2.6"
|
||||
|
||||
RUN pip install --no-cache-dir torch torchvision --index-url ${TORCH_INDEX}
|
||||
|
||||
RUN pip install --no-cache-dir --no-deps basicsr==1.4.2 realesrgan==0.3.0 gfpgan==1.3.8 facexlib==0.3.0 && \
|
||||
pip install --no-cache-dir "numpy==2.2.6" "opencv-python-headless==5.0.0.93" addict future lmdb Pillow pyyaml \
|
||||
requests scikit-image scipy tqdm filterpy numba fastapi "uvicorn[standard]" python-multipart
|
||||
|
||||
RUN BASICSR_DEG=$(python -c "import basicsr; import os; print(os.path.join(os.path.dirname(basicsr.__file__), 'data', 'degradations.py'))" 2>/dev/null) || \
|
||||
BASICSR_DEG=$(find /usr/local/lib/python3.10 -path "*/basicsr/data/degradations.py" 2>/dev/null | head -1) && \
|
||||
if [ -n "$BASICSR_DEG" ]; then \
|
||||
sed -i 's/from torchvision.transforms.functional_tensor/from torchvision.transforms.functional/g' "$BASICSR_DEG" && \
|
||||
echo "basicsr patch applied to $BASICSR_DEG"; \
|
||||
else \
|
||||
echo "basicsr degradations.py not found, skipping patch"; \
|
||||
fi
|
||||
|
||||
COPY app.py ./
|
||||
COPY fetch-weights.py ./
|
||||
COPY vendor/ ./vendor/
|
||||
|
||||
ENV PHOTO_AI_MODELS_DIR=/models
|
||||
ENV MODEL_PATH=/models/RealESRGAN_x2plus.pth
|
||||
ENV PHOTO_AI_SEED_DIR=/opt/photo-ai-seed
|
||||
|
||||
ARG PHOTO_AI_PREFETCH=codeformer
|
||||
|
||||
RUN --mount=type=cache,target=/var/cache/photo-ai-weights,sharing=locked \
|
||||
mkdir -p "$PHOTO_AI_SEED_DIR" && \
|
||||
if [ -n "$PHOTO_AI_PREFETCH" ] && [ "$PHOTO_AI_PREFETCH" != "none" ]; then \
|
||||
python fetch-weights.py "$PHOTO_AI_SEED_DIR" /var/cache/photo-ai-weights || echo "предзагрузка весов не удалась, сервис скачает их при первом запросе"; \
|
||||
else \
|
||||
echo "предзагрузка весов отключена (PHOTO_AI_PREFETCH=$PHOTO_AI_PREFETCH)"; \
|
||||
fi
|
||||
|
||||
VOLUME /models
|
||||
|
||||
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8080"]
|
||||
@@ -0,0 +1,926 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import importlib.util
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import urllib.request
|
||||
from collections import OrderedDict
|
||||
from contextlib import contextmanager
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
import torch
|
||||
from basicsr.archs.rrdbnet_arch import RRDBNet
|
||||
from basicsr.archs.srvgg_arch import SRVGGNetCompact
|
||||
from fastapi import FastAPI, File, Form, Request, UploadFile
|
||||
from fastapi.responses import JSONResponse, Response
|
||||
from realesrgan import RealESRGANer
|
||||
|
||||
VENDOR_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'vendor')
|
||||
if os.path.isdir(VENDOR_DIR) and VENDOR_DIR not in sys.path:
|
||||
sys.path.append(VENDOR_DIR)
|
||||
|
||||
log = logging.getLogger('photo-ai')
|
||||
if not log.handlers:
|
||||
handler = logging.StreamHandler(sys.stderr)
|
||||
handler.setFormatter(logging.Formatter('%(asctime)s %(levelname)s %(name)s %(message)s'))
|
||||
log.addHandler(handler)
|
||||
log.setLevel(logging.INFO)
|
||||
log.propagate = False
|
||||
|
||||
FACE_MODES = ('off', 'face', 'all')
|
||||
DEFAULT_MODEL = 'x2plus'
|
||||
DEFAULT_STRENGTH = 0.7
|
||||
MIN_TILE = 64
|
||||
MIN_FACE_SIDE = 320
|
||||
POOL_LIMIT = 2
|
||||
WARMUP_SIZE = 64
|
||||
RETRY_AFTER_SEC = 5
|
||||
|
||||
|
||||
def env_text(name, default=''):
|
||||
value = os.environ.get(name)
|
||||
if value is None:
|
||||
return default
|
||||
value = value.strip()
|
||||
return value or default
|
||||
|
||||
|
||||
def env_flag(name, default):
|
||||
value = env_text(name).lower()
|
||||
if not value:
|
||||
return default
|
||||
return value not in ('0', 'false', 'no', 'off')
|
||||
|
||||
|
||||
def env_int(name, default):
|
||||
value = env_text(name)
|
||||
if not value:
|
||||
return default
|
||||
try:
|
||||
return int(value)
|
||||
except ValueError:
|
||||
log.warning('%s=%r не число, беру %s', name, value, default)
|
||||
return default
|
||||
|
||||
|
||||
def clamp(value, low, high):
|
||||
return max(low, min(high, value))
|
||||
|
||||
|
||||
DEVICE_PREF = env_text('PHOTO_AI_DEVICE', 'auto').lower()
|
||||
if DEVICE_PREF not in ('auto', 'cuda', 'cpu', 'mps'):
|
||||
log.warning('PHOTO_AI_DEVICE=%r неизвестно, беру auto', DEVICE_PREF)
|
||||
DEVICE_PREF = 'auto'
|
||||
MODELS_DIR = env_text('PHOTO_AI_MODELS_DIR', '/models')
|
||||
WEIGHTS_DIR = os.path.join(MODELS_DIR, 'weights')
|
||||
SEED_DIR = env_text('PHOTO_AI_SEED_DIR', '/opt/photo-ai-seed')
|
||||
LEGACY_MODEL_PATH = env_text('MODEL_PATH')
|
||||
MAX_PIXELS = max(env_int('PHOTO_AI_MAX_PIXELS', env_int('MAX_INPUT_PIXELS', 4000000)), 1)
|
||||
BASE_TILE = max(env_int('PHOTO_AI_TILE', 256), 0)
|
||||
TILE_PAD = 10
|
||||
PRE_PAD = 0
|
||||
LOAD_ALL = env_flag('PHOTO_AI_LOAD_ALL', False)
|
||||
WARMUP = env_flag('PHOTO_AI_WARMUP', True)
|
||||
DEFAULT_FACE_MODEL = env_text('PHOTO_AI_FACE_MODEL', 'gfpgan').lower()
|
||||
DEFAULT_JPEG_QUALITY = clamp(env_int('PHOTO_AI_JPEG_QUALITY', 92), 70, 100)
|
||||
|
||||
OUTPUT_FORMATS = {
|
||||
'jpg': ('jpg', 'image/jpeg', [int(cv2.IMWRITE_JPEG_QUALITY)]),
|
||||
'png': ('png', 'image/png', [int(cv2.IMWRITE_PNG_COMPRESSION), 3]),
|
||||
'webp': ('webp', 'image/webp', [int(cv2.IMWRITE_WEBP_QUALITY), 92]),
|
||||
}
|
||||
|
||||
|
||||
def rrdb_x2():
|
||||
return RRDBNet(num_in_ch=3, num_out_ch=3, scale=2, num_feat=64, num_block=23, num_grow_ch=32)
|
||||
|
||||
|
||||
def srvgg(num_conv):
|
||||
return lambda: SRVGGNetCompact(num_in_ch=3, num_out_ch=3, num_feat=64, num_conv=num_conv,
|
||||
upscale=4, act_type='prelu')
|
||||
|
||||
|
||||
MODEL_REGISTRY = OrderedDict([
|
||||
('x2plus', {
|
||||
'scale': 2,
|
||||
'arch': rrdb_x2,
|
||||
'file': 'RealESRGAN_x2plus.pth',
|
||||
'url': 'https://github.com/xinntao/Real-ESRGAN/releases/download/v0.2.1/RealESRGAN_x2plus.pth',
|
||||
'min_bytes': 60_000_000,
|
||||
'alias': True,
|
||||
'denoise': False,
|
||||
'title': 'Универсальный апскейл x2, дефолт',
|
||||
}),
|
||||
('general-x4v3', {
|
||||
'scale': 4,
|
||||
'arch': srvgg(32),
|
||||
'file': 'realesr-general-x4v3.pth',
|
||||
'url': 'https://github.com/xinntao/Real-ESRGAN/releases/download/v0.2.5.0/realesr-general-x4v3.pth',
|
||||
'min_bytes': 2_000_000,
|
||||
'alias': False,
|
||||
'denoise': True,
|
||||
'title': 'Быстрый апскейл x4 с денойзом',
|
||||
'dni': {
|
||||
'file': 'realesr-general-wdn-x4v3.pth',
|
||||
'url': 'https://github.com/xinntao/Real-ESRGAN/releases/download/v0.2.5.0/realesr-general-wdn-x4v3.pth',
|
||||
'min_bytes': 2_000_000,
|
||||
'weight': 0.5,
|
||||
},
|
||||
}),
|
||||
('animevideo-v3', {
|
||||
'scale': 4,
|
||||
'arch': srvgg(16),
|
||||
'file': 'realesr-animevideov3.pth',
|
||||
'url': 'https://github.com/xinntao/Real-ESRGAN/releases/download/v0.2.5.0/realesr-animevideov3.pth',
|
||||
'min_bytes': 1_000_000,
|
||||
'alias': False,
|
||||
'denoise': False,
|
||||
'title': 'Быстрый апскейл x4 для скриншотов и иллюстраций',
|
||||
}),
|
||||
])
|
||||
|
||||
FACE_REGISTRY = OrderedDict([
|
||||
('gfpgan', {
|
||||
'module': 'gfpgan',
|
||||
'file': 'GFPGANv1.4.pth',
|
||||
'url': 'https://github.com/TencentARC/GFPGAN/releases/download/v1.3.0/GFPGANv1.4.pth',
|
||||
'min_bytes': 300_000_000,
|
||||
'arch': 'clean',
|
||||
'channel_multiplier': 2,
|
||||
'strength': False,
|
||||
'title': 'GFPGAN v1.4, восстановление лиц, дефолт',
|
||||
}),
|
||||
('codeformer', {
|
||||
'module': 'codeformer',
|
||||
'file': 'codeformer.pth',
|
||||
'url': 'https://github.com/sczhou/CodeFormer/releases/download/v0.1.0/codeformer.pth',
|
||||
'min_bytes': 300_000_000,
|
||||
'strength': True,
|
||||
'title': 'CodeFormer, восстановление лиц с регулируемой силой',
|
||||
}),
|
||||
])
|
||||
|
||||
FACEXLIB_WEIGHTS = OrderedDict([
|
||||
('detection_Resnet50_Final.pth', {
|
||||
'url': 'https://github.com/xinntao/facexlib/releases/download/v0.1.0/detection_Resnet50_Final.pth',
|
||||
'min_bytes': 90_000_000,
|
||||
}),
|
||||
('parsing_parsenet.pth', {
|
||||
'url': 'https://github.com/xinntao/facexlib/releases/download/v0.2.2/parsing_parsenet.pth',
|
||||
'min_bytes': 70_000_000,
|
||||
}),
|
||||
])
|
||||
|
||||
|
||||
def cuda_ready():
|
||||
try:
|
||||
return bool(torch.cuda.is_available()) and torch.cuda.device_count() > 0
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def mps_ready():
|
||||
try:
|
||||
return bool(torch.backends.mps.is_available())
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def cpu_device_name():
|
||||
return 'CPU (' + (platform.machine() or 'unknown') + ')'
|
||||
|
||||
|
||||
def pick_device():
|
||||
if DEVICE_PREF == 'cpu':
|
||||
return 'cpu', False, cpu_device_name()
|
||||
if DEVICE_PREF == 'mps':
|
||||
if mps_ready():
|
||||
return 'mps', False, 'Apple Silicon (MPS)'
|
||||
log.warning('PHOTO_AI_DEVICE=mps, но MPS недоступен — работаю на CPU')
|
||||
return 'cpu', False, cpu_device_name()
|
||||
if DEVICE_PREF == 'cuda':
|
||||
if not cuda_ready():
|
||||
log.warning('PHOTO_AI_DEVICE=cuda, но CUDA недоступна — работаю на CPU')
|
||||
return 'cpu', False, cpu_device_name()
|
||||
return 'cuda:0', True, torch.cuda.get_device_name(0)
|
||||
if cuda_ready():
|
||||
return 'cuda:0', True, torch.cuda.get_device_name(0)
|
||||
if mps_ready():
|
||||
return 'mps', False, 'Apple Silicon (MPS)'
|
||||
return 'cpu', False, cpu_device_name()
|
||||
|
||||
|
||||
state = {'device': 'cpu', 'half': False, 'device_name': 'CPU', 'tile': BASE_TILE, 'degraded': False}
|
||||
state['device'], state['half'], state['device_name'] = pick_device()
|
||||
INFER_LOCK = threading.RLock()
|
||||
|
||||
|
||||
class EnhanceError(Exception):
|
||||
def __init__(self, message, status_code=500):
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
self.status_code = status_code
|
||||
|
||||
|
||||
class ModelNotReady(EnhanceError):
|
||||
def __init__(self, label):
|
||||
super().__init__('модель %s ещё загружается, повторите позже' % label, 503)
|
||||
self.label = label
|
||||
|
||||
|
||||
class TileOOM(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def error_response(status_code, message, headers=None):
|
||||
return JSONResponse(status_code=status_code, content={'ok': False, 'error': message},
|
||||
headers=headers)
|
||||
|
||||
|
||||
def is_oom(err):
|
||||
if isinstance(err, (torch.cuda.OutOfMemoryError, TileOOM)):
|
||||
return True
|
||||
text = str(err).lower()
|
||||
return any(mark in text for mark in ('out of memory', 'not enough memory',
|
||||
'alloc_cpu', "can't allocate memory"))
|
||||
|
||||
|
||||
def guard_forward(model):
|
||||
if getattr(model, '_photo_ai_guarded', False):
|
||||
return model
|
||||
forward = model.forward
|
||||
|
||||
def guarded(*args, **kwargs):
|
||||
try:
|
||||
return forward(*args, **kwargs)
|
||||
except RuntimeError as err:
|
||||
if not is_oom(err):
|
||||
raise
|
||||
raise TileOOM(str(err)) from err
|
||||
|
||||
model.forward = guarded
|
||||
model._photo_ai_guarded = True
|
||||
return model
|
||||
|
||||
|
||||
def tile_ladder(base):
|
||||
if base <= 0:
|
||||
return [base]
|
||||
ladder = []
|
||||
for tile in (base, base // 2, base // 4):
|
||||
if tile >= MIN_TILE and (not ladder or ladder[-1] != tile):
|
||||
ladder.append(tile)
|
||||
return ladder or [base]
|
||||
|
||||
|
||||
def file_ok(path, min_bytes):
|
||||
return os.path.isfile(path) and os.path.getsize(path) >= min_bytes
|
||||
|
||||
|
||||
def link_or_copy(src, dst):
|
||||
os.makedirs(os.path.dirname(dst), exist_ok=True)
|
||||
try:
|
||||
os.link(src, dst)
|
||||
except OSError:
|
||||
shutil.copyfile(src, dst)
|
||||
|
||||
|
||||
def download_weight(url, target, min_bytes):
|
||||
os.makedirs(os.path.dirname(target), exist_ok=True)
|
||||
tmp = target + '.tmp'
|
||||
log.info('качаю веса %s -> %s', url, target)
|
||||
try:
|
||||
urllib.request.urlretrieve(url, tmp)
|
||||
except Exception as err:
|
||||
if os.path.exists(tmp):
|
||||
os.unlink(tmp)
|
||||
raise EnhanceError('не удалось скачать веса %s: %s' % (os.path.basename(target), err), 500) from err
|
||||
size = os.path.getsize(tmp) if os.path.isfile(tmp) else 0
|
||||
if size < min_bytes:
|
||||
os.unlink(tmp)
|
||||
raise EnhanceError('веса %s повреждены: %d байт, минимум %d'
|
||||
% (os.path.basename(target), size, min_bytes), 500)
|
||||
os.replace(tmp, target)
|
||||
|
||||
|
||||
def ensure_weight(name, spec, alias=''):
|
||||
target = os.path.join(WEIGHTS_DIR, name)
|
||||
if file_ok(target, spec['min_bytes']):
|
||||
return target
|
||||
if os.path.isfile(target):
|
||||
log.warning('удаляю битые веса %s (%d байт)', target, os.path.getsize(target))
|
||||
os.unlink(target)
|
||||
if alias and file_ok(alias, spec['min_bytes']):
|
||||
link_or_copy(alias, target)
|
||||
log.info('веса %s взяты из существующего файла %s', name, alias)
|
||||
return target
|
||||
seed = os.path.join(SEED_DIR, name)
|
||||
if file_ok(seed, spec['min_bytes']):
|
||||
link_or_copy(seed, target)
|
||||
log.info('веса %s взяты из слоя образа %s', name, seed)
|
||||
return target
|
||||
download_weight(spec['url'], target, spec['min_bytes'])
|
||||
return target
|
||||
|
||||
|
||||
def seed_weights():
|
||||
names = [spec['file'] for spec in MODEL_REGISTRY.values()]
|
||||
names += [spec['file'] for spec in FACE_REGISTRY.values()]
|
||||
names += [spec['dni']['file'] for spec in MODEL_REGISTRY.values() if spec.get('dni')]
|
||||
names += list(FACEXLIB_WEIGHTS)
|
||||
seeded = 0
|
||||
for name in names:
|
||||
source = os.path.join(SEED_DIR, name)
|
||||
spec = weight_spec(name)
|
||||
if spec is None or not file_ok(source, spec['min_bytes']):
|
||||
continue
|
||||
try:
|
||||
ensure_weight(name, spec)
|
||||
seeded += 1
|
||||
except EnhanceError as err:
|
||||
log.warning('не удалось перенести веса %s из образа: %s', name, err)
|
||||
if seeded:
|
||||
log.info('перенесено весов из слоя образа: %d', seeded)
|
||||
return seeded
|
||||
|
||||
|
||||
def weight_spec(name):
|
||||
for spec in MODEL_REGISTRY.values():
|
||||
if spec['file'] == name:
|
||||
return spec
|
||||
if spec.get('dni') and spec['dni']['file'] == name:
|
||||
return spec['dni']
|
||||
for spec in FACE_REGISTRY.values():
|
||||
if spec['file'] == name:
|
||||
return spec
|
||||
return FACEXLIB_WEIGHTS.get(name)
|
||||
|
||||
|
||||
def ensure_facexlib_weights():
|
||||
for name, spec in FACEXLIB_WEIGHTS.items():
|
||||
ensure_weight(name, spec)
|
||||
|
||||
|
||||
def upsamplers_of(obj):
|
||||
found = []
|
||||
if isinstance(obj, RealESRGANer):
|
||||
found.append(obj)
|
||||
inner = getattr(obj, 'upsampler', None)
|
||||
if isinstance(inner, RealESRGANer):
|
||||
found.append(inner)
|
||||
bg = getattr(obj, 'bg_upsampler', None)
|
||||
if isinstance(bg, RealESRGANer):
|
||||
found.append(bg)
|
||||
elif isinstance(getattr(bg, 'upsampler', None), RealESRGANer):
|
||||
found.append(bg.upsampler)
|
||||
return found
|
||||
|
||||
|
||||
def apply_tile(obj, tile):
|
||||
for upsampler in upsamplers_of(obj):
|
||||
upsampler.tile_size = tile
|
||||
|
||||
|
||||
def warm_up(runner):
|
||||
if not WARMUP:
|
||||
return
|
||||
noise = np.random.default_rng(0).integers(0, 256, (WARMUP_SIZE, WARMUP_SIZE, 3), dtype=np.uint8)
|
||||
with INFER_LOCK:
|
||||
runner.enhance(noise)
|
||||
|
||||
|
||||
class Upscaler:
|
||||
def __init__(self, upsampler, outscale):
|
||||
self.upsampler = upsampler
|
||||
self.outscale = outscale
|
||||
|
||||
def enhance(self, img, outscale=None):
|
||||
output, _mode = self.upsampler.enhance(img, outscale=outscale or self.outscale)
|
||||
return output
|
||||
|
||||
|
||||
class FaceRunner:
|
||||
def __init__(self, label, outscale, bg, restore):
|
||||
self.label = label
|
||||
self.outscale = outscale
|
||||
self.bg_upsampler = bg
|
||||
self.restore = restore
|
||||
|
||||
def enhance(self, img, strength=DEFAULT_STRENGTH):
|
||||
output, faces_found = self.restore(img, strength)
|
||||
return output, faces_found
|
||||
|
||||
|
||||
class ModelPool:
|
||||
def __init__(self, limit=POOL_LIMIT):
|
||||
self.lock = threading.Lock()
|
||||
self.entries = OrderedDict()
|
||||
self.loading = OrderedDict()
|
||||
self.in_use = {}
|
||||
self.limit = limit
|
||||
|
||||
def get(self, key, label, factory):
|
||||
with self.lock:
|
||||
entry = self.entries.get(key)
|
||||
if entry is not None:
|
||||
self.entries.move_to_end(key)
|
||||
self.in_use[key] = self.in_use.get(key, 0) + 1
|
||||
return entry['obj']
|
||||
if key in self.loading:
|
||||
raise ModelNotReady(label)
|
||||
self.loading[key] = label
|
||||
try:
|
||||
obj = factory()
|
||||
warm_up(obj)
|
||||
except BaseException:
|
||||
with self.lock:
|
||||
self.loading.pop(key, None)
|
||||
raise
|
||||
with self.lock:
|
||||
self.loading.pop(key, None)
|
||||
self.entries[key] = {'obj': obj, 'label': label}
|
||||
self.in_use[key] = self.in_use.get(key, 0) + 1
|
||||
apply_tile(obj, state['tile'])
|
||||
self.evict_locked()
|
||||
return obj
|
||||
|
||||
def release(self, key):
|
||||
with self.lock:
|
||||
if self.in_use.get(key):
|
||||
self.in_use[key] -= 1
|
||||
|
||||
def evict_locked(self):
|
||||
while len(self.entries) > self.limit:
|
||||
for key in list(self.entries):
|
||||
if not self.in_use.get(key):
|
||||
self.entries.pop(key, None)
|
||||
self.in_use.pop(key, None)
|
||||
log.info('выгружаю из кэша модель %s (LRU, лимит %d)', key, self.limit)
|
||||
break
|
||||
else:
|
||||
break
|
||||
|
||||
@contextmanager
|
||||
def acquire(self, key, label, factory):
|
||||
obj = self.get(key, label, factory)
|
||||
try:
|
||||
yield obj
|
||||
finally:
|
||||
self.release(key)
|
||||
|
||||
def contains(self, key):
|
||||
with self.lock:
|
||||
return key in self.entries
|
||||
|
||||
def clear(self):
|
||||
with self.lock:
|
||||
self.entries.clear()
|
||||
self.in_use.clear()
|
||||
|
||||
def set_tile(self, tile):
|
||||
with self.lock:
|
||||
for entry in self.entries.values():
|
||||
apply_tile(entry['obj'], tile)
|
||||
|
||||
def loaded_labels(self):
|
||||
with self.lock:
|
||||
labels = []
|
||||
for entry in self.entries.values():
|
||||
if entry['label'] not in labels:
|
||||
labels.append(entry['label'])
|
||||
return labels
|
||||
|
||||
def loading_labels(self):
|
||||
with self.lock:
|
||||
return list(self.loading.values())
|
||||
|
||||
|
||||
pool = ModelPool()
|
||||
|
||||
|
||||
def build_esrgan(name, denoise):
|
||||
spec = MODEL_REGISTRY[name]
|
||||
alias = LEGACY_MODEL_PATH if spec['alias'] else ''
|
||||
path = ensure_weight(spec['file'], spec, alias)
|
||||
model_path = path
|
||||
dni_weight = None
|
||||
if denoise and spec.get('dni'):
|
||||
dni_spec = spec['dni']
|
||||
dni_path = ensure_weight(dni_spec['file'], dni_spec)
|
||||
weight = float(dni_spec.get('weight', 0.5))
|
||||
model_path = [path, dni_path]
|
||||
dni_weight = (1.0 - weight, weight)
|
||||
upsampler = RealESRGANer(
|
||||
scale=spec['scale'],
|
||||
model_path=model_path,
|
||||
dni_weight=dni_weight,
|
||||
model=guard_forward(spec['arch']()),
|
||||
tile=state['tile'],
|
||||
tile_pad=TILE_PAD,
|
||||
pre_pad=PRE_PAD,
|
||||
half=state['half'],
|
||||
device=torch.device(state['device']),
|
||||
)
|
||||
return Upscaler(upsampler, spec['scale'])
|
||||
|
||||
|
||||
def face_helper(outscale):
|
||||
from facexlib.utils.face_restoration_helper import FaceRestoreHelper
|
||||
return FaceRestoreHelper(
|
||||
upscale_factor=outscale,
|
||||
face_size=512,
|
||||
crop_ratio=(1, 1),
|
||||
det_model='retinaface_resnet50',
|
||||
save_ext='png',
|
||||
use_parse=True,
|
||||
device=torch.device(state['device']),
|
||||
model_rootpath=WEIGHTS_DIR,
|
||||
)
|
||||
|
||||
|
||||
def link_default_facexlib_dir():
|
||||
default_dir = os.path.abspath('gfpgan/weights')
|
||||
try:
|
||||
if os.path.realpath(default_dir) == os.path.realpath(WEIGHTS_DIR):
|
||||
return
|
||||
if os.path.isdir(default_dir) and not os.path.islink(default_dir):
|
||||
shutil.rmtree(default_dir, ignore_errors=True)
|
||||
os.makedirs(os.path.dirname(default_dir), exist_ok=True)
|
||||
if not os.path.lexists(default_dir):
|
||||
os.symlink(WEIGHTS_DIR, default_dir)
|
||||
log.info('каталог facexlib %s смотрит в том моделей', default_dir)
|
||||
except OSError as err:
|
||||
log.warning('не удалось направить %s в %s: %s', default_dir, WEIGHTS_DIR, err)
|
||||
|
||||
|
||||
def build_face_gfpgan(spec, outscale, bg):
|
||||
from gfpgan import GFPGANer
|
||||
link_default_facexlib_dir()
|
||||
ensure_facexlib_weights()
|
||||
path = ensure_weight(spec['file'], spec)
|
||||
restorer = GFPGANer(
|
||||
model_path=path,
|
||||
upscale=outscale,
|
||||
arch=spec['arch'],
|
||||
channel_multiplier=spec['channel_multiplier'],
|
||||
bg_upsampler=bg.upsampler,
|
||||
device=torch.device(state['device']),
|
||||
)
|
||||
guard_forward(restorer.gfpgan)
|
||||
|
||||
def restore(img, strength):
|
||||
cropped, _restored, output = restorer.enhance(img, has_aligned=False, only_center_face=False,
|
||||
paste_back=True)
|
||||
if output is None:
|
||||
output = bg.enhance(img, outscale)
|
||||
return output, len(cropped)
|
||||
|
||||
return FaceRunner('gfpgan', outscale, bg, restore)
|
||||
|
||||
|
||||
def build_face_codeformer(spec, outscale, bg):
|
||||
from codeformer import CodeFormer
|
||||
from gfpgan.utils import img2tensor, tensor2img
|
||||
from torchvision.transforms.functional import normalize
|
||||
ensure_facexlib_weights()
|
||||
path = ensure_weight(spec['file'], spec)
|
||||
device = torch.device(state['device'])
|
||||
net = CodeFormer(dim_embd=512, codebook_size=1024, n_head=8, n_layers=9,
|
||||
connect_list=['32', '64', '128', '256'])
|
||||
checkpoint = torch.load(path, map_location='cpu', weights_only=False)
|
||||
state_dict = checkpoint.get('params_ema', checkpoint) if isinstance(checkpoint, dict) else checkpoint
|
||||
net.load_state_dict(state_dict)
|
||||
net.eval()
|
||||
net.to(device)
|
||||
guard_forward(net)
|
||||
helper = face_helper(outscale)
|
||||
|
||||
def restore(img, strength):
|
||||
helper.clean_all()
|
||||
helper.read_image(img)
|
||||
helper.get_face_landmarks_5(only_center_face=False, eye_dist_threshold=5)
|
||||
helper.align_warp_face()
|
||||
for cropped in helper.cropped_faces:
|
||||
tensor = img2tensor(cropped / 255., bgr2rgb=True, float32=True)
|
||||
normalize(tensor, (0.5, 0.5, 0.5), (0.5, 0.5, 0.5), inplace=True)
|
||||
tensor = tensor.unsqueeze(0).to(device)
|
||||
with torch.no_grad():
|
||||
output = net(tensor, w=clamp(float(strength), 0.0, 1.0))[0]
|
||||
restored = tensor2img(output.squeeze(0), rgb2bgr=True, min_max=(-1, 1))
|
||||
helper.add_restored_face(restored.astype('uint8'))
|
||||
bg_img = bg.enhance(img, outscale)
|
||||
helper.get_inverse_affine(None)
|
||||
return helper.paste_faces_to_input_image(upsample_img=bg_img), len(helper.cropped_faces)
|
||||
|
||||
return FaceRunner('codeformer', outscale, bg, restore)
|
||||
|
||||
|
||||
FACE_BUILDERS = {'gfpgan': build_face_gfpgan, 'codeformer': build_face_codeformer}
|
||||
|
||||
|
||||
def face_installed(name):
|
||||
module = FACE_REGISTRY[name]['module']
|
||||
try:
|
||||
return importlib.util.find_spec(module) is not None
|
||||
except (ImportError, ValueError, AttributeError):
|
||||
return False
|
||||
|
||||
|
||||
def available_face_models():
|
||||
return [name for name in FACE_REGISTRY if face_installed(name)]
|
||||
|
||||
|
||||
def build_face(name, outscale, bg):
|
||||
spec = FACE_REGISTRY[name]
|
||||
return FACE_BUILDERS[name](spec, outscale, bg)
|
||||
|
||||
|
||||
def process_image(img, outscale, model_name, face, face_name, strength):
|
||||
denoise = face == 'all' and bool(MODEL_REGISTRY[model_name].get('denoise'))
|
||||
if face == 'off':
|
||||
suffix = ':wdn' if denoise else ''
|
||||
with pool.acquire('esrgan:' + model_name + suffix, model_name,
|
||||
lambda: build_esrgan(model_name, denoise)) as runner:
|
||||
return runner.enhance(img, outscale), 0
|
||||
key = 'face:%s@%d' % (face_name, outscale)
|
||||
|
||||
def factory():
|
||||
return build_face(face_name, outscale, build_esrgan(model_name, denoise))
|
||||
|
||||
with pool.acquire(key, face_name, factory) as runner:
|
||||
return runner.enhance(img, strength)
|
||||
|
||||
|
||||
def degrade_to_cpu(warnings):
|
||||
if state['degraded']:
|
||||
return
|
||||
log.warning('устройство %s не справилось, переключаюсь на CPU', state['device'])
|
||||
warnings.append('не хватило памяти на %s, обработка переведена на CPU' % state['device'])
|
||||
pool.clear()
|
||||
state['device'] = 'cpu'
|
||||
state['half'] = False
|
||||
state['device_name'] = cpu_device_name()
|
||||
state['degraded'] = True
|
||||
state['tile'] = BASE_TILE
|
||||
|
||||
|
||||
def run_guarded(img, outscale, model_name, face, face_name, strength, warnings):
|
||||
with INFER_LOCK:
|
||||
start_tile = state['tile']
|
||||
try:
|
||||
for tile in tile_ladder(start_tile):
|
||||
state['tile'] = tile
|
||||
pool.set_tile(tile)
|
||||
try:
|
||||
return process_image(img, outscale, model_name, face, face_name, strength)
|
||||
except (RuntimeError, TileOOM) as err:
|
||||
if not is_oom(err):
|
||||
raise EnhanceError('ошибка модели: %s' % err, 500) from err
|
||||
log.warning('нехватка памяти при tile=%s: %s', tile, err)
|
||||
warnings.append('не хватило памяти при tile=%d' % tile)
|
||||
if not state['device'].startswith('cpu'):
|
||||
degrade_to_cpu(warnings)
|
||||
try:
|
||||
return process_image(img, outscale, model_name, face, face_name, strength)
|
||||
except (RuntimeError, TileOOM) as err:
|
||||
if is_oom(err):
|
||||
raise EnhanceError('не хватило памяти даже на CPU: %s' % err, 500) from err
|
||||
raise EnhanceError('ошибка модели на CPU: %s' % err, 500) from err
|
||||
raise EnhanceError('не хватило памяти даже при tile=%d: пересмотрите PHOTO_AI_TILE или PHOTO_AI_MAX_PIXELS'
|
||||
% state['tile'], 500)
|
||||
finally:
|
||||
state['tile'] = start_tile
|
||||
pool.set_tile(start_tile)
|
||||
|
||||
|
||||
def encode_image(out, fmt, quality):
|
||||
ext, media_type, params = fmt
|
||||
if ext == 'jpg':
|
||||
params = params + [quality]
|
||||
ok, encoded = cv2.imencode('.' + ext, out, params)
|
||||
if not ok:
|
||||
raise EnhanceError('не удалось закодировать результат как %s' % ext, 500)
|
||||
return encoded.tobytes(), media_type
|
||||
|
||||
|
||||
def output_format(filename):
|
||||
name = (filename or '').rsplit('/', 1)[-1]
|
||||
ext = name.rsplit('.', 1)[-1].lower() if '.' in name else ''
|
||||
if ext == 'jpeg':
|
||||
ext = 'jpg'
|
||||
return OUTPUT_FORMATS.get(ext, OUTPUT_FORMATS['jpg'])
|
||||
|
||||
|
||||
def wants_json(request):
|
||||
return 'application/json' in (request.headers.get('accept') or '').lower()
|
||||
|
||||
|
||||
def driver_version():
|
||||
try:
|
||||
with open('/proc/driver/nvidia/version', 'r') as handle:
|
||||
text = handle.read()
|
||||
except OSError:
|
||||
return None
|
||||
match = re.search(r'\d+\.\d+\.\d+', text)
|
||||
return match.group(0) if match else None
|
||||
|
||||
|
||||
def vram_total_mb():
|
||||
if not state['device'].startswith('cuda'):
|
||||
return None
|
||||
try:
|
||||
return round(torch.cuda.get_device_properties(0).total_memory / (1024 * 1024))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def vram_free_mb():
|
||||
if not state['device'].startswith('cuda'):
|
||||
return None
|
||||
try:
|
||||
free, _total = torch.cuda.mem_get_info()
|
||||
return round(free / (1024 * 1024))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def validate(model_name, face, face_name, strength, quality):
|
||||
if model_name not in MODEL_REGISTRY:
|
||||
raise EnhanceError('неизвестная модель %r, доступны: %s'
|
||||
% (model_name, ', '.join(MODEL_REGISTRY)), 400)
|
||||
if face not in FACE_MODES:
|
||||
raise EnhanceError('неизвестный режим лиц %r, доступны: %s' % (face, ', '.join(FACE_MODES)), 400)
|
||||
if quality < 70 or quality > 100:
|
||||
raise EnhanceError('jpeg_quality должен быть 70..100, получено %d' % quality, 400)
|
||||
if face == 'off':
|
||||
return
|
||||
if face_name not in FACE_REGISTRY:
|
||||
raise EnhanceError('неизвестная face-модель %r, доступны: %s'
|
||||
% (face_name, ', '.join(available_face_models()) or 'нет'), 400)
|
||||
if not face_installed(face_name):
|
||||
others = [name for name in available_face_models()]
|
||||
raise EnhanceError('модель лиц %s не установлена в образ, доступен %s'
|
||||
% (face_name, ', '.join(others) or 'ни один'), 400)
|
||||
if strength < 0.0 or strength > 1.0:
|
||||
raise EnhanceError('strength должен быть 0..1, получено %s' % strength, 400)
|
||||
if not FACE_REGISTRY[face_name]['strength'] and abs(strength - DEFAULT_STRENGTH) > 1e-6:
|
||||
raise EnhanceError('strength применяется только к CodeFormer, для %s оставьте %s'
|
||||
% (face_name, DEFAULT_STRENGTH), 400)
|
||||
|
||||
|
||||
def preload():
|
||||
try:
|
||||
seed_weights()
|
||||
except Exception as err:
|
||||
log.warning('перенос весов из образа не удался: %s', err)
|
||||
names = list(MODEL_REGISTRY) if LOAD_ALL else [DEFAULT_MODEL]
|
||||
for name in names:
|
||||
try:
|
||||
with pool.acquire('esrgan:' + name, name, lambda n=name: build_esrgan(n, False)):
|
||||
log.info('модель %s готова', name)
|
||||
except Exception as err:
|
||||
log.error('предзагрузка модели %s не удалась: %s', name, err)
|
||||
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
|
||||
@app.on_event('startup')
|
||||
async def startup():
|
||||
log.info('устройство: %s (%s), half=%s, tile=%s, веса: %s',
|
||||
state['device'], state['device_name'], state['half'], state['tile'], WEIGHTS_DIR)
|
||||
threading.Thread(target=preload, name='photo-ai-preload', daemon=True).start()
|
||||
|
||||
|
||||
@app.get('/health')
|
||||
def health():
|
||||
return {
|
||||
'ok': True,
|
||||
'ready': pool.contains('esrgan:' + DEFAULT_MODEL),
|
||||
'device': state['device'],
|
||||
'device_name': state['device_name'],
|
||||
'half': state['half'],
|
||||
'tile': state['tile'],
|
||||
'driver': driver_version(),
|
||||
'cuda': torch.version.cuda,
|
||||
'vram_total_mb': vram_total_mb(),
|
||||
'vram_free_mb': vram_free_mb(),
|
||||
'models': list(MODEL_REGISTRY),
|
||||
'face_models': available_face_models(),
|
||||
'loaded': pool.loaded_labels(),
|
||||
'loading': pool.loading_labels(),
|
||||
'max_pixels': MAX_PIXELS,
|
||||
}
|
||||
|
||||
|
||||
@app.get('/models')
|
||||
def models():
|
||||
loaded = pool.loaded_labels()
|
||||
faces = available_face_models()
|
||||
return {
|
||||
'device': state['device'],
|
||||
'device_name': state['device_name'],
|
||||
'half': state['half'],
|
||||
'tile': state['tile'],
|
||||
'max_pixels': MAX_PIXELS,
|
||||
'defaults': {
|
||||
'model': DEFAULT_MODEL,
|
||||
'scale': 2,
|
||||
'face': 'off',
|
||||
'face_model': DEFAULT_FACE_MODEL,
|
||||
'strength': DEFAULT_STRENGTH,
|
||||
'jpeg_quality': DEFAULT_JPEG_QUALITY,
|
||||
},
|
||||
'models': [
|
||||
{
|
||||
'name': name,
|
||||
'title': spec['title'],
|
||||
'scale': spec['scale'],
|
||||
'weights': spec['file'],
|
||||
'denoise': bool(spec.get('denoise')),
|
||||
'loaded': name in loaded,
|
||||
}
|
||||
for name, spec in MODEL_REGISTRY.items()
|
||||
],
|
||||
'face_models': [
|
||||
{
|
||||
'name': name,
|
||||
'title': spec['title'],
|
||||
'weights': spec['file'],
|
||||
'available': name in faces,
|
||||
'strength': bool(spec['strength']),
|
||||
'loaded': name in loaded,
|
||||
}
|
||||
for name, spec in FACE_REGISTRY.items()
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@app.post('/enhance')
|
||||
async def enhance(request: Request,
|
||||
image: UploadFile = File(...),
|
||||
scale: int = Form(2),
|
||||
model: str = Form(DEFAULT_MODEL),
|
||||
face: str = Form('off'),
|
||||
face_model: str = Form(''),
|
||||
strength: float = Form(DEFAULT_STRENGTH),
|
||||
jpeg_quality: int = Form(0)):
|
||||
started = time.time()
|
||||
model_name = (model or DEFAULT_MODEL).strip().lower()
|
||||
face_mode = (face or 'off').strip().lower()
|
||||
face_name = (face_model or '').strip().lower() or (
|
||||
DEFAULT_FACE_MODEL if DEFAULT_FACE_MODEL in FACE_REGISTRY else 'gfpgan')
|
||||
quality = jpeg_quality if jpeg_quality else DEFAULT_JPEG_QUALITY
|
||||
try:
|
||||
validate(model_name, face_mode, face_name, strength, quality)
|
||||
data = await image.read()
|
||||
img = cv2.imdecode(np.frombuffer(data, np.uint8), cv2.IMREAD_COLOR)
|
||||
if img is None:
|
||||
raise EnhanceError('bad image', 400)
|
||||
if img.shape[0] * img.shape[1] > MAX_PIXELS:
|
||||
ratio = (MAX_PIXELS / (img.shape[0] * img.shape[1])) ** 0.5
|
||||
img = cv2.resize(img, (int(img.shape[1] * ratio), int(img.shape[0] * ratio)),
|
||||
interpolation=cv2.INTER_AREA)
|
||||
outscale = min(max(int(scale), 2), 4)
|
||||
fmt = output_format(image.filename)
|
||||
warnings = []
|
||||
if face_mode != 'off':
|
||||
if min(img.shape[0], img.shape[1]) < MIN_FACE_SIDE:
|
||||
warnings.append('вход меньше %d×%d — лица могут не найтись'
|
||||
% (MIN_FACE_SIDE, MIN_FACE_SIDE))
|
||||
if face_name == 'codeformer' and not state['device'].startswith('cuda'):
|
||||
warnings.append('CodeFormer на %s медленнее GFPGAN' % state['device'])
|
||||
output, faces_found = await asyncio.to_thread(run_guarded, img, outscale, model_name,
|
||||
face_mode, face_name, float(strength), warnings)
|
||||
payload, media_type = encode_image(output, fmt, int(quality))
|
||||
except EnhanceError as err:
|
||||
if err.status_code == 503:
|
||||
return error_response(503, err.message, headers={'Retry-After': str(RETRY_AFTER_SEC)})
|
||||
return error_response(err.status_code, err.message)
|
||||
if face_mode != 'off' and not faces_found:
|
||||
warnings.append('лица не найдены, фон обработан апскейлом')
|
||||
elapsed_ms = int((time.time() - started) * 1000)
|
||||
if wants_json(request):
|
||||
return {
|
||||
'ok': True,
|
||||
'image_base64': base64.b64encode(payload).decode('ascii'),
|
||||
'image_ext': fmt[0],
|
||||
'model': model_name,
|
||||
'face': face_mode,
|
||||
'face_model': face_name if face_mode != 'off' else None,
|
||||
'faces_found': faces_found,
|
||||
'device': state['device'],
|
||||
'elapsed_ms': elapsed_ms,
|
||||
'warnings': warnings,
|
||||
}
|
||||
return Response(payload, media_type=media_type, headers={'X-Photo-AI-Model': model_name,
|
||||
'X-Photo-AI-Face': face_mode,
|
||||
'X-Photo-AI-Device': state['device'],
|
||||
'X-Photo-AI-Elapsed-Ms': str(elapsed_ms)})
|
||||
@@ -0,0 +1,84 @@
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
import app
|
||||
|
||||
WEIGHTS = {}
|
||||
MODELS = {}
|
||||
for _name, _spec in app.MODEL_REGISTRY.items():
|
||||
WEIGHTS[_spec['file']] = _spec
|
||||
MODELS[_name] = _spec
|
||||
if _spec.get('dni'):
|
||||
WEIGHTS[_spec['dni']['file']] = _spec['dni']
|
||||
for _name, _spec in app.FACE_REGISTRY.items():
|
||||
WEIGHTS[_spec['file']] = _spec
|
||||
MODELS[_name] = _spec
|
||||
for _name, _spec in app.FACEXLIB_WEIGHTS.items():
|
||||
WEIGHTS[_name] = _spec
|
||||
|
||||
|
||||
def wanted():
|
||||
raw = os.environ.get('PHOTO_AI_PREFETCH', '').strip()
|
||||
if not raw or raw.lower() == 'all':
|
||||
return dict(WEIGHTS)
|
||||
picked = {}
|
||||
for key in raw.split(','):
|
||||
name = key.strip()
|
||||
if not name:
|
||||
continue
|
||||
if name.lower() == 'face':
|
||||
for face_name, face_spec in app.FACE_REGISTRY.items():
|
||||
picked[face_spec['file']] = face_spec
|
||||
continue
|
||||
spec = MODELS.get(name) or WEIGHTS.get(name)
|
||||
if spec is None:
|
||||
sys.stderr.write('неизвестная модель для предзагрузки: %s\n' % name)
|
||||
continue
|
||||
picked[spec['file']] = spec
|
||||
return picked
|
||||
|
||||
|
||||
def fetch(name, spec, target, cache_dir):
|
||||
url = spec['url']
|
||||
if cache_dir:
|
||||
cached = os.path.join(cache_dir, name)
|
||||
if app.file_ok(cached, spec['min_bytes']):
|
||||
app.link_or_copy(cached, target)
|
||||
print('веса %s взяты из кэша сборки %s' % (name, cached))
|
||||
return
|
||||
try:
|
||||
app.download_weight(url, cached, spec['min_bytes'])
|
||||
except Exception:
|
||||
if os.path.isfile(cached):
|
||||
os.unlink(cached)
|
||||
raise
|
||||
app.link_or_copy(cached, target)
|
||||
return
|
||||
app.download_weight(url, target, spec['min_bytes'])
|
||||
|
||||
|
||||
def main():
|
||||
target_dir = sys.argv[1] if len(sys.argv) > 1 else app.SEED_DIR
|
||||
cache_dir = sys.argv[2] if len(sys.argv) > 2 else ''
|
||||
if cache_dir:
|
||||
os.makedirs(cache_dir, exist_ok=True)
|
||||
os.makedirs(target_dir, exist_ok=True)
|
||||
failed = []
|
||||
for name, spec in wanted().items():
|
||||
target = os.path.join(target_dir, name)
|
||||
if app.file_ok(target, spec['min_bytes']):
|
||||
continue
|
||||
try:
|
||||
fetch(name, spec, target, cache_dir)
|
||||
except Exception as err:
|
||||
failed.append('%s: %s' % (name, err))
|
||||
continue
|
||||
for line in failed:
|
||||
sys.stderr.write('не удалось скачать %s\n' % line)
|
||||
return 1 if failed else 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,35 @@
|
||||
S-Lab License 1.0
|
||||
|
||||
Copyright 2022 S-Lab
|
||||
|
||||
Redistribution and use for non-commercial purpose in source and
|
||||
binary forms, with or without modification, are permitted provided
|
||||
that the following conditions are met:
|
||||
|
||||
1. Redistributions of source code must retain the above copyright
|
||||
notice, this list of conditions and the following disclaimer.
|
||||
|
||||
2. Redistributions in binary form must reproduce the above copyright
|
||||
notice, this list of conditions and the following disclaimer in
|
||||
the documentation and/or other materials provided with the
|
||||
distribution.
|
||||
|
||||
3. Neither the name of the copyright holder nor the names of its
|
||||
contributors may be used to endorse or promote products derived
|
||||
from this software without specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
|
||||
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
|
||||
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
|
||||
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
|
||||
HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
|
||||
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
|
||||
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
|
||||
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
|
||||
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
|
||||
In the event that redistribution and/or use for commercial purpose in
|
||||
source or binary forms, with or without modification is required,
|
||||
please contact the contributor(s) of the work.
|
||||
@@ -0,0 +1,3 @@
|
||||
from .codeformer_arch import CodeFormer
|
||||
|
||||
__all__ = ['CodeFormer']
|
||||