27 Commits
Author SHA1 Message Date
dev e8c15cc425 feat(photo-ai): реестр моделей и авто-выбор устройства
Stage 1: переписан photo-ai/app.py. Реестр моделей вместо одной модели,
авто-выбор cuda/mps/cpu, ModelPool с ленивой загрузкой, прогревом и LRU
на 2 записи, OOM-деградация по лестнице тайлов, расширенные /health
и /models, /enhance с выбором модели, режима лиц и качества JPEG.

Инвариант I1 держится: запрос только с image + scale=2 по-прежнему даёт
байт-в-байт тот же JPEG (5/5 MATCH против эталона Stage 0). Расширение
выводится по имени файла, а не по content_type, и .jpeg нормализуется
в .jpg — этого хватает для совместимости с воркером, который шлёт
image/jpeg для всего.

/enhance отдаёт сырой JPEG по умолчанию и JSON при Accept: application/json.
Пока модель грузится — 503 с Retry-After: 5; worker.js относит status >= 500
к мягким, поэтому попытка не тратится (I5).

Новые переменные (PHOTO_AI_DEVICE, PHOTO_AI_MODELS_DIR, PHOTO_AI_TILE,
PHOTO_AI_LOAD_ALL, PHOTO_AI_WARMUP, PHOTO_AI_FACE_MODEL,
PHOTO_AI_JPEG_QUALITY) пока не документированы в .env.example: compose их
ещё не подставляет, это Stage 2.

Найдено: на хосте есть GPU (nvidia-smi, драйвер 615.71.09), не хватает
только nvidia-container-toolkit. Установка — решение оператора, ветка CUDA
осталась непроверенной прогоном.
2026-09-29 00:36:16 +03:00
dev 88dbff0136 chore(photo-ai): Stage 0 закрыт, решения D1–D6 зафиксированы
Stage 0: чекбоксы отмечены, приёмка перепроверена — эталон воспроизводится
байт-в-байт (5/5 MATCH), /health -> {"ok":true}, сценарий «🤖 ИИ» -> done.

D1 — порт photo-ai на 127.0.0.1:8081 (loopback), применён и отражён в
README/.env.example.
D2 — отдельная photoAiHealth() вместо aiHealthCheck() в статусе заданий,
контракт service = {configured, reachable, latency_ms, error} + passthrough
полей photo-ai; правка и проверка в предыдущем коммите.
D3 — PHOTO_JOB_ACTIONS выносится на уровень модуля и используется в restore
и в валидации enhance-ai; CHECK в БД не добавляем (I6).
D4 — вендорить gfpgan/facexlib не нужно: пакеты есть на PyPI и уже в образе
(реalesrgan тянет их транзитивно). Зафиксированы проверенные URL весов и
расхождение: пин numpy<2 в Dockerfile не действует (в образе 2.2.6).
D5 — CodeFormer внедряется только при явной необходимости; на PyPI лишь
сторонняя обёртка, дефолт gfpgan, при отсутствии модуля -> 400 с текстом.
D6 — дефолт PHOTO_AI_URL не меняем (фото-ИИ включено из коробки на CPU).
2026-09-28 23:38:48 +03:00
dev 00fbf41efb build(photo-ai): loopback-порт 8081 и раздел про фото-ИИ в README
photo-ai не имел ports, а хостовый 8080 уже занят text-corrector: ручные
проверки из плана попадали не туда. Проброшено 127.0.0.1:8081:8080 — только
loopback, наружу ничего не публикуется.

Заодно исправлен рассинхрон .env.example: там стоял пустой PHOTO_AI_URL с
комментарием «пусто = контейнер photo-ai», из-за чего копирование примера
молча выключало фото-ИИ (в compose дефолт http://photo-ai:8080). Теперь
дефолт указан явно, пустое значение описано как «выключить сервис».
2026-09-28 23:38:39 +03:00
dev 1a25ce7170 fix(photo-ai): отдавать health фото-сервиса в статусе заданий
В GET /api/photo-jobs/status вызывался aiHealthCheck() — это health
текстового ИИ, — а результат в ответ не попадал: поле service отсутствовало,
и оператор не видел состояние photo-ai.

Добавлена photoAiHealth(): GET ${PHOTO_AI_URL}/health с таймаутом 5 с, без
исключений; пустой PHOTO_AI_URL -> {configured:false, reachable:false}, обрыв
или таймаут -> {configured:true, reachable:false, error}. Вызов уходит в тот
же Promise.all, что и запросы к БД, поэтому статус не получает лишние 5 с.
Контракт: {configured, reachable, latency_ms, error} + passthrough полей
photo-ai; первые четыре совпадают с контрактом текстового ИИ, который уже
читает public/js/worker.js.

Контракт зафиксирован в api.smoketest.js двумя проверками.
2026-09-28 23:38:32 +03:00
dev 403574fe79 chore(photo-ai): раздел 0 — жёсткие инварианты I1–I7 зафиксированы
План фото-ИИ с восстановлением лиц разбит на этапы; этот коммит закрывает
раздел 0 — семь инвариантов, которые нельзя ломать дальше. Два из них были
нарушены в текущем коде и исправлены здесь.

I5 (мягкие ошибки не сжигают попытки). Раньше любой сбой photo-ai —
503, обрыв сети, таймаут — попадал в общий catch, инкрементил attempts и
через три попытки переводил задание в error. Теперь ошибки разделены:
5xx/429/425/408 и сетевая недоступность возвращают задание в pending без
инкремента attempts, с экспоненциальной паузой 10 с → 300 с; лимит мягких
повторов (по умолчанию 60) даёт одну честную ошибку с понятным текстом.
Таймаут AbortSignal.timeout — жёсткая ошибка с попытками, как и раньше.
Счётчик мягких повторов живёт в памяти процесса и в счётчиках воркера,
метаданные повтора — в audit_log.target (soft_attempt/soft_limit), без
новых колонок. Новый аудит-код photo.job.soft_retry и подпись в audit.js.
Переменные PHOTO_AI_SOFT_MAX_RETRIES, PHOTO_AI_SOFT_BACKOFF_MS,
PHOTO_AI_SOFT_BACKOFF_MAX_MS описаны в .env.example и отдаются в
worker.config в GET /api/photo-jobs/status.

I7 (никаких прямых fs.* по uploads/). runAiEnhance писал результат
fs.writeFileSync в uploads/ и только потом persist в S3; enhanceWithSharp
делал то же через sharp toFile. Оба теперь считают буфер и пишут его
через storage.put — драйвер выбирает сам, локальной копии не остаётся.
Из worker.js убраны require('fs'), require('path') и параметр uploadsDir.

I3 (photo-ai не обязателен). photo_ai_enabled вычислялся внутри
cacheWrap('public-settings'), поэтому после перезапуска с пустым
PHOTO_AI_URL кнопка «🤖 ИИ» оставалась видимой до истечения кэша (60 с),
хотя enhance-ai уже отдавал 503. Флаг вынесен из кэша: он выводится из
PHOTO_AI_URL в памяти процесса и всегда актуален.

Проверено на стенде (журнал — в TODO_PHOTO_FACE_AI.md, раздел 0):
- I1: эталон /enhance снят на 5 фото (3 реальных, 2 синтетических),
  два независимых прогона и прогон после правок совпали байт-в-байт
  (sha256), /health отдаёт ok. Скрипты и эталон — в backups/ (вне git)
- I2: задание с params IS NULL и action='ai' дошло до done при
  attempts=0, результат отдан из S3 (200)
- I3: с пустым PHOTO_AI_URL photo_ai_enabled=false сразу после старта,
  enhance-ai → 503, остальные маршруты API живы
- I4: nvidia-ctk и nvidia-container-runtime на хосте отсутствуют, runtime
  только runc — фото-ИИ поднялся на CPU, /health не падает. Проверка
  PHOTO_AI_DEVICE переносится на приёмку Stage 1 (переменной ещё нет)
- I6: db/ не тронут, состав колонок photo_jobs прежний
- I7: node --check для всех изменённых JS, комментариев в диффе нет,
  весь SQL параметризован

api.smoketest.js: контракт фото-воркера — согласованность
photo_ai_enabled и ai_configured, ключи мягких повторов в worker.config,
503/404 для enhance-ai на несуществующей записи (тест не создаёт реальных
заданий). Вместе с планом и чек-листом этапов.
2026-09-28 23:19:16 +03:00
dev 31542de33b feat(photos): раздел «Фото» — единая галерея всех загруженных фотографий
Новый read-only раздел для просмотра всех фотографий системы:
GET /api/photos собирает через UNION ALL пять источников — главное фото
записи, фото записи (entry_photos), фотохронику групп, фото учеников и фото
тем модулей. Фильтры search/student_name/group_id/date_from/date_to и
пагинация limit/offset, ответ { photos, total }. Не-admin ограничен
своими филиалами (branch_id), чужой group_id отдаёт 403.

Фронтенд: public/photos.html + public/js/photos.js — сетка превью
(/uploads/thumb/...), бейдж источника, описание, ученик/группа, дата,
ссылка на источник, lightbox по клику, поиск с debounce и пагинация.
Навигация: пункт «Фото» в сайдбаре и плитка в быстрых действиях дашборда.
2026-09-28 00:15:32 +03:00
dev 2afe676969 feat(notifications): центр уведомлений о системных событиях
Добавлена система уведомлений о системных и фоновых событиях (новые записи
журнала, обработка фото, авто-проверка текста, блокировки IP, бэкапы).

- backend (server.js, worker.js):
  - каталог NOTIFY_TYPES с метаданными и уровнями
  - таблицы notifications и notification_reads в db/init.sql и db/migration.sql
  - SSE-стрим GET /api/notifications/stream через Redis pub/sub с in-memory fallback
  - REST API: список, счётчик непрочитанных, отметка о прочтении, удаление, очистка
  - настройки уведомлений в settings (notify_enabled, notify_retention_days, notify_<тип>)
  - автоматическая очистка старых уведомлений по расписанию
- frontend:
  - колокольчик со счётчиком непрочитанных в шапке (admin.js)
  - страница списка уведомлений public/notifications.html и public/js/notifications.js
  - секция настроек уведомлений в public/settings.html и public/js/settings.js
  - стили для уведомлений в public/admin.css
- тесты и документация:
  - добавлены проверки в api.smoketest.js
  - обновлены README.md и AGENTS.md
2026-09-27 23:34:47 +03:00
dev f31b8deea2 feat(settings): блок «Статус стека» — на чём всё крутится
На странице Настроек появился блок с состоянием стека системы: версии
компонентов, состояние сервисов и нагрузка. Рендерится из нового блока
stack в ответе GET /api/system-info, в том же стиле, что и соседний
блок системной информации (sys-card / sys-item / sys-bar, точки
worker-dot, полосы загрузки и памяти с порогами 70 % и 90 %).

Карточки: Приложение (версия и коммит, Node.js, PID и RSS, аптайм,
куча, сворачиваемый список библиотек), Сервер (ОС, ядро, архитектура и
число ядер, модель CPU, loadavg, память, аптайм, контейнер), База
данных (PostgreSQL и версия, хост, состояние пула), Кэш (драйвер,
версия Redis, ключи, память, попадания и промахи, операций в памяти) и
Хранилище (драйвер, endpoint, бакет или каталог).

- server.js: getStackInfo() — версии из package.json, node_modules и
  public/version.json, ОС из /etc/os-release, определение Docker,
  SHOW server_version, счётчики пула pg, состояние Redis. Считается вне
  cacheWrap, чтобы версии и нагрузка не отдавались из 30-секундного
  кэша; хосты только через URL.hostname, без учётных данных URL
- settings.html: карточка sec-stack и пункт «Стек» в навигации
- settings.js: renderStackInfo(), форматирование аптаймов с
  русскими склонениями, degrade-состояние «нет связи — в памяти»;
  кнопка обновления перезагружает оба блока одним запросом
- admin.css: .sys-inline для точки статуса рядом с текстом
- api.smoketest.js: контракт блока stack и проверка, что в ответе нет
  учётных данных из DATABASE_URL/REDIS_URL
2026-09-27 12:47:04 +03:00
dev 4d3df1de37 fix(redis): разбирать INFO по разделителю, а не по фиксированному смещению
used_memory и used_memory_human вырезались на символ длиннее нужного,
поэтому первая цифра терялась: 1024 превращалось в 24, а "1.34M" — в
".34M". Значение памяти Redis в /api/system-info и в self-тестах было
занижено на порядок.

- parseInfoSections(): разбор INFO по indexOf(':'), пропуск заголовков
  # и пустых строк, работа с любой секцией
- info(): добавлены redis_version и uptime_in_seconds (секция server),
  в том числе в ветках memory и ошибки — с null
2026-09-27 12:46:41 +03:00
dev 3ecf87146a build(docker): перейти на Node 22 (AWS SDK требует node >=22) 2026-09-27 12:05:55 +03:00
dev eea42eb704 style(audit): кнопка закрытия деталей в общем стиле сайта 2026-09-27 12:01:48 +03:00
dev 54cf5bbfa4 feat(audit): показывать изменения текста записи по шагам
При сохранении записи журнала (PUT /api/entries/:id) сравнивается
состояние до и после, и в audit_log пишется не только факт правки,
но и сами изменения: пословный дифф текста, статистика добавленных
и удалённых слов, а также смена ФИО, группы и темы модуля.

- diff.js: пословный LCS-дифф без зависимостей, обрезка больших
  текстов, сборка изменений по полям записи, облегчённый target
  для списка аудита
- source правки: manual / ai / ai_manual / ai_revert; журнал шлёт
  edit_source, сервер доверяет явному значению и определяет источник
  по description_ai как запасной вариант
- те же диффы пишутся для автопроверки ИИ (entry.ai.auto-check)
  и отката к оригиналу (entry.ai.revert)
- GET /api/audit отдаёт список без diff, GET /api/audit/:id — полный
  target, чтобы не грузить килобайты текста на каждую строку
- Аудит: колонка «Кто», сводка в таблице, модалка с подсветкой
  удалённого и добавленного текста, «было/стало» для полей
- auth.login теперь пишет user_id, иначе колонка «Кто» показывала
  «система»
- diff.selftest.js: 16 тестов диффа; README и AGENTS обновлены
2026-09-27 11:29:17 +03:00
dev 19676c9b64 test(auth): зафиксировать контракт авторизации в smoke-тесте
Документация долго описывала X-Admin-Token как способ авторизации, хотя его
нет в коде. Расхождение не ловилось ничем: curl-пример в доках ходил на
GET /api/groups, а он публичный (optionalAuth) и отвечает 200 без токена,
то есть авторизацию не проверял вообще.

Добавлены проверки, которые падают при возврате статического токена:
- защищённый маршрут без токена -> 401;
- мусорный X-Auth-Token -> 401;
- X-Admin-Token (значение ADMIN_PASSWORD) -> 401;
- Authorization: Bearer -> 401;
- ADMIN_PASSWORD как токен -> 401;
- позитивный контроль: валидный токен на admin-маршруте -> 200, иначе
  проверки выше проходили бы из-за сломанного роута;
- /api/groups остаётся публичным -> 200 без токена.

Хелпер api() научен принимать произвольные заголовки — иначе X-Admin-Token
и Bearer не отправить. AGENTS.md дополнен описанием контракта и пунктом в
чеклисте безопасности.
2026-09-26 16:08:32 +03:00
dev 87541a5ce8 docs(auth): исправить устаревшее описание авторизации
Документация утверждала, что доступ админский и задаётся заголовком
X-Admin-Token со значением ADMIN_PASSWORD, и что без ADMIN_PASSWORD сервер
не стартует. Ни то, ни другое не верно:

- X-Admin-Token в server.js отсутствует полностью, авторизация держится на
  сессиях: POST /api/auth/login (bcrypt) выдаёт токен, который клиент шлёт
  в X-Auth-Token. Проверено на живом стенде: X-Auth-Token -> 200,
  X-Admin-Token -> 401 на /api/auth/me и /api/users;
- ADMIN_PASSWORD участвует только в ensureFirstAdmin() — создании первого
  админа в пустой БД. Без него сервер пишет предупреждение и стартует;
- роли и филиалы: requireAuth (любой активный), requireAdmin (role=admin,
  самодостаточный), optionalAuth; не-admin ограничены user_branches через
  branchScope/branchWhere — это в доках не описывалось.

Заодно curl-пример проверки авторизации в AGENTS.md вёл на GET /api/groups,
который публичный (optionalAuth) и отвечает 200 без токена, то есть авторизацию
не проверял. Переведён на /api/auth/me.

Секрет Gitea убран из URL remote в ~/.git-credentials (600) — deploy.sh
работает без промпта.
2026-09-26 16:05:47 +03:00
dev 4b620d3e59 chore(repo): перестать отслеживать генерируемый public/version.json
Файл генерируется дважды и всегда расходится с HEAD:
- .git/hooks/post-commit вызывает scripts/gen-version.js и переписывает файл
  новым хешем после каждого коммита, поэтому он немедленно снова становится
  грязным и коммитить его бесполезно;
- Dockerfile пишет свою копию внутрь образа из аргументов GIT_COMMIT, и её
  приложение и отдаёт: public/ не смонтирован в контейнер, а public/admin.js
  читает /version.json по HTTP.

Отслеживаемый файл в рабочей копии рантайму не нужен. scripts/deploy.sh уже
исключает его из проверки чистоты, теперь это перестаёт быть обходным путём.
2026-09-26 15:36:32 +03:00
dev cb0e68d04a chore(deploy): обновить version.json на 0e38a28 2026-09-26 15:27:47 +03:00
dev 0e38a280d7 feat(redis): кэш, rate limit, баны IP и pub/sub через Redis
Добавлен сервис redis:7-alpine (AOF, requirepass, maxmemory + allkeys-lru,
healthcheck, том redis-data, порт только на 127.0.0.1) и абстракция redis.js
по образцу storage.js.

Переведено на Redis:
- кэш ответов API и настроек (было Map в памяти), инвалидация по префиксу
  через SCAN + DEL;
- rate limit для api/entry/file — общие счётчики вместо MemoryStore;
- баны IP и счётчики неудачных входа — с TTL, вместо опроса БД каждую минуту;
- кэш сессий (30 с) с invalidateSessions() на каждой мутации users/sessions/
  user_branches, иначе деактивированный пользователь сохранил бы доступ;
- pub/sub для SSE-событий и мгновенного пробуждения фоновых воркеров вместо
  ожидания цикла опроса БД.

Отказоустойчивость: при недоступном Redis все операции уходят в in-memory
backend с той же семантикой, приложение стартует и работает без Redis и
возвращается в Redis автоматически. Первое подключение ограничено по времени
(REDIS_CONNECT_TIMEOUT_MS, 5 с) — node-redis не отклоняет connect() при
недоступном сервере, а повторяет попытки бесконечно.

Добавлены тесты: redis.selftest.js (в т.ч. поведение при недоступном
сервере) и api.smoketest.js (сквозная проверка API, включая инвалидацию
кэша и мгновенную смерть сессии после logout).
2026-09-26 15:26:00 +03:00
dev e4d58d6525 chore(storage): убрать неиспользуемый код и стабилизировать деплой
- storage.js: из экспорта убраны неиспользуемые publicPath, localExists и dir
- server.js: убран неиспользуемый импорт mimeFor и параметр originalsDir в воркере
- worker.js: убран неиспользуемый параметр originalsDir
- scripts/deploy.sh: up -d --force-recreate app, чтобы пересобранный образ
  гарантированно применялся (compose не всегда пересоздаёт контейнер при
  неизменном конфиге сервиса)
2026-09-26 11:16:36 +03:00
dev ddd49707ae feat(storage): S3-совместимое хранилище файлов (SeaweedFS/MinIO) и миграция uploads
- storage.js: абстракция хранилища с драйверами local и s3 (AWS SDK v3),
  ключи объектов совпадают с текущими путями /uploads/<файл>, поэтому схема БД
  и URL не меняются
- docker-compose.yml: сервис s3 (SeaweedFS, том s3-data, API только на loopback),
  переменные S3_*/STORAGE_*, restart unless-stopped для app и db
- docker-compose.minio.yml: оверрайд S3-сервиса на MinIO (образ из своего зеркала)
- server.js/worker.js: чтение и запись файлов только через storage (отдача
  /uploads/*, миниатюры, share-файлы, zip-отчёты, enhance/apply/rollback,
  photo-worker), автосоздание бакета, глобальная персистенция загрузок multer
- бэкап/восстановление и scripts/backup.sh, restore.sh — через scripts/storage-sync.js
- scripts/migrate-to-s3.js: идемпотентная миграция uploads/ в бакет
  (--dry-run, --verify-only, --delete-local)
- админка: блок «Хранилище» в системной информации
- .env.example, README.md, AGENTS.md: описание драйверов, переменных и перехода на S3
2026-09-26 11:08:53 +03:00
dev 8bf54fb95d feat(trash): deferred purge after configurable days, groups in trash 2026-09-24 13:11:31 +03:00
dev e6c69c8a78 feat(settings): option to show/hide camera button, preview click opens camera 2026-09-24 12:00:20 +03:00
dev e4704c2dc6 fix(index): paste fills only project files, photo stays webcam-only
- pasted images/files always go to "Файлы проекта", never to the entry photo
- drop the photo paste hint and the capturedName plumbing: the entry photo is
  captured from the camera only (submit still requires it)
- keep paste naming ("Вставка <date>.<ext>") and server-mirrored limits
2026-09-24 11:21:58 +03:00
dev a72600af13 feat(index): paste photos and project files from clipboard with Ctrl+V
- handle document paste: images become the entry photo when none is set,
  otherwise they land in "Файлы проекта" along with any other pasted files
- take file names from the clipboard, replace generic ones (image.png, blob)
  with "Вставка <date>.<ext>"; derive extension from MIME when missing
- validate client-side like the server: 10 files, 10 MB per file, 30 MB total,
  reject blocked extensions (*.html, *.js, *.svg, ...)
- share the limit logic between the picker and paste, send the photo under its
  real name so pasted PNG/JPEG keep their extension
- add Ctrl+V hints to the photo and files cards (hidden on touch devices)
2026-09-24 11:18:08 +03:00
dev 2e1d36bc4d chore(deploy): exclude generated version.json from deploy dirty check 2026-09-23 23:02:37 +03:00
dev d943b77f58 chore(deploy): bake commit version into image and add scripts/deploy.sh 2026-09-23 23:01:36 +03:00
dev a95af7daa7 fix(backup): accept /uploads/.originals paths in photo_jobs on restore
photo_jobs.before_path хранит путь к оригиналу фото (/uploads/.originals/<файл>), но normalizeRestoreData проверял это поле через optUploadPath/isSafeUploadPath, который запрещает "/" — при наличии завершённых улучшений фото весь импорт падал с 400 «Неверный формат бэкапа: Invalid upload path».

- добавлены reqPhotoRefPath/optPhotoRefPath: допустимы /uploads/<файл> и /uploads/.originals/<файл> (та же ORIGINALS_PATH_RE, что и для entries.photo_original_path); применяются к photo_jobs.before_path/after_path
- POST /api/restore: понятная ошибка, если загружен архив скрипта scripts/backup.sh (db.sql.gz + _uploads) вместо веб-архива
- README: форматы скриптового и веб-архива не взаимозаменяемы
2026-09-23 22:23:38 +03:00
dev 69d46a0e5f fix(backup): stream backup download via resumable token link instead of buffering
Формирование и скачивание бэкапа разделены: POST /api/backup собирает архив
на диске и возвращает временную ссылку, GET /api/backup/:token отдаёт его
через res.download (Content-Length, Accept-Ranges, 206 при докачке).

- больше нет fs.readFileSync всего архива и res.send буфера (~550 МБ RAM -> ~60 МБ)
- GET /api/backup сохранён для совместимости, тоже потоковый
- gzip level 1 (архив из JPEG почти не сжимается), чистка /tmp/wido-backups по TTL 30 мин
- settings.html/js: нативное скачивание браузером с прогрессом и докачкой,
  понятные ошибки вместо «Ошибка сети при формировании бэкапа»
2026-09-23 19:03:19 +03:00
45 changed files with 8239 additions and 530 deletions
+47 -2
View File
@@ -4,6 +4,14 @@ DB_PASSWORD=случайная-длинная-строка
# Лимит размера загружаемого на восстановление бэкапа, МБ (по умолчанию 500) # Лимит размера загружаемого на восстановление бэкапа, МБ (по умолчанию 500)
BACKUP_UPLOAD_LIMIT_MB=500 BACKUP_UPLOAD_LIMIT_MB=500
# === 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 (нужен для закрытых/приватных репозиториев моделей) # Hugging Face token (нужен для закрытых/приватных репозиториев моделей)
HUGGINGFACE_TOKEN= HUGGINGFACE_TOKEN=
# Имя GGUF-файла модели для text-corrector (скачивается с Hugging Face, если отсутствует) # Имя GGUF-файла модели для text-corrector (скачивается с Hugging Face, если отсутствует)
@@ -28,6 +36,43 @@ CLOUDFLARE_TUNNEL_URL=http://app:3003
# туннеля без VPN (сек). Если VPN-провайдер не отвечает — сайт всё равно поднимется. # туннеля без VPN (сек). Если VPN-провайдер не отвечает — сайт всё равно поднимется.
WG_HANDSHAKE_TIMEOUT=60 WG_HANDSHAKE_TIMEOUT=60
# ИИ-улучшение фото (Real-ESRGAN), пусто = контейнер photo-ai # === ИИ-улучшение фото (контейнер photo-ai, Real-ESRGAN) ===
PHOTO_AI_URL= # По умолчанию фото-ИИ включено: сервис 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 PHOTO_AI_MAX_PIXELS=4000000
# Сколько раз воркер повторит задание, если 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
+3 -3
View File
@@ -14,6 +14,9 @@ backups/
*.sql.gz *.sql.gz
*.tar.gz *.tar.gz
# --- Generated build artifacts ---
public/version.json
# --- Node --- # --- Node ---
node_modules/ node_modules/
@@ -27,8 +30,5 @@ Thumbs.db
# --- Playwright test artifacts --- # --- Playwright test artifacts ---
.playwright-mcp/ .playwright-mcp/
# --- Generated version data ---
public/version.json
# --- Internal audit (not for commit) --- # --- Internal audit (not for commit) ---
SECURITY_AUDIT.md SECURITY_AUDIT.md
+142 -18
View File
@@ -8,10 +8,10 @@ This document defines how AI agents should work with the WhatIDo codebase. Follo
**WhatIDo** — Accounting system for an educational center: attendance journal, student project works, group gallery, detached files, and public showcase pages (share links). **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, Docker Compose, Tailscale (Serve/Funnel) - **Stack**: Node.js 20 + Express, PostgreSQL 16, Redis 7, Docker Compose, S3-совместимое хранилище файлов, Tailscale (Serve/Funnel)
- **Architecture**: Single Express server (`server.js`) + static frontend in `public/` - **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 + tailscale), bind-mounted uploads, named volume for Postgres data - **Deployment**: Docker Compose (app + db + s3 + tailscale), bind-mounted uploads, named volumes for Postgres and S3 data
- **Auth**: Admin-only via `X-Admin-Token` header (value = `ADMIN_PASSWORD` env var). No user sessions. - **Auth**: сессии в БД. `POST /api/auth/login` (bcrypt) → токен в заголовке `X-Auth-Token`. Роли: `admin` и не-admin, ограниченные филиалами (`user_branches`). `ADMIN_PASSWORD` используется **только** для автосоздания первого админа в пустой БД — это не механизм авторизации API
--- ---
@@ -34,13 +34,50 @@ This document defines how AI agents should work with the WhatIDo codebase. Follo
### 3. File Uploads ### 3. File Uploads
- **Multer configs**: `upload` (images only), `adminUpload` (wider allowed ext), `uploadBackup` (restore) - **Multer configs**: `upload` (images only), `adminUpload` (wider allowed ext), `uploadBackup` (restore)
- **Limits**: 10 MB/file, 30 MB total per entry - **Limits**: 10 MB/file, 30 MB total per entry
- **Storage**: `uploads/` bind-mounted to host, filenames = `timestamp-random.ext` - **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` - **HEIC**: Auto-converted to JPEG via `heic-convert`
- **Cleanup**: `safeUnlink` / `sweepOrphanedUploads` — never delete outside `uploads/` - **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`, `del`, `copyObject`, `listAll`, `localize`, `persist`, `streamTo`, `downloadAll`, `uploadTree`, `ensureBucket`, `usage`, `pruneCache`
- **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:`, `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`)
### 4. API Patterns ### 4. API Patterns
- **Admin routes**: `requireAdmin` middleware (checks `X-Admin-Token`) - **Middleware**: `requireAuth` — читает `X-Auth-Token`, 401 без валидной активной сессии. `requireAdmin` — самодостаточный (внутри вызывает `requireAuth`, если `req.user` ещё нет), 403 при `role !== 'admin'`. `optionalAuth` — для публичных страниц с персонализацией
- **Public routes**: `apiLimiter` (300/15min), `entryLimiter` (10/15min), `fileLimiter` (300/15min) - **Филиалы**: `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 - **Responses**: JSON, `{ error: 'message' }` on failure, data directly on success
- **Pagination**: `limit` / `offset` query params, return `{ items, total }` or `{ entries, total }` - **Pagination**: `limit` / `offset` query params, return `{ items, total }` or `{ entries, total }`
- **Filters**: `group_id`, `date_from`, `date_to`, `student_name`, `search`, `deleted` - **Filters**: `group_id`, `date_from`, `date_to`, `student_name`, `search`, `deleted`
@@ -48,16 +85,18 @@ This document defines how AI agents should work with the WhatIDo codebase. Follo
### 5. Frontend (public/) ### 5. Frontend (public/)
- Vanilla HTML/CSS/JS, no build step - Vanilla HTML/CSS/JS, no build step
- Each page = single HTML file + shared `admin.js` / `admin.css` - Each page = single HTML file + shared `admin.js` / `admin.css`
- API calls via `fetch` with `X-Admin-Token` from `localStorage` - API calls via `fetch` with `X-Auth-Token` (токен из `localStorage`); `X-Admin-Token` больше не используется и не работает
- Share pages (`share.html`, `links.html`) work without auth - Share pages (`share.html`, `links.html`) work without auth
### 6. Docker / Compose ### 6. Docker / Compose
- **Dockerfile**: Node 20 Alpine, installs deps, generates self-signed TLS cert - **Dockerfile**: Node 22 Alpine, installs deps, generates self-signed TLS cert
- **docker-compose.yml**: 3 services (db, app, tailscale) - **docker-compose.yml**: сервисы `db`, `app`, `redis`, `s3` (+ опционально `tailscale`, `cloudflared`, `text-corrector`, `photo-ai`)
- `db`: postgres:16-alpine, healthcheck, init.sql mounted - `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 - `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) - `tailscale`: host network, NET_ADMIN, runs `start-tailscale.sh` (funnel to 127.0.0.1:3443)
- **Env vars** (required): `ADMIN_PASSWORD`, `DB_PASSWORD` - **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) - **Port 443 on host** must be free (tailscale listens directly)
### 7. Tailscale Publication ### 7. Tailscale Publication
@@ -102,6 +141,15 @@ This document defines how AI agents should work with the WhatIDo codebase. Follo
- Update Multer `fileFilter` functions - Update Multer `fileFilter` functions
- Keep `MAX_TOTAL_UPLOAD_BYTES` and per-file limit in sync - 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 ## Testing & Verification
@@ -115,21 +163,68 @@ docker compose up -d --build
# Check logs # Check logs
docker compose logs -f app docker compose logs -f app
# Test API (replace TOKEN) # Test API (replace LOGIN/PASS; X-Admin-Token больше не работает)
curl -H "X-Admin-Token: $ADMIN_PASSWORD" http://localhost:3003/api/groups 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 # Run backup/restore scripts
./scripts/backup.sh ./scripts/backup.sh
./scripts/restore.sh backups/whatido-backup-<date>.tar.gz ./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
docker compose start redis # app reconnects on its own
# Audit diff checks
node diff.selftest.js # unit, no stack needed
``` ```
`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) ## Security Checklist (before any change)
- [ ] No SQL interpolation — only `$1`, `$2`... - [ ] No SQL interpolation — only `$1`, `$2`...
- [ ] Upload path validation via `isSafeUploadPath` / `safeUnlink` - [ ] Upload path validation via `isSafeUploadPath` / `safeUnlink`
- [ ] File I/O через `storage.*`, ключи объектов не выходят за пределы бакета/`uploads/`
- [ ] Rate limiter on new public routes - [ ] Rate limiter on new public routes
- [ ] Admin routes behind `requireAdmin` - [ ] Admin routes behind `requireAdmin`
- [ ] New auth paths checked against the contract in `api.smoketest.js`, docs updated in the same change
- [ ] No secrets in code — only via env vars - [ ] No secrets in code — only via env vars
- [ ] Helmet headers present (already global) - [ ] Helmet headers present (already global)
- [ ] CORS disabled (no `cors` middleware) - [ ] CORS disabled (no `cors` middleware)
@@ -141,15 +236,24 @@ curl -H "X-Admin-Token: $ADMIN_PASSWORD" http://localhost:3003/api/groups
| File | Purpose | | File | Purpose |
|------|---------| |------|---------|
| `server.js` | Entire backend (Express, routes, DB, uploads, backup) | | `server.js` | Entire backend (Express, routes, DB, uploads, backup) |
| `worker.js` | Background AI auto-check worker for entry messages | | `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 |
| `api.smoketest.js` | End-to-end API smoke test against a running stack |
| `worker.js` | Background AI auto-check worker for entry messages + photo enhance worker |
| `db/init.sql` | Initial schema (runs on fresh DB) | | `db/init.sql` | Initial schema (runs on fresh DB) |
| `db/migration.sql` | Idempotent migrations for existing DBs | | `db/migration.sql` | Idempotent migrations for existing DBs |
| `docker-compose.yml` | Service definitions (app, db, tailscale) | | `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 | | `Dockerfile` | App image build |
| `public/*.html` | Frontend pages | | `public/*.html` | Frontend pages |
| `public/admin.js` | Shared frontend logic | | `public/admin.js` | Shared frontend logic |
| `scripts/backup.sh` | Host-level backup script | | `scripts/backup.sh` | Host-level backup script (DB dump + storage export) |
| `scripts/restore.sh` | Host-level restore script | | `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 | | `start-tailscale.sh` | Tailscale container entrypoint |
| `.env.example` | Env var template | | `.env.example` | Env var template |
@@ -159,6 +263,14 @@ curl -H "X-Admin-Token: $ADMIN_PASSWORD" http://localhost:3003/api/groups
- ❌ Add dependencies without updating `package.json` and rebuilding - ❌ Add dependencies without updating `package.json` and rebuilding
- ❌ Write files outside `uploads/` or `certs/` - ❌ 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/` - ❌ Commit `.env`, `certs/`, `uploads/`, `backups/`, `node_modules/`
- ❌ Expose DB port (5432) outside docker network - ❌ Expose DB port (5432) outside docker network
- ❌ Use `eval`, `Function` constructor, or dynamic code execution - ❌ Use `eval`, `Function` constructor, or dynamic code execution
@@ -172,12 +284,24 @@ curl -H "X-Admin-Token: $ADMIN_PASSWORD" http://localhost:3003/api/groups
# Full rebuild # Full rebuild
docker compose down && docker compose up -d --build docker compose down && docker compose up -d --build
# Обновление на сервере (pull master + сборка образа с версией коммита + перезапуск app)
./scripts/deploy.sh
# App logs # App logs
docker compose logs -f app docker compose logs -f app
# DB shell # DB shell
docker compose exec db psql -U app -d whereldo 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 # Tailscale status
docker exec -it whatido-tailscale-1 tailscale status docker exec -it whatido-tailscale-1 tailscale status
+4 -1
View File
@@ -1,10 +1,13 @@
FROM node:20-alpine FROM node:22-alpine
ENV TZ=Europe/Moscow ENV TZ=Europe/Moscow
WORKDIR /app WORKDIR /app
COPY package.json package-lock.json* ./ COPY package.json package-lock.json* ./
RUN npm install --omit=dev RUN npm install --omit=dev
COPY . . COPY . .
RUN mkdir -p uploads certs RUN mkdir -p uploads certs
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; \ RUN set -e; \
V="v$(cut -d. -f1-2 /etc/alpine-release)"; \ V="v$(cut -d. -f1-2 /etc/alpine-release)"; \
try_repo() { \ try_repo() { \
+553
View File
@@ -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
```
+3 -1
View File
@@ -182,7 +182,9 @@ branches
| GET | `/api/share/:token` | Public | Share page data | | GET | `/api/share/:token` | Public | Share page data |
| GET | `/api/links` | Admin | List share links | | GET | `/api/links` | Admin | List share links |
| POST/PUT/DELETE | `/api/links` | Admin | CRUD share links | | POST/PUT/DELETE | `/api/links` | Admin | CRUD share links |
| GET | `/api/backup` | Admin | Download backup | | 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 | | POST | `/api/restore` | Admin | Upload & restore backup |
| GET | `/api/dashboard` | Admin | Dashboard data | | GET | `/api/dashboard` | Admin | Dashboard data |
| GET | `/api/stats` | Admin | Stats cards | | GET | `/api/stats` | Admin | Stats cards |
+278 -10
View File
@@ -12,7 +12,9 @@
- **Share-ссылки** — публичные страницы-витрины с выбором группы / воспитанника / диапазона дат - **Share-ссылки** — публичные страницы-витрины с выбором группы / воспитанника / диапазона дат
- **Дашборд** — статистика, активные группы, активность за 14 дней, последние записи, топ воспитанников - **Дашборд** — статистика, активные группы, активность за 14 дней, последние записи, топ воспитанников
- **Резервное копирование** — экспорт/импорт полного дампа (БД + файлы) в `tar.gz` - **Резервное копирование** — экспорт/импорт полного дампа (БД + файлы) в `tar.gz`
- **Настройки** — тексты футера, анти-спам интервал - **Хранилище файлов** — локальный каталог `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 - **Публикация через Tailscale** — приложение открывается по постоянному адресу `https://whatido.<tailnet>.ts.net` без проброса портов, внешнего IP и reverse-proxy
## Технологии ## Технологии
@@ -20,6 +22,8 @@
- Node.js + Express - Node.js + Express
- PostgreSQL (pg) - PostgreSQL (pg)
- Multer (загрузка файлов), Tar (бэкапы) - Multer (загрузка файлов), Tar (бэкапы)
- S3-совместимое хранилище (AWS SDK v3): сервис `s3` (SeaweedFS / MinIO)
- Redis: кэш, rate limit, баны IP, кэш сессий, pub/sub (SSE и воркеры)
- Lucide (иконки UI) - Lucide (иконки UI)
- Docker / Docker Compose - Docker / Docker Compose
- Tailscale (Serve / Funnel) — публикация по HTTPS - Tailscale (Serve / Funnel) — публикация по HTTPS
@@ -48,6 +52,7 @@ docker compose up -d --build
- **HTTP** `http://localhost:3003` — редирект на HTTPS - **HTTP** `http://localhost:3003` — редирект на HTTPS
- **HTTPS** `https://localhost:3443` — приложение (самоподписанный сертификат, примите предупреждение браузера) - **HTTPS** `https://localhost:3443` — приложение (самоподписанный сертификат, примите предупреждение браузера)
- **PostgreSQL** — доступен только внутри docker-сети (наружу не публикуется) - **PostgreSQL** — доступен только внутри docker-сети (наружу не публикуется)
- **Redis** — `127.0.0.1:6379` на хосте (только loopback), внутри сети — `redis:6379`
Управление: Управление:
@@ -59,6 +64,35 @@ docker compose down # остановка (данные сохраняю
> Приложение **не запустится** без `ADMIN_PASSWORD` (защита от пароля по умолчанию). > Приложение **не запустится** без `ADMIN_PASSWORD` (защита от пароля по умолчанию).
> `DB_PASSWORD` задаёт пароль пользователя `app` в PostgreSQL. > `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`), версия в сайдбаре будет пустой.
## Конфигурация ## Конфигурация
@@ -66,20 +100,51 @@ docker compose down # остановка (данные сохраняю
| Переменная | По умолчанию | Назначение | | Переменная | По умолчанию | Назначение |
|------------------|--------------------|-------------------------------------| |------------------|--------------------|-------------------------------------|
| `ADMIN_PASSWORD` | — (обязательно) | Пароль администратора (X-Admin-Token). Без него сервер не стартует | | `ADMIN_PASSWORD` | — (обязательно) | Пароль первого администратора, создаётся в пустой БД. Не является способом авторизации в API |
| `ADMIN_USERNAME` | `admin` | Логин первого администратора |
| `DB_PASSWORD` | — (обязательно) | Пароль пользователя `app` в PostgreSQL | | `DB_PASSWORD` | — (обязательно) | Пароль пользователя `app` в PostgreSQL |
| `REDIS_PASSWORD` | — (обязательно) | Пароль Redis (`--requirepass`) |
| `REDIS_PREFIX` | `whatido` | Префикс ключей Redis — свой для каждого инстанса |
| `REDIS_MAXMEMORY` | `256mb` | Лимит памяти Redis, при переполнении вытесняется LRU |
Пример `.env` (в репозитории — `.env.example`): Пример `.env` (в репозитории — `.env.example`):
``` ```
ADMIN_PASSWORD=сложный-пароль ADMIN_PASSWORD=сложный-пароль
DB_PASSWORD=случайная-длинная-строка DB_PASSWORD=случайная-длинная-строка
REDIS_PASSWORD=случайная-длинная-строка
``` ```
`DB_PASSWORD` подставляется в `docker-compose.yml` в `POSTGRES_PASSWORD` и `DATABASE_URL`. Если БД уже была инициализирована ранее, значение `DB_PASSWORD` должно совпадать с фактическим паролем пользователя `app` в БД (иначе приложение не подключится). `DB_PASSWORD` подставляется в `docker-compose.yml` в `POSTGRES_PASSWORD` и `DATABASE_URL`. Если БД уже была инициализирована ранее, значение `DB_PASSWORD` должно совпадать с фактическим паролем пользователя `app` в БД (иначе приложение не подключится).
Имя узла Tailscale задаётся в `docker-compose.yml` (`tailscale.hostname`, по умолчанию `whatido`). Имя узла Tailscale задаётся в `docker-compose.yml` (`tailscale.hostname`, по умолчанию `whatido`).
## ИИ-улучшение фото (photo-ai)
Сервис `photo-ai` (Real-ESRGAN) поднимается вместе со стеком и **включён по умолчанию**: `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_SOFT_MAX_RETRIES` | `60` | Сколько раз задание ждёт недоступный сервис, не увеличивая счётчик попыток; после исчерпания — честная ошибка |
| `PHOTO_AI_SOFT_BACKOFF_MS` | `10000` | Первая пауза перед мягким повтором |
| `PHOTO_AI_SOFT_BACKOFF_MAX_MS` | `300000` | Потолок паузы (задержка растёт вдвое) |
Порт `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
```
Состояние сервиса и воркера — в `GET /api/photo-jobs/status` (admin): `service` отдаёт health фото-сервиса
(`configured`, `reachable`, `latency_ms`, `error`), `ai_configured` — задан ли `PHOTO_AI_URL`,
`worker` — состояние очереди и конфигурация воркера.
## Публичный доступ через Tailscale ## Публичный доступ через Tailscale
Стек не требует внешнего IP и проброса портов: контейнер `tailscale` запускается с `network_mode: host`, входит в вашу tailnet-сеть и через **Serve** открывает приложение внутри tailnet, а через **Funnel** — в публичном интернете. Стек не требует внешнего IP и проброса портов: контейнер `tailscale` запускается с `network_mode: host`, входит в вашу tailnet-сеть и через **Serve** открывает приложение внутри tailnet, а через **Funnel** — в публичном интернете.
@@ -212,20 +277,186 @@ docker compose exec cloudflared wg show # есть handshake — VPN подн
- `group_photos` — фотохроника групп - `group_photos` — фотохроника групп
- `share_links` — публичные ссылки-витрины - `share_links` — публичные ссылки-витрины
- `settings` — пары ключ/значение (анти-спам интервал, футер) - `settings` — пары ключ/значение (анти-спам интервал, футер)
- `audit_log` — журнал действий (`action`, `target` JSONB, `ip`, `user_id`)
Схема инициализируется при первом запуске из `db/init.sql`; миграции существующей БД — в `db/migration.sql`. Схема инициализируется при первом запуске из `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 (нужен запущенный стек)
```
## Уведомления
Система уведомлений — журнал событий (`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/` - Скачать полный бэкап — `tar.gz`, содержащий `data.json` (все таблицы) и `uploads/`
- Восстановить из файла бэкапа - Восстановить из файла бэкапа
Архив формируется на сервере (`POST /api/backup`) и скачивается браузером по одноразовой ссылке (`GET /api/backup/<token>`, действует 30 минут) — загрузку можно возобновить при обрыве связи. Совместимый эндпоинт `GET /api/backup` отдаёт тот же архив сразу.
Также доступны скрипты на хосте: Также доступны скрипты на хосте:
```bash ```bash
@@ -233,9 +464,13 @@ docker compose exec cloudflared wg show # есть handshake — VPN подн
./scripts/restore.sh # восстановление из архива ./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` нет. - **Пароль администратора** обязателен (`ADMIN_PASSWORD`) — он создаёт первого админа в пустой БД; фолбэка на `admin` нет. Самостоятельной роли в API не даёт: доступ только по сессиям.
- **CORS отключён** — кросс-доменные запросы к API запрещены. - **CORS отключён** — кросс-доменные запросы к API запрещены.
- **Rate limiting** по IP на публичные роуты: `POST /api/entries` — 10 запросов / 15 мин, загрузка файлов и share-ссылки — 300 / 15 мин. - **Rate limiting** по IP на публичные роуты: `POST /api/entries` — 10 запросов / 15 мин, загрузка файлов и share-ссылки — 300 / 15 мин.
- **Загрузки** ограничены: 30 МБ суммарно на запись, 10 МБ на файл; заблокированы опасные расширения (`.html`, `.js`, `.svg`, `.xml`, `.exe` и др.); SVG не отдаётся inline. - **Загрузки** ограничены: 30 МБ суммарно на запись, 10 МБ на файл; заблокированы опасные расширения (`.html`, `.js`, `.svg`, `.xml`, `.exe` и др.); SVG не отдаётся inline.
@@ -261,24 +496,57 @@ docker compose exec cloudflared wg show # есть handshake — VPN подн
| `GET/POST/PUT/DELETE` | `/api/share/...`, `/api/links` | Публичные ссылки | | `GET/POST/PUT/DELETE` | `/api/share/...`, `/api/links` | Публичные ссылки |
| `GET` | `/api/backup` | Скачать бэкап | | `GET` | `/api/backup` | Скачать бэкап |
| `POST` | `/api/restore` | Восстановить из бэкапа | | `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` | Статистика | | `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 секунд.
## Структура проекта ## Структура проекта
``` ```
├── docker-compose.yml # сервисы: app + db + tailscale ├── docker-compose.yml # сервисы: app + db + redis + s3 (+ опционально tailscale)
├── docker-compose.minio.yml # оверрайд: S3-сервис на MinIO вместо SeaweedFS
├── .env.example # шаблон переменных окружения ├── .env.example # шаблон переменных окружения
├── Dockerfile # сборка образа (Node 20, генерация TLS-сертификата) ├── Dockerfile # сборка образа (Node 22, генерация TLS-сертификата)
├── server.js # Express-приложение ├── server.js # Express-приложение
├── worker.js # фоновый worker AI-проверки записей ├── 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 по поднятому стеку
├── worker.js # фоновый worker AI-проверки и ИИ-улучшения фото
├── certs/ # cert.pem приложения (монтируется в tailscale, в git не хранится) ├── certs/ # cert.pem приложения (монтируется в tailscale, в git не хранится)
├── db/ ├── db/
│ ├── init.sql # схема при первом запуске │ ├── init.sql # схема при первом запуске
│ └── migration.sql # миграции существующей БД │ └── migration.sql # миграции существующей БД
├── public/ # статика (HTML/CSS/JS админки и витрин) ├── public/ # статика (HTML/CSS/JS админки и витрин)
├── scripts/ # вспомогательные скрипты ├── scripts/ # вспомогательные скрипты (backup/restore/deploy, migrate-to-s3, storage-sync)
├── uploads/ # загруженные файлы (bind-монт, вне git) ├── uploads/ # локальные файлы и кэш миниатюр (bind-монт, вне git)
└── backups/ # локальные бэкапы └── backups/ # локальные бэкапы
``` ```
+478
View File
@@ -0,0 +1,478 @@
# 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.
- Веса (проверено 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`, использовать его не будем. Официальные веса доступны.
Решение: официальный модуль вендорится в `photo-ai/vendor/codeformer/` **только если** face-режим
CodeFormer реально понадобится; в рамках текущего плана не вендорим, дефолт
`PHOTO_AI_FACE_MODEL=gfpgan` (Stage 1–3), чтобы дефолтный путь работал без вендоринга.
- `FACE_REGISTRY` всегда содержит обе записи, но запись `codeformer` активна только если
`import codeformer` (с `photo-ai/vendor` в `sys.path`) успешен.
- Нет модуля → запись не попадает в `face_models` в `/health` и в список `/models`, UI её не показывает,
`POST /enhance` с `face_model=codeformer` → `400` с текстом «CodeFormer не установлен в образ,
доступен gfpgan». Сборка и CPU-режим не падают.
- `strength` валиден только для CodeFormer: при `face_model=gfpgan` и `strength`, отличном от 0.7,
→ `400` с пояснением, иначе параметр молча игнорировался бы.
- Веса CodeFormer качаются тем же загрузчиком в `/models/weights/codeformer.pth`.
- [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
- [ ] `ARG TORCH_VARIANT=cpu` и `ARG TORCH_INDEX=https://download.pytorch.org/whl/${TORCH_VARIANT}`;
один образ, `cu124` — вариант сборки.
- [ ] Сохранить патч `basicsr/data/degradations.py` (`functional_tensor` → `functional`) — без него basicsr
падает на torch ≥ 2.0. Не «упрощать» Dockerfile без проверки.
- [ ] Сохранить `libgl1 libglib2.0-0` (нужны facexlib), `numpy<2`, `opencv-python-headless`.
- [ ] Добавить `gfpgan`/`facexlib` (или вендоринг по `D4`), вендоренный CodeFormer копировать в образ
при наличии (`D5`).
- [ ] `ENV PHOTO_AI_MODELS_DIR=/models`; `MODEL_PATH` остаётся валидным алиасом.
- [ ] `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 — сохранить.
- [ ] Новый `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`).
Без него стек поднимается на любой машине.
- [ ] Сервису `app` добавить env `PHOTO_AI_FACE_MODEL` и `PHOTO_AI_FACE_TIMEOUT_MS`.
- [ ] **Приёмка:** 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 есть).
---
## 6. Stage 3. Face-режим (GFPGAN) в `app.py`
- [ ] `face=off` — только ESRGAN (текущее поведение).
- [ ] `face=face` — ESRGAN-фон + GFPGAN `paste_back`.
- [ ] `face=all` — ESRGAN(+`wdn` для `general-x4v3`) + мягкий денойз фона + лица.
- [ ] `strength` → `fidelity_weight` CodeFormer, `-w`; вне диапазона → `400`.
- [ ] Предупреждения (`warnings`: вход меньше 320×320, лиц не найдено, CodeFormer на CPU) в JSON-ответе.
- [ ] **Приёмка:** портрет 640×480 в `face` → `faces_found=1`, лицо резче; фото без лиц → `faces_found=0`
и результат не хуже `x2plus`; искусственный OOM (`PHOTO_AI_TILE=1024` на 4 ГБ) деградирует до CPU
без падения сервиса.
---
## 7. Stage 4. `worker.js` + `server.js`
### 7.1 `worker.js` (`createPhotoEnhanceWorker`)
- [ ] Таймауты из env: `PHOTO_AI_TIMEOUT_MS` (дефолт 300000), `PHOTO_AI_FACE_TIMEOUT_MS` (дефолт 600000)
для `face != 'off'`. Таймаут выбирается на основе `params`, а не глобально.
- [ ] `runAiEnhance(srcKey, params)`: отправляет `image`, `scale`, `model`, `face`, `face_model`, `strength`
из `params` (дефолты при пустых `params`); ставит `Accept: application/json`; принимает и JSON
(base64 → буфер), и сырой `image/jpeg` (старый photo-ai) без ошибки.
- [ ] `503` / `Retry-After` / сетевая недоступность → `sleep` c экспоненциальной задержкой, `return false`,
**`attempts` не инкрементится**; лимит мягких повторов (например 60) → одна честная ошибка
с понятным текстом (I5).
- [ ] `AbortError` от `AbortSignal.timeout` → сообщение «ИИ-сервис не ответил за N с» — это уже
«жёсткая» ошибка с попытками.
- [ ] `processOne`: `action` ∈ {`ai`, `ai_face`, `ai_upscale`} → AI-путь; `enhance` → sharp.
Пустые `params` при `ai_face` → `face='face'`.
- [ ] `applyResult`: в `logAudit(..., 'photo.job.preview', {...})` добавить `model`, `face`, `face_model`,
`device`, `elapsed_ms`, `faces_found`, `warnings`. Текст уведомления `photo.job.done` — по факту
режима (`job.action === 'ai_face'` → «Фото обработано нейросетью с восстановлением лиц»).
- [ ] `CONFIG` воркера: + `face_model`, `default_model`, `face_timeout_ms` (попадают в
`GET /api/photo-jobs/status` → `worker.config`).
### 7.2 `server.js`
- [ ] `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.
- [ ] `PHOTO_JOB_ACTIONS` (`server.js:2254`): + `'ai_face'`, `'ai_upscale'` (см. `D3`). Схема
`action VARCHAR(20)` вмещает новые значения — миграция не нужна.
- [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)
- [ ] `GET /api/photo-ai/health` (`requireAdmin`) — прямой прокси `/health` photo-ai для оператора.
- [ ] `getStackInfo()` (`server.js:769`) — блок `photo_ai` (`engine: 'Real-ESRGAN + GFPGAN'`, `driver`,
`device`, `device_name`, `vram_total_mb`, `models`). Без credentials, только hostname.
- [ ] Настройки (`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/доков — фактическое устройство задаёт контейнер).
- [ ] Дефолты новых настроек: `db/init.sql`, `db/migration.sql` (`INSERT ... ON CONFLICT DO NOTHING`),
`keys`/`defaults` в `/api/public-settings` (`server.js:1663–1664`). `photo_ai_enabled` (строка 1677)
сохранить.
- [ ] `createPhotoEnhanceWorker({...})` (`server.js:6045`): + `faceTimeoutMs: PHOTO_AI_FACE_TIMEOUT_MS`,
`defaultFaceModel: PHOTO_AI_FACE_MODEL`.
- [ ] `warnings` от photo-ai сохранять в аудит/уведомление, чтобы оператор видел, что face-режим не
сработал не из-за ошибки.
- [ ] **Приёмка:** задание с `face='face'` проходит `pending → processing → done`; `params` сохранены;
аудит содержит модель/устройство/время; остановка photo-ai на лету → задание дорабатывается после
возврата сервиса **без** `error` (I5); результат применяется и откатывается как раньше.
---
## 8. Stage 5. Фронтенд
- [ ] `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).
- [ ] `public/js/worker.js`: `PHOTO_ACTION_LABELS` + `ai_face: 'ИИ + лица'`, `ai_upscale: 'ИИ-апскейл'`;
в модалке сравнения — `model`, `device`, `faces_found`, `elapsed_ms` (данные из `params`/аудита, I6);
строка состояния учитывает `service.reachable === false` и `service.device`.
- [ ] `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`).
- [ ] `public/js/settings.js:372` `renderStackInfo()` — карточка «Фото-ИИ» из `stack.photo_ai`.
- [ ] `public/js/audit.js` — метки для новых кодов аудита (иначе в UI будет сырой код).
- [ ] **Приёмка:** из журнала доступны все 4 режима; после постановки видно «В очереди», затем сравнение
«Было/Стало» с моделью/устройством; в настройках видно фактическое устройство и VRAM.
---
## 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`, дефолт воркера/UI |
| `PHOTO_AI_LOAD_ALL` | `0` | `app.py` |
| `PHOTO_AI_FACE_MODE` | `off` | дефолт UI |
| `PHOTO_AI_JPEG_QUALITY` | `92` | `app.py` |
| `PHOTO_AI_TIMEOUT_MS` | `300000` | `worker.js` (сейчас хардкод) |
| `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. Порядок и правила выполнения
- [ ] Этапы выполняются строго 0 → 1 → 2 → 3 → 4 → 5 → 6. Каждый этап закрывается своей приёмкой
**до** начала следующего.
- [ ] Каждый завершённый чекбокс отмечать (`- [x]`) в этом файле по мере выполнения.
- [ ] Изменения в `AGENTS.md`/`.env.example`/`README.md` делать **в том же** изменении, что и код,
который они описывают (правило `AGENTS.md` про рассинхрон документации).
- [ ] Коммит на этап, а не на весь план: `app.py`+`Dockerfile`+compose → воркер/сервер → фронт → доки/тесты.
- [ ] Стоп-условия (сообщить оператору, не «чинить» самостоятельно): требуется `sudo` на хосте;
требуется установка `nvidia-container-toolkit`; нашёлся конфликт миграции БД; текущий
`/enhance` перестал давать эталонный результат (I1).
## 12. Готово, когда
- [ ] `photo-ai` стартует на CPU и на CUDA, устройство выбирается автоматически, `/health` показывает
фактическое устройство, имя GPU, VRAM, доступные и загруженные модели.
- [ ] Сценарий «🤖 ИИ» без параметров даёт результат, эквивалентный текущему.
- [ ] Доступны режимы: универсальный x2, быстрый x4, только лица (GFPGAN), лица + фон.
- [ ] Face-задание проходит полный цикл, результат виден в сравнении, применяется и откатывается.
- [ ] Отсутствие GPU, отсутствие `nvidia-ctk` и недоступный `photo-ai` не ломают приложение.
- [ ] `node api.smoketest.js` проходит; `AGENTS.md`, `README.md`, `.env.example` описывают новые
переменные и GPU-запуск.
+224
View File
@@ -0,0 +1,224 @@
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 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,
});
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); });
+60 -1
View File
@@ -21,9 +21,14 @@ CREATE TABLE IF NOT EXISTS groups (
day_of_week INT, day_of_week INT,
time_start TIME, time_start TIME,
time_end TIME, time_end TIME,
cover_path VARCHAR(255) 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 ( CREATE TABLE IF NOT EXISTS modules (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
name VARCHAR(200) NOT NULL UNIQUE, name VARCHAR(200) NOT NULL UNIQUE,
@@ -71,6 +76,7 @@ CREATE TABLE IF NOT EXISTS entries (
photo_path VARCHAR(255), photo_path VARCHAR(255),
photo_original_path VARCHAR(255), photo_original_path VARCHAR(255),
deleted_at TIMESTAMPTZ, deleted_at TIMESTAMPTZ,
purge_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT now() created_at TIMESTAMPTZ DEFAULT now()
); );
@@ -80,6 +86,7 @@ ALTER TABLE entries ADD COLUMN IF NOT EXISTS ai_status VARCHAR(20) NOT NULL DEFA
ALTER TABLE entries ADD COLUMN IF NOT EXISTS ai_checked_at TIMESTAMPTZ; 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 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 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_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 INDEX IF NOT EXISTS idx_entries_module_id ON entries(module_id);
@@ -120,6 +127,10 @@ INSERT INTO settings (key, value) VALUES ('share_show_group_photos', 'true')
ON CONFLICT (key) DO NOTHING; ON CONFLICT (key) DO NOTHING;
INSERT INTO settings (key, value) VALUES ('cookie_notice_text', 'Этот сайт использует cookie-файлы для корректной работы. Продолжая просмотр, вы соглашаетесь с их использованием.') INSERT INTO settings (key, value) VALUES ('cookie_notice_text', 'Этот сайт использует cookie-файлы для корректной работы. Продолжая просмотр, вы соглашаетесь с их использованием.')
ON CONFLICT (key) DO NOTHING; 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;
CREATE TABLE IF NOT EXISTS share_links ( CREATE TABLE IF NOT EXISTS share_links (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
@@ -241,3 +252,51 @@ CREATE TABLE IF NOT EXISTS audit_log (
); );
CREATE INDEX IF NOT EXISTS idx_audit_log_created_at ON audit_log(created_at DESC); 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;
+46
View File
@@ -92,6 +92,7 @@ CREATE TABLE IF NOT EXISTS entry_photos (
CREATE INDEX IF NOT EXISTS idx_entry_photos_entry_id ON entry_photos(entry_id); 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 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_original TEXT;
ALTER TABLE entries ADD COLUMN IF NOT EXISTS description_ai 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_status VARCHAR(20) NOT NULL DEFAULT 'pending';
@@ -115,6 +116,12 @@ ON CONFLICT (key) DO NOTHING;
INSERT INTO settings (key, value) VALUES ('cookie_notice_text', 'Этот сайт использует cookie-файлы для корректной работы. Продолжая просмотр, вы соглашаетесь с их использованием.') INSERT INTO settings (key, value) VALUES ('cookie_notice_text', 'Этот сайт использует cookie-файлы для корректной работы. Продолжая просмотр, вы соглашаетесь с их использованием.')
ON CONFLICT (key) DO NOTHING; 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 $$ DO $$
BEGIN BEGIN
IF NOT EXISTS (SELECT 1 FROM settings WHERE key = 'ai_autocheck_migrated') THEN IF NOT EXISTS (SELECT 1 FROM settings WHERE key = 'ai_autocheck_migrated') THEN
@@ -137,6 +144,8 @@ 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_start TIME;
ALTER TABLE groups ADD COLUMN IF NOT EXISTS time_end 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 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 ( CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
@@ -249,3 +258,40 @@ CREATE TABLE IF NOT EXISTS student_photos (
); );
CREATE INDEX IF NOT EXISTS idx_student_photos_student_id ON student_photos(student_id); 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;
+215
View File
@@ -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
};
+215
View File
@@ -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);
+26
View File
@@ -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
+81
View File
@@ -3,6 +3,7 @@ name: whatido
services: services:
db: db:
image: postgres:16-alpine image: postgres:16-alpine
restart: unless-stopped
environment: environment:
POSTGRES_DB: whereldo POSTGRES_DB: whereldo
POSTGRES_USER: app POSTGRES_USER: app
@@ -17,10 +18,46 @@ services:
timeout: 3s timeout: 3s
retries: 10 retries: 10
# 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:
- "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: app:
build: build:
context: . context: .
network: host network: host
args:
GIT_COMMIT: ${GIT_COMMIT:-}
GIT_COMMIT_DATE: ${GIT_COMMIT_DATE:-}
restart: unless-stopped
extra_hosts: extra_hosts:
- "host.docker.internal:host-gateway" - "host.docker.internal:host-gateway"
expose: expose:
@@ -31,6 +68,8 @@ services:
- "3443:3443" - "3443:3443"
environment: environment:
DATABASE_URL: postgres://app:${DB_PASSWORD}@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_PASSWORD: ${ADMIN_PASSWORD}
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin} ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
BACKUP_UPLOAD_LIMIT_MB: ${BACKUP_UPLOAD_LIMIT_MB:-500} BACKUP_UPLOAD_LIMIT_MB: ${BACKUP_UPLOAD_LIMIT_MB:-500}
@@ -40,12 +79,48 @@ services:
PHOTO_AI_URL: ${PHOTO_AI_URL:-http://photo-ai:8080} PHOTO_AI_URL: ${PHOTO_AI_URL:-http://photo-ai:8080}
NODE_ENV: production NODE_ENV: production
TZ: Europe/Moscow 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: depends_on:
db: db:
condition: service_healthy condition: service_healthy
redis:
condition: service_healthy
volumes: volumes:
- ./uploads:/app/uploads - ./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) без проброса портов. # Публикация через Tailscale (Serve / Funnel) без проброса портов.
# Приложение доступно по https://whatido.<tailnet>.ts.net # Приложение доступно по https://whatido.<tailnet>.ts.net
# tailscale: # tailscale:
@@ -116,6 +191,8 @@ services:
# ИИ-улучшение фотографий (Real-ESRGAN x2, CPU). # ИИ-улучшение фотографий (Real-ESRGAN x2, CPU).
# Поднимается вместе со стеком; если не нужен — PHOTO_AI_URL пустой в .env. # Поднимается вместе со стеком; если не нужен — PHOTO_AI_URL пустой в .env.
# Порт 8081 пробрасывается только на loopback хоста — наружу ничего не публикуется,
# хостовый 8080 уже занят text-corrector. Ручные проверки: curl http://127.0.0.1:8081/health
photo-ai: photo-ai:
build: ./photo-ai build: ./photo-ai
container_name: photo-ai container_name: photo-ai
@@ -126,8 +203,12 @@ services:
TZ: Europe/Moscow TZ: Europe/Moscow
volumes: volumes:
- photo-ai-models:/models - photo-ai-models:/models
ports:
- "127.0.0.1:8081:8080"
volumes: volumes:
pgdata: pgdata:
photo-ai-models: photo-ai-models:
redis-data:
s3-data:
+495 -2
View File
@@ -8,6 +8,7 @@
"name": "whereldo", "name": "whereldo",
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.1141.0",
"bcrypt": "^5.1.1", "bcrypt": "^5.1.1",
"express": "^4.21.0", "express": "^4.21.0",
"express-rate-limit": "^8.7.0", "express-rate-limit": "^8.7.0",
@@ -16,10 +17,319 @@
"lucide": "^1.44.0", "lucide": "^1.44.0",
"multer": "^1.4.5-lts.1", "multer": "^1.4.5-lts.1",
"pg": "^8.13.0", "pg": "^8.13.0",
"redis": "^5.12.1",
"sharp": "^0.34.5", "sharp": "^0.34.5",
"tar": "^7.4.3" "tar": "^7.4.3"
} }
}, },
"node_modules/@aws-sdk/checksums": {
"version": "3.1001.1",
"resolved": "https://registry.npmjs.org/@aws-sdk/checksums/-/checksums-3.1001.1.tgz",
"integrity": "sha512-x12Q17KYlJAd3nKf8LV5LV0vt8sh8/6YfQLGPtrGnQf/tW4jqxPGq5GPpuVitpQYM3eUR4XB7CbxZf751NMbLw==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.978.1",
"@aws-sdk/types": "^3.974.6",
"@smithy/core": "^3.35.0",
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/client-s3": {
"version": "3.1141.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1141.0.tgz",
"integrity": "sha512-uOVH37xGLenAdJkCPCin/JJG2PgWrFcSsDnQ9+C9Zq8N9Oalo5ol4xmn5fG28iWAlA/b/9boQZgHbMh+UsIhcg==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/checksums": "^3.1001.1",
"@aws-sdk/core": "^3.978.1",
"@aws-sdk/credential-provider-node": "^3.972.84",
"@aws-sdk/middleware-sdk-s3": "^3.972.77",
"@aws-sdk/signature-v4-multi-region": "^3.996.47",
"@aws-sdk/types": "^3.974.6",
"@smithy/core": "^3.35.0",
"@smithy/fetch-http-handler": "^5.8.0",
"@smithy/node-http-handler": "^4.12.1",
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/core": {
"version": "3.978.1",
"resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.978.1.tgz",
"integrity": "sha512-LbY9aGsEiznDWmUc30Nwv3aIX/+dbwTx8KfS0yOC3NPYMO+O91e6jkT1azf34FwjOndq8/Q+RcVVZz5xnerwdg==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/types": "^3.974.6",
"@aws-sdk/xml-builder": "^3.972.41",
"@aws/lambda-invoke-store": "^0.3.0",
"@smithy/core": "^3.35.0",
"@smithy/signature-v4": "^5.7.3",
"@smithy/types": "^4.19.0",
"bowser": "^2.11.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-env": {
"version": "3.972.72",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.72.tgz",
"integrity": "sha512-xTKO/FWJPozTIXbozVnVGoNBhaGba8TBcx+KyUjRVeOlXE+dUc7GTR1cLvu0uTdIdmemzaFbqqCshXeZA1fZew==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.978.1",
"@aws-sdk/types": "^3.974.6",
"@smithy/core": "^3.35.0",
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-http": {
"version": "3.972.74",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.74.tgz",
"integrity": "sha512-u91E/hT8f4d1xy0Jl7VG4nVKJ3lxbrZkoBTeSVoJdWBiSEUMwMS/9+e0H/aJVQV//Lt5wuzP+E69v4aRSsNTmw==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.978.1",
"@aws-sdk/types": "^3.974.6",
"@smithy/core": "^3.35.0",
"@smithy/fetch-http-handler": "^5.8.0",
"@smithy/node-http-handler": "^4.12.1",
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-ini": {
"version": "3.973.17",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.17.tgz",
"integrity": "sha512-ged4KXdBkvIC81bLvNHHuQKdKak/VXhQTR1NWYTTqW0474nlmsxy9O/vlgTIohDDWH3xpBdtVMZRyjb+DnocDA==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.978.1",
"@aws-sdk/credential-provider-env": "^3.972.72",
"@aws-sdk/credential-provider-http": "^3.972.74",
"@aws-sdk/credential-provider-login": "^3.972.79",
"@aws-sdk/credential-provider-process": "^3.972.72",
"@aws-sdk/credential-provider-sso": "^3.973.16",
"@aws-sdk/credential-provider-web-identity": "^3.972.78",
"@aws-sdk/nested-clients": "^3.997.46",
"@aws-sdk/types": "^3.974.6",
"@smithy/core": "^3.35.0",
"@smithy/credential-provider-imds": "^4.5.2",
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-login": {
"version": "3.972.79",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.79.tgz",
"integrity": "sha512-L+Z85anONJd8MaiuraO4wRxATCdEejBZ3K3eymzWI5JPXa9sOS9CkIm72PBKqXKX+Z9p9NGMX5AIMXm0LEflgw==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.978.1",
"@aws-sdk/nested-clients": "^3.997.46",
"@aws-sdk/types": "^3.974.6",
"@smithy/core": "^3.35.0",
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-node": {
"version": "3.972.84",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.84.tgz",
"integrity": "sha512-oHt854odINVwzwsh+c5x69j0ajm4DbqqqVJ+O1ECsCIZeMDAbzFpXItaqP7UZstJj/ATdTk/KFSH0LaNAgV+kA==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/credential-provider-env": "^3.972.72",
"@aws-sdk/credential-provider-http": "^3.972.74",
"@aws-sdk/credential-provider-ini": "^3.973.17",
"@aws-sdk/credential-provider-process": "^3.972.72",
"@aws-sdk/credential-provider-sso": "^3.973.16",
"@aws-sdk/credential-provider-web-identity": "^3.972.78",
"@aws-sdk/types": "^3.974.6",
"@smithy/core": "^3.35.0",
"@smithy/credential-provider-imds": "^4.5.2",
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-process": {
"version": "3.972.72",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.72.tgz",
"integrity": "sha512-rLIp2xbMjX/k9/od7APpqq1ZgXXnV0pOL1Th3ZsL8Wu0TRtBsDTVS8iPqcfRFcHakFxPvR04OSTv2ka2qOb/2A==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.978.1",
"@aws-sdk/types": "^3.974.6",
"@smithy/core": "^3.35.0",
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-sso": {
"version": "3.973.16",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.16.tgz",
"integrity": "sha512-IGihaJfFZYacJJr/odqILCoK7W/mvrZ7cuK7ECn3sAu4vLC6u0V8bS7mCGbdugJ8Aum2tnvqmx0F2MRFp2rn9g==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.978.1",
"@aws-sdk/nested-clients": "^3.997.46",
"@aws-sdk/token-providers": "3.1138.0",
"@aws-sdk/types": "^3.974.6",
"@smithy/core": "^3.35.0",
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-web-identity": {
"version": "3.972.78",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.78.tgz",
"integrity": "sha512-/y9WvNtlcPBGLR0qc1a+9J/xtYZfVczvLUOuXaVWylzttH7ewsxwHtjmiJSolNrVSDorIxHGHMU61CbonRkmwA==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.978.1",
"@aws-sdk/nested-clients": "^3.997.46",
"@aws-sdk/types": "^3.974.6",
"@smithy/core": "^3.35.0",
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/middleware-sdk-s3": {
"version": "3.972.77",
"resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.77.tgz",
"integrity": "sha512-E7W2UOeUoc+lg3uIfR/dM7ZwusHwhBQrKMnlkRv4EXRR+C0YtV1pg25xC7GdZIhXH+NAMgZPCbE7o5to2cjFiw==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.978.1",
"@aws-sdk/signature-v4-multi-region": "^3.996.47",
"@aws-sdk/types": "^3.974.6",
"@smithy/core": "^3.35.0",
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/nested-clients": {
"version": "3.997.46",
"resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.46.tgz",
"integrity": "sha512-oRxtBcka/JGHGs9l9p9IVajGoTP8vTPmoAzdHGy4Qcy9P5vPnDf6nhIeM/COQNY9k/OahImTRaLkHftoXvfcmQ==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.978.1",
"@aws-sdk/signature-v4-multi-region": "^3.996.47",
"@aws-sdk/types": "^3.974.6",
"@smithy/core": "^3.35.0",
"@smithy/fetch-http-handler": "^5.8.0",
"@smithy/node-http-handler": "^4.12.1",
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/signature-v4-multi-region": {
"version": "3.996.47",
"resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.47.tgz",
"integrity": "sha512-Zk08macMvQTHzQJCLJVkOlviVoqwYMrpXv4lmLN7b7sAbiMoOK7Go0NYdR5UeF+MW8LIbRmwrNy9u/5VvX1U5g==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/types": "^3.974.6",
"@smithy/signature-v4": "^5.7.3",
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/token-providers": {
"version": "3.1138.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1138.0.tgz",
"integrity": "sha512-GpyAr0DD63YOEmYFM6Df+gJuIgC92MMTiBK4FTKfxii5MJ9ge20epR7LyroulscYlG89J+ZB2ivFDPjvfQhzdw==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.978.1",
"@aws-sdk/nested-clients": "^3.997.46",
"@aws-sdk/types": "^3.974.6",
"@smithy/core": "^3.35.0",
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/types": {
"version": "3.974.6",
"resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.6.tgz",
"integrity": "sha512-v/clNZzZnDxGyvpHMOGpJKVXFAExJzUNAAjaWGdcx8QAcXLGwTaOkw33p5SHAi0YAioK32xB3hWwOekRVfmfKg==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/xml-builder": {
"version": "3.972.41",
"resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.41.tgz",
"integrity": "sha512-ctjVSyCMegrWfXlx6VqzSBFI6UqmQ5ZlnfMhdLIiWmhoH8UAQxSCP5N3OpG7X3k4LnS7ou74C4mt20+bfTW2aQ==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws/lambda-invoke-store": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.3.0.tgz",
"integrity": "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==",
"license": "Apache-2.0",
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@emnapi/runtime": { "node_modules/@emnapi/runtime": {
"version": "1.11.3", "version": "1.11.3",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
@@ -654,6 +964,159 @@
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/@redis/bloom": {
"version": "5.12.1",
"resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-5.12.1.tgz",
"integrity": "sha512-PUUfv+ms7jgPSBVoo/DN4AkPHj4D5TZSd6SbJX7egzBplkYUcKmHRE8RKia7UtZ8bSQbLguLvxVO+asKtQfZWA==",
"license": "MIT",
"engines": {
"node": ">= 18.19.0"
},
"peerDependencies": {
"@redis/client": "^5.12.1"
}
},
"node_modules/@redis/client": {
"version": "5.12.1",
"resolved": "https://registry.npmjs.org/@redis/client/-/client-5.12.1.tgz",
"integrity": "sha512-7aPGWeqA3uFm43o19umzdl16CEjK/JQGtSXVPevplTaOU3VJA/rseBC1QvYUz9lLDIMBimc4SW/zrW4S89BaCA==",
"license": "MIT",
"dependencies": {
"cluster-key-slot": "1.1.2"
},
"engines": {
"node": ">= 18.19.0"
},
"peerDependencies": {
"@node-rs/xxhash": "^1.1.0",
"@opentelemetry/api": ">=1 <2"
},
"peerDependenciesMeta": {
"@node-rs/xxhash": {
"optional": true
},
"@opentelemetry/api": {
"optional": true
}
}
},
"node_modules/@redis/json": {
"version": "5.12.1",
"resolved": "https://registry.npmjs.org/@redis/json/-/json-5.12.1.tgz",
"integrity": "sha512-eOze75esLve4vfqDel7aMX08CNaiLLQS2fV8mpRN9NxPe1rVR4vQyYiW/OgtGUysF6QOr9ANhfxABKNOJfXdKg==",
"license": "MIT",
"engines": {
"node": ">= 18.19.0"
},
"peerDependencies": {
"@redis/client": "^5.12.1"
}
},
"node_modules/@redis/search": {
"version": "5.12.1",
"resolved": "https://registry.npmjs.org/@redis/search/-/search-5.12.1.tgz",
"integrity": "sha512-ItlxbxC9cKI6IU1TLWoczwJCRb6TdmkEpWv05UrPawqaAnWGRu3rcIqsc5vN483T2fSociuyV1UkWIL5I4//2w==",
"license": "MIT",
"engines": {
"node": ">= 18.19.0"
},
"peerDependencies": {
"@redis/client": "^5.12.1"
}
},
"node_modules/@redis/time-series": {
"version": "5.12.1",
"resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-5.12.1.tgz",
"integrity": "sha512-c6JL6E3EcZJuNqKFz+KM+l9l5mpcQiKvTwgA3blt5glWJ8hjDk0yeHN3beE/MpqYIQ8UEX44ItQzgkE/gCBELQ==",
"license": "MIT",
"engines": {
"node": ">= 18.19.0"
},
"peerDependencies": {
"@redis/client": "^5.12.1"
}
},
"node_modules/@smithy/core": {
"version": "3.35.0",
"resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.35.0.tgz",
"integrity": "sha512-zRMhfkByhT2snNdr1si24vJitU6Cr9ix2MikUfWmkAgp4jrNP0GcKSP5YvwQ+TlI8AZXER5QOGJn3JsVtSD9/A==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@smithy/credential-provider-imds": {
"version": "4.5.2",
"resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.5.2.tgz",
"integrity": "sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/core": "^3.33.2",
"@smithy/types": "^4.17.2",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@smithy/fetch-http-handler": {
"version": "5.8.0",
"resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.8.0.tgz",
"integrity": "sha512-ycSJu3tFAQ4v04CBB0agqFMVsSQ1iG3yw+SpgxRqKfaURpQD4CZ8Wn0zPMmSnOuTpTh65Vz+EA0rMrw089wvkA==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/core": "^3.33.3",
"@smithy/types": "^4.18.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@smithy/node-http-handler": {
"version": "4.12.1",
"resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.12.1.tgz",
"integrity": "sha512-ThMkboGeONWXAelq9FvGsuJC4rOi+qyC4/zhUF58xYpxUg5sQKx2VXZYJmtNjr4dSuBJ1HeJXETQILCz3wOHvw==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/core": "^3.33.3",
"@smithy/types": "^4.18.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@smithy/signature-v4": {
"version": "5.7.4",
"resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.7.4.tgz",
"integrity": "sha512-tHy0K0VtqNd5Y7Y41h0a0Lhh0L1GzC08dTWg0F7vRJWFtTENg7IZikf3wQkanYIRdb7ngoIPMTmqgUi401fEeQ==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/core": "^3.35.0",
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@smithy/types": {
"version": "4.19.0",
"resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.19.0.tgz",
"integrity": "sha512-r7jh49VJxGerfAcTQA6gXcKc+98zOp/tqRwzYjgOE+iSQsP6cEU1hq2QzbuipmP68QtYdY9wKEhiCQZIzHgZ4Q==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/abbrev": { "node_modules/abbrev": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz",
@@ -807,6 +1270,12 @@
"npm": "1.2.8000 || >= 1.4.16" "npm": "1.2.8000 || >= 1.4.16"
} }
}, },
"node_modules/bowser": {
"version": "2.14.1",
"resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz",
"integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==",
"license": "MIT"
},
"node_modules/brace-expansion": { "node_modules/brace-expansion": {
"version": "1.1.18", "version": "1.1.18",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
@@ -881,6 +1350,15 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/cluster-key-slot": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
"integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/color-support": { "node_modules/color-support": {
"version": "1.1.3", "version": "1.1.3",
"resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz",
@@ -2067,6 +2545,22 @@
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/redis": {
"version": "5.12.1",
"resolved": "https://registry.npmjs.org/redis/-/redis-5.12.1.tgz",
"integrity": "sha512-LDsoVvb/CpoV9EN3FXvgvSHNJWuCIzl9MiO3ppOevuGLpSGJhwfQjpEwfFJcQvNSddHADDdZaWx0HnmMxRXG7g==",
"license": "MIT",
"dependencies": {
"@redis/bloom": "5.12.1",
"@redis/client": "5.12.1",
"@redis/json": "5.12.1",
"@redis/search": "5.12.1",
"@redis/time-series": "5.12.1"
},
"engines": {
"node": ">= 18.19.0"
}
},
"node_modules/rimraf": { "node_modules/rimraf": {
"version": "3.0.2", "version": "3.0.2",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
@@ -2402,8 +2896,7 @@
"version": "2.8.1", "version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD", "license": "0BSD"
"optional": true
}, },
"node_modules/type-is": { "node_modules/type-is": {
"version": "1.6.18", "version": "1.6.18",
+2
View File
@@ -6,6 +6,7 @@
"start": "node server.js" "start": "node server.js"
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.1141.0",
"bcrypt": "^5.1.1", "bcrypt": "^5.1.1",
"express": "^4.21.0", "express": "^4.21.0",
"express-rate-limit": "^8.7.0", "express-rate-limit": "^8.7.0",
@@ -14,6 +15,7 @@
"lucide": "^1.44.0", "lucide": "^1.44.0",
"multer": "^1.4.5-lts.1", "multer": "^1.4.5-lts.1",
"pg": "^8.13.0", "pg": "^8.13.0",
"redis": "^5.12.1",
"sharp": "^0.34.5", "sharp": "^0.34.5",
"tar": "^7.4.3" "tar": "^7.4.3"
}, },
+816 -41
View File
@@ -1,73 +1,848 @@
import io
import os
import asyncio import asyncio
import base64
import importlib.util
import logging
import os
import platform
import re
import shutil
import sys
import threading import threading
import time
import urllib.request import urllib.request
from collections import OrderedDict
from contextlib import contextmanager
from fastapi import FastAPI, File, Form, UploadFile
from fastapi.responses import Response
import numpy as np
import cv2 import cv2
import numpy as np
import torch import torch
from basicsr.archs.rrdbnet_arch import RRDBNet 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 from realesrgan import RealESRGANer
MODEL_PATH = os.environ.get('MODEL_PATH', '/models/RealESRGAN_x2plus.pth') VENDOR_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'vendor')
MODEL_URL = 'https://github.com/xinntao/Real-ESRGAN/releases/download/v0.2.1/RealESRGAN_x2plus.pth' if os.path.isdir(VENDOR_DIR) and VENDOR_DIR not in sys.path:
MAX_PIXELS = int(os.environ.get('MAX_INPUT_PIXELS', str(4_000_000))) sys.path.append(VENDOR_DIR)
lock = threading.Lock() log = logging.getLogger('photo-ai')
upsampler = None if not log.handlers:
app = FastAPI() 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
POOL_LIMIT = 2
WARMUP_SIZE = 64
RETRY_AFTER_SEC = 5
def ensure_model(): def env_text(name, default=''):
if not os.path.exists(MODEL_PATH): value = os.environ.get(name)
os.makedirs(os.path.dirname(MODEL_PATH), exist_ok=True) if value is None:
tmp = MODEL_PATH + '.tmp' return default
urllib.request.urlretrieve(MODEL_URL, tmp) value = value.strip()
os.replace(tmp, MODEL_PATH) return value or default
def load_model(): def env_flag(name, default):
global upsampler value = env_text(name).lower()
ensure_model() if not value:
model = RRDBNet(num_in_ch=3, num_out_ch=3, scale=2, num_feat=64, num_block=23, num_grow_ch=32) 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')
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
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):
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 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
download_weight(spec['url'], target, spec['min_bytes'])
return target
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( upsampler = RealESRGANer(
scale=2, scale=spec['scale'],
model_path=MODEL_PATH, model_path=model_path,
model=model, dni_weight=dni_weight,
tile=256, model=spec['arch'](),
tile_pad=10, tile=state['tile'],
pre_pad=0, tile_pad=TILE_PAD,
half=False, pre_pad=PRE_PAD,
device='cpu', 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']),
)
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)
net = CodeFormer(dim_embd=512, codebook_size=1024, n_head=8, n_layers=9,
connect_list=['32', '64', '128', '256'], device=state['device'], fp16=False)
net.load_state_dict(torch.load(path, map_location=lambda storage, loc: storage))
net.eval()
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(net.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 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 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():
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') @app.on_event('startup')
async def startup(): async def startup():
await asyncio.to_thread(load_model) 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') @app.get('/health')
def health(): def health():
return {'ok': upsampler is not None} 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') @app.post('/enhance')
async def enhance(image: UploadFile = File(...), scale: int = Form(2)): 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() data = await image.read()
img = cv2.imdecode(np.frombuffer(data, np.uint8), cv2.IMREAD_COLOR) img = cv2.imdecode(np.frombuffer(data, np.uint8), cv2.IMREAD_COLOR)
if img is None: if img is None:
return Response('bad image', status_code=400) raise EnhanceError('bad image', 400)
if img.shape[0] * img.shape[1] > MAX_PIXELS: if img.shape[0] * img.shape[1] > MAX_PIXELS:
r = (MAX_PIXELS / (img.shape[0] * img.shape[1])) ** 0.5 ratio = (MAX_PIXELS / (img.shape[0] * img.shape[1])) ** 0.5
img = cv2.resize(img, (int(img.shape[1] * r), int(img.shape[0] * r)), interpolation=cv2.INTER_AREA) 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) outscale = min(max(int(scale), 2), 4)
with lock: fmt = output_format(image.filename)
out, _ = upsampler.enhance(img, outscale=outscale) warnings = []
ok, enc = cv2.imencode('.jpg', out, [int(cv2.IMWRITE_JPEG_QUALITY), 92]) output, faces_found = await asyncio.to_thread(run_guarded, img, outscale, model_name,
if not ok: face_mode, face_name, float(strength), warnings)
return Response('encode failed', status_code=500) payload, media_type = encode_image(output, fmt, int(quality))
return Response(enc.tobytes(), media_type='image/jpeg') 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)})
+117 -16
View File
@@ -3,26 +3,50 @@
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:var(--bg);color:var(--text);min-height:100dvh} body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:var(--bg);color:var(--text);min-height:100dvh}
.layout{display:flex;min-height:100dvh} .layout{display:flex;min-height:100dvh}
.sidebar{width:var(--sidebar);background:var(--card);border-right:1px solid var(--border);padding:24px 0;position:fixed;top:0;left:0;height:100dvh;display:flex;flex-direction:column} .sidebar{width:var(--sidebar);background:var(--card);border-right:1px solid var(--border);position:fixed;top:0;left:0;height:100dvh;display:flex;flex-direction:column;z-index:50;box-shadow:1px 0 0 rgba(0,0,0,.02);user-select:none}
.sidebar .logo{font-size:1.1rem;font-weight:700;padding:0 20px 20px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:10px;min-width:0} .sidebar .logo{height:58px;padding:0 16px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:10px;min-width:0;flex-shrink:0;font-size:1.02rem;font-weight:700;letter-spacing:-.01em;background:var(--card)}
.sidebar .logo-ic{height:28px;width:auto;min-width:28px;background:var(--bg);border:1px solid var(--border);display:flex;align-items:center;justify-content:center;overflow:hidden;flex-shrink:0} .sidebar .logo-ic{height:28px;width:auto;min-width:28px;border-radius:6px;background:var(--bg);border:1px solid var(--border);display:flex;align-items:center;justify-content:center;overflow:hidden;flex-shrink:0}
.sidebar .logo-ic img{max-width:100%;max-height:100%;object-fit:contain} .sidebar .logo-ic img{max-width:100%;max-height:100%;object-fit:contain}
.sidebar #sidebarName{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0} .sidebar #sidebarName{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0}
.user-box{padding:14px 20px;border-bottom:1px solid var(--border);display:flex;flex-direction:column;gap:2px} .user-box{padding:10px 14px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:10px;flex-shrink:0;background:var(--card)}
.user-box .user-name{font-weight:600;font-size:.9rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .user-box .user-avatar{width:32px;height:32px;border-radius:8px;background:rgba(37,99,235,.08);color:var(--accent);display:flex;align-items:center;justify-content:center;flex-shrink:0}
.user-box .user-role{color:var(--muted);font-size:.72rem} .user-box .user-avatar svg{width:16px;height:16px}
.sidebar nav{flex:1;padding:12px 0} .user-box .user-meta{display:flex;flex-direction:column;min-width:0;flex:1}
.sidebar nav a{display:flex;align-items:center;gap:10px;padding:10px 20px;color:var(--muted);text-decoration:none;font-size:.9rem;transition:all .15s;border-left:3px solid transparent} .user-box .user-name{font-weight:600;font-size:.85rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text);line-height:1.25}
.user-box .user-role{color:var(--muted);font-size:.7rem;font-weight:500;line-height:1.2}
.nav-scroll-wrap{flex:1 1 auto;min-height:0;overflow-y:auto;overflow-x:hidden;overscroll-behavior:contain;padding:8px 0;scrollbar-width:thin;scrollbar-color:rgba(0,0,0,.15) transparent}
.nav-scroll-wrap::-webkit-scrollbar{width:5px}
.nav-scroll-wrap::-webkit-scrollbar-track{background:transparent}
.nav-scroll-wrap::-webkit-scrollbar-thumb{background:rgba(0,0,0,.12);border-radius:10px}
.nav-scroll-wrap::-webkit-scrollbar-thumb:hover{background:rgba(0,0,0,.22)}
.sidebar nav{display:flex;flex-direction:column;gap:10px;padding:0 8px}
.nav-group{display:flex;flex-direction:column;gap:2px}
.nav-group-title{font-size:.66rem;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);padding:6px 10px 4px;user-select:none;opacity:.8}
.nav-items{display:flex;flex-direction:column;gap:1px}
.sidebar nav a{display:flex;align-items:center;gap:10px;padding:7px 10px;border-radius:8px;color:var(--muted);text-decoration:none;font-size:.84rem;font-weight:500;line-height:1.3;transition:all .14s ease;position:relative}
.sidebar nav a:hover{color:var(--text);background:var(--bg)} .sidebar nav a:hover{color:var(--text);background:var(--bg)}
.sidebar nav a.active{color:var(--accent);background:rgba(37,99,235,.06);border-left-color:var(--accent);font-weight:500} .sidebar nav a.active{color:var(--accent);background:rgba(37,99,235,.08);font-weight:600}
.sidebar nav a i,.sidebar .bottom a i{width:16px;height:16px;flex-shrink:0} .sidebar nav a.active::before{content:'';position:absolute;left:0;top:6px;bottom:6px;width:3px;border-radius:0 3px 3px 0;background:var(--accent)}
.sidebar nav a i,.sidebar .bottom a i{width:16px;height:16px;flex-shrink:0;transition:transform .14s ease}
.sidebar nav a:hover i{transform:scale(1.06)}
.sidebar nav a .nav-text{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.sidebar-badge{min-width:18px;height:18px;padding:0 6px;border-radius:999px;background:#ef4444;color:#fff;font-size:.65rem;font-weight:700;line-height:18px;text-align:center;display:inline-flex;align-items:center;justify-content:center;flex-shrink:0;margin-left:auto;box-shadow:0 1px 3px rgba(239,68,68,.35)}
.sidebar-badge[hidden]{display:none}
.sidebar nav a[data-nav=dashboard] svg{color:#6366f1} .sidebar nav a[data-nav=dashboard] svg{color:#6366f1}
.sidebar nav a[data-nav=journal] svg{color:#0ea5e9} .sidebar nav a[data-nav=journal] svg{color:#0ea5e9}
.sidebar nav a[data-nav=notifications] svg{color:#f59e0b}
.sidebar nav a[data-nav=students] svg{color:#14b8a6} .sidebar nav a[data-nav=students] svg{color:#14b8a6}
.sidebar nav a[data-nav=groups] svg{color:#8b5cf6} .sidebar nav a[data-nav=groups] svg{color:#8b5cf6}
.sidebar nav a[data-nav=files] svg{color:#f59e0b} .sidebar nav a[data-nav=files] svg{color:#f59e0b}
.sidebar nav a[data-nav=photos] svg{color:#0891b2}
.sidebar nav a[data-nav=links] svg{color:#06b6d4} .sidebar nav a[data-nav=links] svg{color:#06b6d4}
.sidebar nav a[data-nav=trash] svg{color:#ef4444} .sidebar nav a[data-nav=trash] svg{color:#ef4444}
.sidebar nav a[data-nav=modules] svg{color:#3b82f6}
.sidebar nav a[data-nav=branches] svg{color:#10b981} .sidebar nav a[data-nav=branches] svg{color:#10b981}
.sidebar nav a[data-nav=users] svg{color:#ec4899} .sidebar nav a[data-nav=users] svg{color:#ec4899}
.sidebar nav a[data-nav=worker] svg{color:#a855f7} .sidebar nav a[data-nav=worker] svg{color:#a855f7}
@@ -30,12 +54,18 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
.sidebar nav a[data-nav=bans] svg{color:#dc2626} .sidebar nav a[data-nav=bans] svg{color:#dc2626}
.sidebar nav a[data-nav=settings] svg{color:#475569} .sidebar nav a[data-nav=settings] svg{color:#475569}
.sidebar nav a.active svg{color:var(--accent)} .sidebar nav a.active svg{color:var(--accent)}
.sidebar .bottom a[data-nav=home] svg{color:#2563eb}
.sidebar .bottom{padding:12px 20px;border-top:1px solid var(--border)} .sidebar .bottom{padding:10px 10px 12px;border-top:1px solid var(--border);display:flex;flex-direction:column;gap:8px;flex-shrink:0;background:var(--card)}
.sidebar .version{color:var(--muted);font-size:.72rem;padding:0 20px 8px} .sidebar .bottom-actions{display:flex;flex-direction:column;gap:1px}
.sidebar .bottom a{display:flex;align-items:center;gap:10px;color:var(--muted);text-decoration:none;font-size:.8rem;padding:6px 0} .sidebar .bottom-actions a{display:flex;align-items:center;gap:10px;padding:6px 10px;border-radius:8px;color:var(--muted);text-decoration:none;font-size:.82rem;font-weight:500;transition:all .14s ease;position:relative}
.sidebar .bottom a:hover{color:var(--text)} .sidebar .bottom-actions a:hover{color:var(--text);background:var(--bg)}
.sidebar .bottom .logout{color:#ef4444;cursor:pointer;margin-top:4px} .sidebar .bottom-actions a[data-nav=home] svg{color:#2563eb}
.sidebar .bottom-actions .notify-bell svg{color:#f59e0b}
.sidebar .bottom-footer{display:flex;align-items:center;justify-content:space-between;gap:8px;padding-top:6px;border-top:1px dashed var(--border)}
.sidebar .bottom-footer .logout-btn{display:inline-flex;align-items:center;gap:6px;padding:4px 8px;border-radius:6px;color:#ef4444;text-decoration:none;font-size:.78rem;font-weight:600;cursor:pointer;transition:background .14s ease}
.sidebar .bottom-footer .logout-btn:hover{background:rgba(239,68,68,.08)}
.sidebar .bottom-footer .logout-btn svg{width:14px;height:14px;color:#ef4444}
.sidebar .bottom-footer .version{color:var(--muted);font-size:.7rem;font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;opacity:.8}
.main{flex:1;margin-left:var(--sidebar);padding:32px} .main{flex:1;margin-left:var(--sidebar);padding:32px}
.main h2{font-size:1.3rem;font-weight:600;margin-bottom:20px} .main h2{font-size:1.3rem;font-weight:600;margin-bottom:20px}
@@ -257,6 +287,11 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
.del-btn{background:none;border:none;color:#ef4444;cursor:pointer;font-size:1.15rem;padding:4px;flex-shrink:0} .del-btn{background:none;border:none;color:#ef4444;cursor:pointer;font-size:1.15rem;padding:4px;flex-shrink:0}
.del-btn:hover{opacity:.8} .del-btn:hover{opacity:.8}
.trash-time{color:var(--muted);font-size:.75rem;margin-top:8px} .trash-time{color:var(--muted);font-size:.75rem;margin-top:8px}
.pending-toggle{margin-bottom:16px;max-width:680px}
.trash-section{margin-top:24px}
.trash-section h4{margin:16px 0 8px;font-size:.95rem;font-weight:600}
.card.pending{border-color:#d97706;background:linear-gradient(180deg,rgba(217,119,6,.06),transparent 55%)}
.trash-time.warn{color:#d97706;font-weight:600}
/* --- Настройки (UX/UI) --- */ /* --- Настройки (UX/UI) --- */
.page-head{display:flex;align-items:baseline;gap:14px;margin-bottom:24px} .page-head{display:flex;align-items:baseline;gap:14px;margin-bottom:24px}
@@ -389,6 +424,7 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
.sys-bar-wrap{height:6px;background:var(--border);border-radius:3px;overflow:hidden;margin-top:4px} .sys-bar-wrap{height:6px;background:var(--border);border-radius:3px;overflow:hidden;margin-top:4px}
.sys-bar{height:100%;transition:width .3s,background .3s} .sys-bar{height:100%;transition:width .3s,background .3s}
.sys-meta{font-size:.7rem;color:var(--muted);margin-top:4px} .sys-meta{font-size:.7rem;color:var(--muted);margin-top:4px}
.sys-inline{display:inline-flex;align-items:center;gap:7px;justify-content:flex-end}
.actions-row{display:flex;align-items:center;gap:12px;flex-wrap:wrap} .actions-row{display:flex;align-items:center;gap:12px;flex-wrap:wrap}
.audit-table{width:100%;border-collapse:collapse;font-size:.85rem} .audit-table{width:100%;border-collapse:collapse;font-size:.85rem}
@@ -401,6 +437,27 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
.audit-details:hover{text-decoration:underline} .audit-details:hover{text-decoration:underline}
.audit-details-body{background:var(--bg);border:1px solid var(--border);border-radius:8px;padding:12px;max-height:60vh;overflow:auto} .audit-details-body{background:var(--bg);border:1px solid var(--border);border-radius:8px;padding:12px;max-height:60vh;overflow:auto}
.audit-details-body pre{margin:0;white-space:pre-wrap;word-break:break-word;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:.78rem;line-height:1.45;color:var(--text)} .audit-details-body pre{margin:0;white-space:pre-wrap;word-break:break-word;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:.78rem;line-height:1.45;color:var(--text)}
.audit-details-body.audit-loading{opacity:.6}
.audit-details-head{display:flex;flex-wrap:wrap;gap:6px;margin-bottom:10px}
.audit-pill{background:var(--card);border:1px solid var(--border);border-radius:999px;padding:3px 10px;font-size:.72rem;color:var(--muted)}
.audit-changes{display:flex;flex-direction:column;gap:12px}
.audit-change{background:var(--card);border:1px solid var(--border);border-radius:8px;padding:10px 12px}
.audit-change-title{font-weight:600;font-size:.82rem;margin-bottom:6px}
.audit-change-stats{display:flex;flex-wrap:wrap;gap:10px;font-size:.75rem;margin-bottom:8px}
.audit-change-line{font-size:.8rem;line-height:1.6;word-break:break-word}
.audit-tag-del,.audit-tag-add{display:inline-block;min-width:52px;font-size:.7rem;text-transform:uppercase;letter-spacing:.03em;padding:1px 6px;border-radius:4px;margin-right:6px}
.audit-tag-del{background:rgba(239,68,68,.14);color:#dc2626}
.audit-tag-add{background:rgba(22,163,74,.14);color:#16a34a}
.audit-stat-add{color:#16a34a;font-weight:600}
.audit-stat-del{color:#dc2626;font-weight:600}
.audit-stat-muted{color:var(--muted)}
.audit-diff{white-space:pre-wrap;word-break:break-word;font-size:.85rem;line-height:1.6;background:var(--bg);border:1px solid var(--border);border-radius:6px;padding:10px;max-height:40vh;overflow:auto}
.audit-diff-add{background:rgba(22,163,74,.18);color:#14532d;text-decoration:none;border-radius:3px;padding:0 2px}
.audit-diff-del{background:rgba(239,68,68,.16);color:#7f1d1d;border-radius:3px;padding:0 2px}
.audit-truncated{margin-top:6px;font-size:.72rem;color:#b45309}
.audit-raw{margin-top:12px;font-size:.78rem;color:var(--muted)}
.audit-raw summary{cursor:pointer}
.audit-raw pre{margin-top:8px;background:var(--card);border:1px solid var(--border);border-radius:6px;padding:10px}
/* --- AI Correction --- */ /* --- AI Correction --- */
.ai-btn{background:none;border:none;color:var(--muted);cursor:pointer;font-size:1.1rem;padding:4px 8px;border-radius:6px;transition:color .15s,background .15s;vertical-align:middle;margin-left:6px} .ai-btn{background:none;border:none;color:var(--muted);cursor:pointer;font-size:1.1rem;padding:4px 8px;border-radius:6px;transition:color .15s,background .15s;vertical-align:middle;margin-left:6px}
@@ -541,4 +598,48 @@ body[data-page=settings] .page-head{align-items:flex-end}
} }
@media (prefers-reduced-motion: reduce){.save-bar{animation:none}.toggle-track,.toggle-thumb{transition:none}.icon-btn.spinning svg{animation:none}} @media (prefers-reduced-motion: reduce){.save-bar{animation:none}.toggle-track,.toggle-thumb{transition:none}.icon-btn.spinning svg{animation:none}}
/* --- Notifications --- */
.notify-bell{position:relative}
.notify-badge{position:absolute;left:20px;top:-3px;min-width:16px;height:16px;padding:0 4px;border-radius:999px;background:#ef4444;color:#fff;font-size:.62rem;font-weight:700;line-height:16px;text-align:center}
.notify-badge[hidden]{display:none}
.notify-panel{position:fixed;left:12px;bottom:96px;width:min(380px,calc(100vw - 24px));max-height:min(70vh,520px);display:flex;flex-direction:column;background:var(--card);border:1px solid var(--border);border-radius:14px;box-shadow:0 18px 40px rgba(0,0,0,.18);z-index:90;overflow:hidden}
.notify-panel[hidden]{display:none}
.notify-head{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:12px 14px;border-bottom:1px solid var(--border);font-size:.9rem}
.notify-list{overflow-y:auto;flex:1}
.notify-empty{padding:22px 14px;color:var(--muted);font-size:.85rem;text-align:center}
.notify-item{display:flex;gap:10px;padding:11px 14px;border-bottom:1px solid var(--border);text-decoration:none;color:var(--text);transition:background .15s}
.notify-item:hover{background:var(--bg)}
.notify-item:last-child{border-bottom:none}
.notify-item.unread{background:rgba(37,99,235,.05)}
.notify-item.unread .notify-title{font-weight:600}
.notify-item.level-warning .notify-ic{background:rgba(245,158,11,.14);color:#f59e0b}
.notify-item.level-critical .notify-ic{background:rgba(239,68,68,.14);color:#ef4444}
.notify-ic{display:flex;align-items:center;justify-content:center;width:28px;height:28px;border-radius:8px;background:var(--bg);color:var(--muted);flex-shrink:0}
.notify-ic svg{width:15px;height:15px}
.notify-body{display:flex;flex-direction:column;gap:2px;min-width:0}
.notify-title{font-size:.84rem;line-height:1.3}
.notify-text{font-size:.76rem;color:var(--muted);line-height:1.35;overflow-wrap:anywhere}
.notify-time{font-size:.7rem;color:var(--muted)}
.notify-foot{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:10px 14px;border-top:1px solid var(--border);font-size:.78rem}
.notify-foot a{color:var(--accent);text-decoration:none}
.notify-foot a:hover{text-decoration:underline}
.notify-link{background:none;border:none;color:var(--accent);font-size:.78rem;cursor:pointer;padding:0}
.notify-link:hover{text-decoration:underline}
.notify-types{display:flex;flex-direction:column;gap:6px;margin-top:6px}
.notify-type-badge{font-size:.65rem;font-weight:700;color:var(--muted);background:var(--bg);border:1px solid var(--border);border-radius:999px;padding:1px 7px;margin-left:6px;white-space:nowrap}
.notify-page-head{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:14px}
.notify-page-head .grow{flex:1}
.notify-page-list{display:flex;flex-direction:column;gap:8px}
.notify-card{display:flex;gap:12px;padding:14px 16px;background:var(--card);border:1px solid var(--border);border-radius:12px}
.notify-card.unread{border-color:rgba(37,99,235,.45);background:rgba(37,99,235,.04)}
.notify-card.level-warning{border-left:3px solid #f59e0b}
.notify-card.level-critical{border-left:3px solid #ef4444}
.notify-card .notify-body{flex:1}
.notify-card .notify-title{font-size:.92rem}
.notify-card .notify-text{font-size:.82rem}
.notify-card-actions{display:flex;flex-direction:column;gap:6px;align-items:flex-end}
@media(max-width:1024px){
.notify-panel{left:12px;right:12px;width:auto;bottom:12px}
}
+272 -6
View File
@@ -49,8 +49,10 @@ function buildSidebar(active) {
const base = [ const base = [
{ page: 'dashboard', label: 'Дашборд', icon: 'layout-dashboard' }, { page: 'dashboard', label: 'Дашборд', icon: 'layout-dashboard' },
{ page: 'journal', label: 'Журнал', icon: 'book-open' }, { page: 'journal', label: 'Журнал', icon: 'book-open' },
{ page: 'notifications', label: 'Уведомления', icon: 'bell', badge: true },
{ page: 'students', label: 'Ученики', icon: 'graduation-cap' }, { page: 'students', label: 'Ученики', icon: 'graduation-cap' },
{ page: 'groups', label: 'Группы', icon: 'users' }, { page: 'groups', label: 'Группы', icon: 'users' },
{ page: 'photos', label: 'Фото', icon: 'Image' },
{ page: 'files', label: 'Файлы', icon: 'folder' }, { page: 'files', label: 'Файлы', icon: 'folder' },
{ page: 'links', label: 'Ссылки', icon: 'link' }, { page: 'links', label: 'Ссылки', icon: 'link' },
{ page: 'trash', label: 'Корзина', icon: 'trash-2' }, { page: 'trash', label: 'Корзина', icon: 'trash-2' },
@@ -64,29 +66,70 @@ function buildSidebar(active) {
{ page: 'bans', label: 'Блокировки', icon: 'shield-off' }, { page: 'bans', label: 'Блокировки', icon: 'shield-off' },
{ page: 'settings', label: 'Настройки', icon: 'settings' }, { page: 'settings', label: 'Настройки', icon: 'settings' },
]; ];
const nav = [...base, ...(isAdmin() ? adminNav : [])];
const userName = currentUser?.name || currentUser?.username || ''; const userName = currentUser?.name || currentUser?.username || '';
const roleLabel = isAdmin() ? 'Администратор' : 'Тутор'; const roleLabel = isAdmin() ? 'Администратор' : 'Тутор';
const renderItem = n => {
const isAct = n.page === active;
const badgeHtml = n.badge ? `<span class="sidebar-badge" id="sidebarNotifyBadge" ${notifyUnread > 0 ? '' : 'hidden'}>${notifyUnread > 99 ? '99+' : notifyUnread}</span>` : '';
return `<a href="${n.page}.html" data-nav="${n.page}" class="${isAct ? 'active' : ''}" title="${n.label}">
<i data-lucide="${n.icon}"></i>
<span class="nav-text">${n.label}</span>
${badgeHtml}
</a>`;
};
el.innerHTML = ` el.innerHTML = `
<div class="logo"> <div class="logo">
<span class="logo-ic" id="sidebarLogo"></span> <span class="logo-ic" id="sidebarLogo"></span>
<span id="sidebarName">Админ-панель</span> <span id="sidebarName">Админ-панель</span>
</div> </div>
<div class="user-box"> <div class="user-box">
<div class="user-name">${esc(userName)}</div> <div class="user-avatar"><i data-lucide="${isAdmin() ? 'shield-check' : 'user'}"></i></div>
<div class="user-meta">
<div class="user-name" title="${esc(userName)}">${esc(userName)}</div>
<div class="user-role">${roleLabel}</div> <div class="user-role">${roleLabel}</div>
</div> </div>
</div>
<div class="nav-scroll-wrap">
<nav> <nav>
${nav.map(n => `<a href="${n.page}.html" data-nav="${n.page}" class="${n.page === active ? 'active' : ''}"><i data-lucide="${n.icon}"></i>${n.label}</a>`).join('')} <div class="nav-group">
<div class="nav-group-title">Основное</div>
<div class="nav-items">
${base.map(renderItem).join('')}
</div>
</div>
${isAdmin() ? `
<div class="nav-group">
<div class="nav-group-title">Администрирование</div>
<div class="nav-items">
${adminNav.map(renderItem).join('')}
</div>
</div>` : ''}
</nav> </nav>
</div>
<div class="bottom"> <div class="bottom">
<div class="version" id="sidebarVersion"></div> <div class="bottom-actions">
<a href="/" data-nav="home"><i data-lucide="home"></i>На главную</a> <a href="#" class="notify-bell" id="notifyBell" title="Быстрые уведомления">
<a class="logout" href="#" data-action="logout"><i data-lucide="log-out"></i>Выйти</a> <i data-lucide="bell"></i>
<span>Уведомления</span>
<span class="notify-badge" id="notifyBadge" ${notifyUnread > 0 ? '' : 'hidden'}>${notifyUnread > 99 ? '99+' : notifyUnread}</span>
</a>
<a href="/" data-nav="home" title="Перейти на главную страницу">
<i data-lucide="home"></i>
<span>На главную</span>
</a>
</div>
<div class="bottom-footer">
<a class="logout-btn" href="#" data-action="logout" title="Выйти из аккаунта">
<i data-lucide="log-out"></i>
<span>Выйти</span>
</a>
<span class="version" id="sidebarVersion"></span>
</div>
</div>`; </div>`;
renderIcons(); renderIcons();
loadVersion(); loadVersion();
loadBrand(); loadBrand();
initNotifications();
} }
async function loadBrand() { async function loadBrand() {
@@ -122,6 +165,229 @@ async function loadVersion() {
} catch { el.textContent = ''; } } catch { el.textContent = ''; }
} }
// --- Notifications ---
const NOTIFY_ICONS = {
'entry.new': 'book-open',
'entry.ai.corrected': 'sparkles',
'entry.ai.error': 'bot',
'photo.job.done': 'image',
'photo.job.error': 'image-off',
'ip.ban': 'shield-off',
'backup.restore': 'database',
'backup.create': 'download',
'system.test': 'send',
};
const NOTIFY_LEVELS = { warning: 'Предупреждение', critical: 'Важно', info: '' };
let notifyList = [];
let notifyUnread = 0;
let notifyStream = null;
let notifyReconnectTimer = null;
let desktopNotify = localStorage.getItem('notifyDesktop') === '1';
function notifyIcon(type) { return NOTIFY_ICONS[type] || 'bell'; }
function attrEsc(s) { return esc(String(s === null || s === undefined ? '' : s).replace(/"/g, '&quot;')); }
function notifyTime(ts) {
const d = new Date(ts);
if (Number.isNaN(d.getTime())) return '';
const diff = Date.now() - d.getTime();
if (diff < 60000) return 'только что';
if (diff < 3600000) return `${Math.floor(diff / 60000)} мин назад`;
if (diff < 86400000) return `${Math.floor(diff / 3600000)} ч назад`;
return d.toLocaleString('ru-RU', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' });
}
function setNotifyUnread(n) {
notifyUnread = Number(n) || 0;
const badge = document.getElementById('notifyBadge');
if (badge) {
badge.textContent = notifyUnread > 99 ? '99+' : String(notifyUnread);
badge.hidden = notifyUnread <= 0;
}
const navBadge = document.getElementById('sidebarNotifyBadge');
if (navBadge) {
navBadge.textContent = notifyUnread > 99 ? '99+' : String(notifyUnread);
navBadge.hidden = notifyUnread <= 0;
}
}
function notifyItemHtml(n, expanded) {
const level = NOTIFY_LEVELS[n.level] ? ` level-${n.level}` : '';
return `<a href="#" class="notify-item${n.read ? '' : ' unread'}${level}" data-notify-id="${n.id}" data-notify-link="${attrEsc(n.link || '')}">
<span class="notify-ic"><i data-lucide="${notifyIcon(n.type)}"></i></span>
<span class="notify-body">
<span class="notify-title">${esc(n.title)}</span>
${n.body ? `<span class="notify-text">${esc(n.body)}</span>` : ''}
<span class="notify-time">${esc(notifyTime(n.created_at))}</span>
</span>
</a>`;
}
function notifyPanelEl() {
let panel = document.getElementById('notifyPanel');
if (panel) return panel;
panel = document.createElement('div');
panel.className = 'notify-panel';
panel.id = 'notifyPanel';
panel.hidden = true;
document.body.appendChild(panel);
return panel;
}
function renderNotifyPanel() {
const panel = notifyPanelEl();
if (!panel) return;
const items = notifyList.slice(0, 12);
panel.innerHTML = `
<div class="notify-head">
<b>Уведомления</b>
<button type="button" class="notify-link" id="notifyReadAll">Прочитать все</button>
</div>
<div class="notify-list">
${items.length ? items.map(n => notifyItemHtml(n, false)).join('') : '<div class="notify-empty">Пока уведомлений нет</div>'}
</div>
<div class="notify-foot">
<a href="notifications.html">Все уведомления${notifyUnread ? ` (${notifyUnread})` : ''}</a>
<button type="button" class="notify-link" id="notifyDesktopToggle">${desktopNotify ? 'В браузере: вкл' : 'В браузере: выкл'}</button>
</div>`;
renderIcons();
}
async function loadNotifications() {
try {
const res = await fetch(`${API}/api/notifications?limit=20`, { headers: hdr() });
if (!res.ok) return;
const data = await res.json();
notifyList = data.items || [];
setNotifyUnread(data.unread);
renderNotifyPanel();
} catch {}
}
function toggleNotifyPanel(force) {
const panel = notifyPanelEl();
if (!panel) return;
const open = force === undefined ? panel.hidden : !!force;
panel.hidden = !open;
if (open) { renderNotifyPanel(); loadNotifications(); }
}
async function markNotifyRead(id) {
const item = notifyList.find(n => n.id === id);
if (item && !item.read) {
item.read = true;
setNotifyUnread(Math.max(0, notifyUnread - 1));
renderNotifyPanel();
}
try { await fetch(`${API}/api/notifications/${id}/read`, { method: 'POST', headers: hdr() }); } catch {}
}
async function markAllNotifyRead() {
try {
const res = await fetch(`${API}/api/notifications/read-all`, { method: 'POST', headers: hdr() });
if (!res.ok) return;
notifyList.forEach(n => { n.read = true; });
setNotifyUnread(0);
renderNotifyPanel();
if (typeof onNotifyListChanged === 'function') onNotifyListChanged();
} catch {}
}
function showDesktopNotification(n) {
if (!desktopNotify) return;
if (typeof Notification === 'undefined' || Notification.permission !== 'granted') return;
try {
const note = new Notification(n.title, { body: n.body || '', tag: 'whatido-' + n.id });
note.onclick = () => {
window.focus();
if (n.link) location.href = n.link;
note.close();
};
} catch {}
}
async function toggleDesktopNotifications() {
if (desktopNotify) {
desktopNotify = false;
localStorage.setItem('notifyDesktop', '0');
renderNotifyPanel();
return;
}
if (typeof Notification === 'undefined') { showToast('Браузер не поддерживает уведомления'); return; }
let perm = Notification.permission;
if (perm === 'default') {
try { perm = await Notification.requestPermission(); } catch {}
}
if (perm !== 'granted') { showToast('Разрешение на уведомления не выдано'); return; }
desktopNotify = true;
localStorage.setItem('notifyDesktop', '1');
renderNotifyPanel();
showToast('Уведомления в браузере включены');
}
function handleIncomingNotification(n) {
notifyList.unshift(n);
if (notifyList.length > 40) notifyList.length = 40;
setNotifyUnread(notifyUnread + 1);
renderNotifyPanel();
if (n.level === 'warning' || n.level === 'critical') showToast(n.title);
showDesktopNotification(n);
if (typeof onNotificationArrived === 'function') onNotificationArrived(n);
}
function connectNotifyStream() {
if (notifyStream) { notifyStream.close(); notifyStream = null; }
const connect = () => {
const t = sessionStorage.getItem('authToken');
if (!t) return;
notifyStream = new EventSource(`${API}/api/notifications/stream?token=${encodeURIComponent(t)}`);
notifyStream.addEventListener('ready', ev => {
try { setNotifyUnread(JSON.parse(ev.data).unread); } catch {}
});
notifyStream.addEventListener('notification', ev => {
let n = null;
try { n = JSON.parse(ev.data); } catch { return; }
if (!n || !n.id) return;
handleIncomingNotification(n);
});
notifyStream.onerror = () => {
if (notifyStream) { notifyStream.close(); notifyStream = null; }
clearTimeout(notifyReconnectTimer);
notifyReconnectTimer = setTimeout(connect, 5000);
};
};
connect();
window.addEventListener('beforeunload', () => { if (notifyStream) notifyStream.close(); });
}
function initNotifications() {
const bell = document.getElementById('notifyBell');
const panel = notifyPanelEl();
if (!bell || !panel) return;
bell.addEventListener('click', e => { e.preventDefault(); toggleNotifyPanel(); });
panel.addEventListener('click', e => {
if (e.target.closest('#notifyReadAll')) { markAllNotifyRead(); return; }
if (e.target.closest('#notifyDesktopToggle')) { toggleDesktopNotifications(); return; }
const item = e.target.closest('[data-notify-id]');
if (!item) return;
e.preventDefault();
const id = parseInt(item.dataset.notifyId, 10);
const link = item.dataset.notifyLink;
toggleNotifyPanel(false);
markNotifyRead(id);
if (link) location.href = link;
});
document.addEventListener('click', e => {
if (panel.hidden) return;
if (e.target.closest('#notifyPanel') || e.target.closest('#notifyBell')) return;
toggleNotifyPanel(false);
});
document.addEventListener('keydown', e => { if (e.key === 'Escape' && !panel.hidden) toggleNotifyPanel(false); });
loadNotifications();
connectNotifyStream();
}
// --- Common UI --- // --- Common UI ---
function renderIcons() { function renderIcons() {
if (window.lucide && typeof window.lucide.createIcons === 'function') { if (window.lucide && typeof window.lucide.createIcons === 'function') {
+5 -3
View File
@@ -29,6 +29,7 @@
<thead> <thead>
<tr> <tr>
<th>Время</th> <th>Время</th>
<th>Кто</th>
<th>Действие</th> <th>Действие</th>
<th>Детали</th> <th>Детали</th>
<th>IP</th> <th>IP</th>
@@ -42,11 +43,12 @@
</div> </div>
</div> </div>
<div class="modal-overlay" id="auditDetailsModal"> <div class="modal-overlay" id="auditDetailsModal">
<div class="edit-modal" style="max-width:640px"> <div class="edit-modal" style="max-width:820px">
<h3 id="auditDetailsTitle">Детали действия</h3> <h3 id="auditDetailsTitle">Детали действия</h3>
<div class="audit-details-body"><pre id="auditDetailsBody"></pre></div> <div class="audit-details-head" id="auditDetailsHead"></div>
<div class="audit-details-body" id="auditDetailsBody"></div>
<div class="card-foot" style="justify-content:flex-end;gap:8px"> <div class="card-foot" style="justify-content:flex-end;gap:8px">
<button type="button" class="cancel" id="auditDetailsCloseBtn">Закрыть</button> <button type="button" class="btn-primary ghost" id="auditDetailsCloseBtn">Закрыть</button>
</div> </div>
</div> </div>
</div> </div>
+1
View File
@@ -41,6 +41,7 @@
<div class="actions-grid"> <div class="actions-grid">
<a class="action-btn" href="journal.html"><span class="aic"><i data-lucide="book-open"></i></span>Журнал</a> <a class="action-btn" href="journal.html"><span class="aic"><i data-lucide="book-open"></i></span>Журнал</a>
<a class="action-btn" href="files.html"><span class="aic"><i data-lucide="folder"></i></span>Файлы</a> <a class="action-btn" href="files.html"><span class="aic"><i data-lucide="folder"></i></span>Файлы</a>
<a class="action-btn" href="photos.html"><span class="aic"><i data-lucide="Image"></i></span>Фото</a>
<a class="action-btn" href="groups.html"><span class="aic"><i data-lucide="users"></i></span>Группы</a> <a class="action-btn" href="groups.html"><span class="aic"><i data-lucide="users"></i></span>Группы</a>
<a class="action-btn" href="links.html"><span class="aic"><i data-lucide="link"></i></span>Ссылки</a> <a class="action-btn" href="links.html"><span class="aic"><i data-lucide="link"></i></span>Ссылки</a>
<a class="action-btn" href="trash.html"><span class="aic"><i data-lucide="trash-2"></i></span>Корзина</a> <a class="action-btn" href="trash.html"><span class="aic"><i data-lucide="trash-2"></i></span>Корзина</a>
+10 -3
View File
@@ -39,9 +39,14 @@ h1{font-size:1.5rem;font-weight:600;margin-bottom:8px;letter-spacing:-.02em;text
.files-list .file-row .f-size{color:var(--muted);flex-shrink:0} .files-list .file-row .f-size{color:var(--muted);flex-shrink:0}
.files-list .file-row button{background:none;border:none;color:var(--muted);cursor:pointer;font-size:.85rem;padding:0 2px;flex-shrink:0} .files-list .file-row button{background:none;border:none;color:var(--muted);cursor:pointer;font-size:.85rem;padding:0 2px;flex-shrink:0}
.files-list .file-row button:hover{color:#ef4444} .files-list .file-row button:hover{color:#ef4444}
.paste-hint{font-size:.75rem;color:var(--muted);line-height:1.35;text-align:center}
.files-card .paste-hint{text-align:left}
@media(hover:none){.paste-hint{display:none}}
.preview-wrap{width:100%;display:flex;flex-direction:column;align-items:center;gap:8px} .preview-wrap{width:100%;display:flex;flex-direction:column;align-items:center;gap:8px}
.preview{max-width:100%;width:100%;aspect-ratio:4/3;object-fit:cover;border-radius:var(--radius);background:var(--card);display:block;border:1px dashed var(--border)} .preview{max-width:100%;width:100%;aspect-ratio:4/3;object-fit:cover;border-radius:var(--radius);background:var(--card);display:block;border:1px dashed var(--border);cursor:pointer;transition:border-color .15s}
.preview.has-photo{border-style:solid} .preview.has-photo{border-style:solid}
.preview:hover,.preview:focus-visible{border-color:var(--accent)}
.preview-caption{font-size:.85rem;color:var(--muted);text-align:center;line-height:1.35}
.remove-photo{background:none;border:none;color:#ef4444;font-size:.85rem;cursor:pointer;padding:4px;display:none} .remove-photo{background:none;border:none;color:#ef4444;font-size:.85rem;cursor:pointer;padding:4px;display:none}
.remove-photo.show{display:block} .remove-photo.show{display:block}
@@ -104,10 +109,11 @@ nav a:hover{color:var(--text)}
<div class="photo-col"> <div class="photo-col">
<div class="photo-card"> <div class="photo-card">
<div class="preview-wrap"> <div class="preview-wrap">
<img class="preview" id="preview" alt="Фото" src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='640' height='480'%3E%3Crect width='100%25' height='100%25' fill='none' stroke='%23475569' stroke-width='2' stroke-dasharray='8 8'/%3E%3Ctext x='50%25' y='50%25' fill='%2364748b' font-family='sans-serif' font-size='28' text-anchor='middle' dominant-baseline='middle'%3EФото%3C/text%3E%3C/svg%3E"> <img class="preview" id="preview" alt="Фото" title="Открыть камеру" tabindex="0" role="button" src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='640' height='480'%3E%3Crect width='100%25' height='100%25' fill='none' stroke='%23475569' stroke-width='2' stroke-dasharray='8 8'/%3E%3Ctext x='50%25' y='50%25' fill='%2364748b' font-family='sans-serif' font-size='28' text-anchor='middle' dominant-baseline='middle'%3EФото%3C/text%3E%3C/svg%3E">
<p class="preview-caption" id="previewCaption">Нажми чтобы сделать фото</p>
<button type="button" class="remove-photo" id="removePhotoBtn">Убрать фото</button> <button type="button" class="remove-photo" id="removePhotoBtn">Убрать фото</button>
</div> </div>
<div class="photo-btns"> <div class="photo-btns" id="camBtns">
<button type="button" class="cam" id="openCamBtn">Камера</button> <button type="button" class="cam" id="openCamBtn">Камера</button>
</div> </div>
</div> </div>
@@ -118,6 +124,7 @@ nav a:hover{color:var(--text)}
</div> </div>
<button type="button" class="files-btn" id="filesBtn">Выбрать файлы</button> <button type="button" class="files-btn" id="filesBtn">Выбрать файлы</button>
<input type="file" id="filesInput" multiple hidden> <input type="file" id="filesInput" multiple hidden>
<p class="paste-hint">Ctrl+V — вставить файлы из буфера</p>
<div class="files-list" id="filesList"></div> <div class="files-list" id="filesList"></div>
</div> </div>
</div> </div>
+122 -8
View File
@@ -9,6 +9,11 @@ const ACTION_LABELS = {
'group.create': 'Создана группа', 'group.create': 'Создана группа',
'group.update': 'Изменена группа', 'group.update': 'Изменена группа',
'group.delete': 'Удалена группа', 'group.delete': 'Удалена группа',
'group.soft-delete': 'Группа в корзину',
'group.restore': 'Группа восстановлена',
'group.permanent-delete': 'Группа удалена безвозвратно',
'group.schedule-delete': 'Группа помечена на удаление',
'group.unschedule': 'Удаление группы отменено',
'group.photo.create': 'Загружена фото группы', 'group.photo.create': 'Загружена фото группы',
'group.photo.update': 'Изменено фото группы', 'group.photo.update': 'Изменено фото группы',
'group.photo.reorder': 'Изменён порядок фото группы', 'group.photo.reorder': 'Изменён порядок фото группы',
@@ -37,6 +42,7 @@ const ACTION_LABELS = {
'entry.photo.restore_original': 'Возвращён оригинал фото', 'entry.photo.restore_original': 'Возвращён оригинал фото',
'photo.job.preview': 'Создан предпросмотр обработки фото', 'photo.job.preview': 'Создан предпросмотр обработки фото',
'photo.job.retry': 'Повтор обработки фото', 'photo.job.retry': 'Повтор обработки фото',
'photo.job.soft_retry': 'ИИ-сервис недоступен, ожидание',
'photo.job.error': 'Ошибка обработки фото', 'photo.job.error': 'Ошибка обработки фото',
'photo-jobs.wake': 'Фото-воркер разбужен', 'photo-jobs.wake': 'Фото-воркер разбужен',
'photo-jobs.enabled': 'Переключена обработка фото', 'photo-jobs.enabled': 'Переключена обработка фото',
@@ -44,6 +50,9 @@ const ACTION_LABELS = {
'entry.soft-delete': 'Запись в корзину', 'entry.soft-delete': 'Запись в корзину',
'entry.restore': 'Запись восстановлена', 'entry.restore': 'Запись восстановлена',
'entry.permanent-delete': 'Запись удалена безвозвратно', 'entry.permanent-delete': 'Запись удалена безвозвратно',
'entry.schedule-delete': 'Запись помечена на удаление',
'entry.unschedule': 'Удаление записи отменено',
'trash.clear': 'Корзина помечена на удаление',
'link.create': 'Создана ссылка', 'link.create': 'Создана ссылка',
'link.update': 'Изменена ссылка', 'link.update': 'Изменена ссылка',
'link.delete': 'Удалена ссылка', 'link.delete': 'Удалена ссылка',
@@ -69,10 +78,30 @@ let auditPage = 1;
let auditTotal = 0; let auditTotal = 0;
let auditRows = []; let auditRows = [];
const EDIT_SOURCE_LABELS = {
manual: 'Вручную',
ai: 'ИИ',
ai_manual: 'ИИ + правка вручную',
ai_revert: 'Откат к оригиналу (ИИ)'
};
const FIELD_LABELS = {
student_name: 'ФИО ученика',
group_id: 'Группа',
module_id: 'Тема модуля',
description: 'Текст работы'
};
function fmtTime(t) { function fmtTime(t) {
try { return new Date(t).toLocaleString('ru'); } catch { return t || ''; } try { return new Date(t).toLocaleString('ru'); } catch { return t || ''; }
} }
function parseTarget(t) {
if (t === null || t === undefined) return null;
if (typeof t === 'object') return t;
try { return JSON.parse(t); } catch { return null; }
}
function dt(t) { function dt(t) {
if (!t) return ''; if (!t) return '';
if (typeof t === 'object') { if (typeof t === 'object') {
@@ -92,6 +121,76 @@ function fullTarget(t) {
} catch { return String(t); } } catch { return String(t); }
} }
function changeLabel(c) {
return c.label || FIELD_LABELS[c.field] || c.field;
}
function changeSummary(c) {
if (c.field === 'description' && c.stats) return `текст: +${c.stats.added_words}/−${c.stats.removed_words} слов`;
if (c.field === 'description') return 'текст работы';
return changeLabel(c).toLowerCase();
}
function targetSummary(t) {
const o = parseTarget(t);
if (!o) return dt(t);
if (Array.isArray(o.changes)) {
if (!o.changes.length) return esc(`без изменений${o.source ? ` · ${EDIT_SOURCE_LABELS[o.source] || o.source}` : ''}`);
const parts = o.changes.map(changeSummary);
if (o.photos_added) parts.push(`фото +${o.photos_added}`);
const src = o.source ? ` · ${EDIT_SOURCE_LABELS[o.source] || o.source}` : '';
return esc(parts.join(', ') + src);
}
return dt(t);
}
function renderDiffSegments(segments) {
return (segments || []).map(s => {
const text = esc(s.text);
if (s.type === 'add') return `<ins class="audit-diff-add">${text}</ins>`;
if (s.type === 'del') return `<del class="audit-diff-del">${text}</del>`;
return `<span>${text}</span>`;
}).join('');
}
function renderChange(c) {
const label = changeLabel(c);
if (c.field !== 'description') {
return `<div class="audit-change">
<div class="audit-change-title">${esc(label)}</div>
<div class="audit-change-line"><span class="audit-tag-del">было</span> ${esc(c.before === null || c.before === undefined ? '—' : c.before)}</div>
<div class="audit-change-line"><span class="audit-tag-add">стало</span> ${esc(c.after === null || c.after === undefined ? '—' : c.after)}</div>
</div>`;
}
const s = c.stats || {};
const parts = [];
if (s.added_words || s.added_chars) parts.push(`<span class="audit-stat-add">+${s.added_words || 0} слов (+${s.added_chars || 0} симв.)</span>`);
if (s.removed_words || s.removed_chars) parts.push(`<span class="audit-stat-del">−${s.removed_words || 0} слов (−${s.removed_chars || 0} симв.)</span>`);
if (s.chars_before !== undefined) parts.push(`<span class="audit-stat-muted">было ${s.chars_before} симв. → стало ${s.chars_after} симв.</span>`);
return `<div class="audit-change">
<div class="audit-change-title">${esc(label)}</div>
<div class="audit-change-stats">${parts.join('')}</div>
<div class="audit-diff">${renderDiffSegments(c.diff) || '<span class="audit-stat-muted">нет текстового диффа</span>'}</div>
${c.truncated ? '<div class="audit-truncated">Показана только часть изменений</div>' : ''}
</div>`;
}
function renderTargetDetails(target) {
const o = parseTarget(target);
if (!o) return `<pre>${esc(fullTarget(target))}</pre>`;
if (!Array.isArray(o.changes)) return `<pre>${esc(fullTarget(target))}</pre>`;
const head = [];
if (o.id) head.push(`<span class="audit-pill">Запись #${esc(o.id)}</span>`);
if (o.source) head.push(`<span class="audit-pill">Источник: ${esc(EDIT_SOURCE_LABELS[o.source] || o.source)}</span>`);
head.push(`<span class="audit-pill">Полей затронуто: ${o.changes.length}</span>`);
if (o.photos_added) head.push(`<span class="audit-pill">Добавлено фото: ${o.photos_added}</span>`);
const body = o.changes.length
? o.changes.map(renderChange).join('')
: '<div class="audit-stat-muted">Изменений не зафиксировано.</div>';
return `${head.join('')}<div class="audit-changes">${body}</div>
<details class="audit-raw"><summary>Технические данные (JSON)</summary><pre>${esc(fullTarget(target))}</pre></details>`;
}
function renderPager(container, page, totalPages, onPageChange) { function renderPager(container, page, totalPages, onPageChange) {
if (totalPages <= 1) { container.innerHTML = ''; return; } if (totalPages <= 1) { container.innerHTML = ''; return; }
const pages = []; const pages = [];
@@ -112,21 +211,20 @@ function renderPager(container, page, totalPages, onPageChange) {
async function loadAudit() { async function loadAudit() {
const el = document.getElementById('auditTable').querySelector('tbody'); const el = document.getElementById('auditTable').querySelector('tbody');
el.innerHTML = '<tr><td colspan="4" style="color:var(--muted)">Загрузка…</td></tr>'; el.innerHTML = '<tr><td colspan="5" style="color:var(--muted)">Загрузка…</td></tr>';
const p = new URLSearchParams(); const p = new URLSearchParams();
p.set('limit', AUDIT_PAGE_SIZE); p.set('limit', AUDIT_PAGE_SIZE);
p.set('offset', (auditPage - 1) * AUDIT_PAGE_SIZE); p.set('offset', (auditPage - 1) * AUDIT_PAGE_SIZE);
const action = document.getElementById('auditActionFilter').value; const action = document.getElementById('auditActionFilter').value;
if (action) p.set('action', action); if (action) p.set('action', action);
const res = await fetch(`${API}/api/audit?${p}`, { headers: hdr() }); const res = await fetch(`${API}/api/audit?${p}`, { headers: hdr() });
if (!res.ok) { el.innerHTML = '<tr><td colspan="4" style="color:var(--muted)">Ошибка загрузки</td></tr>'; return; } if (!res.ok) { el.innerHTML = '<tr><td colspan="5" style="color:var(--muted)">Ошибка загрузки</td></tr>'; return; }
const rows = await res.json(); const rows = await res.json();
// Total count for pagination (approximate: if we got less than page size, we're on last page)
const totalPages = rows.length < AUDIT_PAGE_SIZE ? auditPage : auditPage + 1; const totalPages = rows.length < AUDIT_PAGE_SIZE ? auditPage : auditPage + 1;
auditTotal = totalPages; auditTotal = totalPages;
document.getElementById('auditCount').textContent = rows.length ? `Записей на странице: ${rows.length}` : ''; document.getElementById('auditCount').textContent = rows.length ? `Записей на странице: ${rows.length}` : '';
const q = (document.getElementById('auditFilter').value || '').toLowerCase().trim(); const q = (document.getElementById('auditFilter').value || '').toLowerCase().trim();
const filtered = q ? rows.filter(r => (ACTION_LABELS[r.action] || r.action).toLowerCase().includes(q)) : rows; const filtered = q ? rows.filter(r => `${ACTION_LABELS[r.action] || r.action} ${targetSummary(r.target)}`.toLowerCase().includes(q)) : rows;
if (!filtered.length) { if (!filtered.length) {
document.getElementById('auditEmpty').style.display = 'block'; document.getElementById('auditEmpty').style.display = 'block';
el.innerHTML = ''; el.innerHTML = '';
@@ -136,8 +234,9 @@ async function loadAudit() {
el.innerHTML = filtered.map((r, i) => ` el.innerHTML = filtered.map((r, i) => `
<tr> <tr>
<td style="white-space:nowrap">${fmtTime(r.created_at)}</td> <td style="white-space:nowrap">${fmtTime(r.created_at)}</td>
<td style="white-space:nowrap">${esc(r.user_name || 'система')}</td>
<td><b>${esc(ACTION_LABELS[r.action] || r.action)}</b></td> <td><b>${esc(ACTION_LABELS[r.action] || r.action)}</b></td>
<td><button type="button" class="audit-details" data-audit-idx="${i}">${dt(r.target)}</button></td> <td><button type="button" class="audit-details" data-audit-idx="${i}">${targetSummary(r.target)}</button></td>
<td style="color:var(--muted);white-space:nowrap">${esc(r.ip || '')}</td> <td style="color:var(--muted);white-space:nowrap">${esc(r.ip || '')}</td>
</tr>`).join(''); </tr>`).join('');
} }
@@ -164,14 +263,29 @@ document.getElementById('auditActionFilter').addEventListener('change', () => {
document.getElementById('auditFilter').addEventListener('input', () => { auditPage = 1; loadAudit(); }); document.getElementById('auditFilter').addEventListener('input', () => { auditPage = 1; loadAudit(); });
document.getElementById('auditRefreshBtn').addEventListener('click', loadAudit); document.getElementById('auditRefreshBtn').addEventListener('click', loadAudit);
document.getElementById('auditTable').addEventListener('click', e => { document.getElementById('auditTable').addEventListener('click', async e => {
const btn = e.target.closest('.audit-details'); const btn = e.target.closest('.audit-details');
if (!btn) return; if (!btn) return;
const r = auditRows[parseInt(btn.dataset.auditIdx, 10)]; const r = auditRows[parseInt(btn.dataset.auditIdx, 10)];
if (!r) return; if (!r) return;
const modal = document.getElementById('auditDetailsModal');
const headEl = document.getElementById('auditDetailsHead');
const bodyEl = document.getElementById('auditDetailsBody');
document.getElementById('auditDetailsTitle').textContent = ACTION_LABELS[r.action] || r.action; document.getElementById('auditDetailsTitle').textContent = ACTION_LABELS[r.action] || r.action;
document.getElementById('auditDetailsBody').textContent = fullTarget(r.target); headEl.innerHTML = `<span class="audit-pill">${esc(fmtTime(r.created_at))}</span><span class="audit-pill">${esc(r.user_name || 'система')}</span>${r.ip ? `<span class="audit-pill">IP ${esc(r.ip)}</span>` : ''}`;
document.getElementById('auditDetailsModal').classList.add('open'); bodyEl.innerHTML = renderTargetDetails(r.target);
modal.classList.add('open');
if (!r.id) return;
bodyEl.classList.add('audit-loading');
try {
const res = await fetch(`${API}/api/audit/${r.id}`, { headers: hdr() });
if (!res.ok) return;
const full = await res.json();
bodyEl.innerHTML = renderTargetDetails(full.target);
} catch (err) {
} finally {
bodyEl.classList.remove('audit-loading');
}
}); });
document.getElementById('auditDetailsCloseBtn').addEventListener('click', () => { document.getElementById('auditDetailsCloseBtn').addEventListener('click', () => {
document.getElementById('auditDetailsModal').classList.remove('open'); document.getElementById('auditDetailsModal').classList.remove('open');
+3 -2
View File
@@ -113,8 +113,9 @@ async function addGroup() {
} }
async function delGroup(id) { async function delGroup(id) {
if (!confirm('Удалить группу? Фото группы также будут удалены.')) return; if (!confirm('Удалить группу? Группа попадёт в корзину, восстановить её можно будет позже.')) return;
await fetch(`${API}/api/groups/${id}`, { method: 'DELETE', headers: hdr() }); const res = await fetch(`${API}/api/groups/${id}`, { method: 'DELETE', headers: hdr() });
if (!res.ok) { const e = await res.json(); alert(e.error || 'Ошибка'); }
loadGroups(); loadGroups();
} }
+124 -6
View File
@@ -15,6 +15,7 @@ const camCanvas = document.getElementById('camCanvas');
const camModal = document.getElementById('camModal'); const camModal = document.getElementById('camModal');
let capturedBlob = null; let capturedBlob = null;
let stream = null; let stream = null;
let camOpening = false;
const descInput = document.getElementById('descInput'); const descInput = document.getElementById('descInput');
const filesInput = document.getElementById('filesInput'); const filesInput = document.getElementById('filesInput');
const filesList = document.getElementById('filesList'); const filesList = document.getElementById('filesList');
@@ -118,13 +119,108 @@ function clearFiles() {
renderFiles(); renderFiles();
} }
const MAX_FILES = 10;
const MAX_FILE_BYTES = 10 * 1024 * 1024;
const MAX_TOTAL_BYTES = 30 * 1024 * 1024;
const BLOCKED_FILE_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;
const MIME_EXT = {
'image/png': '.png', 'image/jpeg': '.jpg', 'image/jpg': '.jpg', 'image/gif': '.gif', 'image/webp': '.webp',
'image/bmp': '.bmp', 'image/avif': '.avif', 'image/heic': '.heic', 'image/heif': '.heif', 'image/svg+xml': '.svg',
'application/pdf': '.pdf', 'application/zip': '.zip', 'application/x-zip-compressed': '.zip',
'application/msword': '.doc', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': '.docx',
'application/vnd.ms-excel': '.xls', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': '.xlsx',
'application/vnd.ms-powerpoint': '.ppt', 'application/vnd.openxmlformats-officedocument.presentationml.presentation': '.pptx',
'text/plain': '.txt', 'text/csv': '.csv', 'application/rtf': '.rtf',
'application/x-rar-compressed': '.rar', 'application/vnd.rar': '.rar', 'application/x-7z-compressed': '.7z'
};
let notifyTimer = null;
function notify(text, isError) {
toast.textContent = text;
toast.classList.add('show');
toast.classList.toggle('error', !!isError);
clearTimeout(notifyTimer);
notifyTimer = setTimeout(() => toast.classList.remove('show', 'error'), 3200);
}
function extOf(name) {
const s = String(name || '');
const i = s.lastIndexOf('.');
return i > 0 ? s.slice(i).toLowerCase() : '';
}
function extFromFile(file) {
const named = extOf(file.name);
if (/^\.[a-z0-9]{1,8}$/i.test(named)) return named;
const byMime = MIME_EXT[String(file.type || '').toLowerCase()];
if (byMime) return byMime;
const sub = String(file.type || '').split('/')[1] || '';
return /^[a-z0-9]{1,8}$/i.test(sub) ? '.' + sub : '';
}
function pasteFileName(file) {
const base = String(file.name || '').trim();
const stem = base.replace(/\.[a-z0-9]{1,8}$/i, '');
if (base && !/^(image|photo|picture|blob|file|screenshot|снимок|скриншот|изображение|файл)$/i.test(stem)) return base;
const d = new Date();
const pad = n => String(n).padStart(2, '0');
const stamp = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}-${pad(d.getMinutes())}-${pad(d.getSeconds())}`;
return `Вставка ${stamp}${extFromFile(file)}`;
}
function totalUploadBytes() {
return (capturedBlob ? capturedBlob.size : 0) + selectedFiles.reduce((sum, f) => sum + f.size, 0);
}
function addFiles(list) {
const added = [];
const skipped = [];
for (const file of list) {
if (selectedFiles.length >= MAX_FILES) { skipped.push('Можно прикрепить не более 10 файлов'); break; }
if (file.size > MAX_FILE_BYTES) { skipped.push(`«${file.name}» больше 10 МБ`); continue; }
if (BLOCKED_FILE_EXT.test(extOf(file.name))) { skipped.push(`«${file.name}»: недопустимый тип файла`); continue; }
if (totalUploadBytes() + file.size > MAX_TOTAL_BYTES) { skipped.push('Суммарный размер файлов — не более 30 МБ'); break; }
selectedFiles.push(file);
added.push(file.name);
}
if (added.length) renderFiles();
return { added, skipped };
}
filesInput.addEventListener('change', () => { filesInput.addEventListener('change', () => {
const keep = 10 - selectedFiles.length; const res = addFiles([...filesInput.files]);
if (keep <= 0) { alert('Можно прикрепить не более 10 файлов'); filesInput.value = ''; return; }
selectedFiles = selectedFiles.concat([...filesInput.files].slice(0, keep));
if ([...filesInput.files].length > keep) alert('Можно прикрепить не более 10 файлов');
filesInput.value = ''; filesInput.value = '';
renderFiles(); if (res.skipped.length) alert(res.skipped[0]);
});
document.addEventListener('paste', (e) => {
if (form.style.display === 'none') return;
const clip = e.clipboardData;
if (!clip) return;
const pasted = [];
for (const item of Array.from(clip.items || [])) {
if (item.kind !== 'file') continue;
const file = item.getAsFile();
if (file) pasted.push(file);
}
if (!pasted.length && clip.files && clip.files.length) pasted.push(...Array.from(clip.files));
if (!pasted.length) return;
e.preventDefault();
let filesAdded = 0;
const skipped = [];
for (const raw of pasted) {
const name = pasteFileName(raw);
const file = raw.name === name ? raw : new File([raw], name, { type: raw.type || 'application/octet-stream', lastModified: raw.lastModified });
const res = addFiles([file]);
filesAdded += res.added.length;
skipped.push(...res.skipped);
}
if (!filesAdded) {
if (skipped.length) notify(skipped[0], true);
return;
}
const summary = filesAdded === 1 ? 'Файл добавлен' : `Файлов добавлено: ${filesAdded}`;
notify(summary + (skipped.length ? '. ' + skipped[0] : ''), skipped.length > 0);
}); });
const params = new URLSearchParams(location.search); const params = new URLSearchParams(location.search);
@@ -223,6 +319,10 @@ async function resolveModuleId(name) {
if (Number.isFinite(h) && h >= 120 && h <= 4096) captureHeight = h; if (Number.isFinite(h) && h >= 120 && h <= 4096) captureHeight = h;
const q = parseFloat(f.photo_capture_quality); const q = parseFloat(f.photo_capture_quality);
if (Number.isFinite(q) && q >= 0.5 && q <= 1) captureQuality = q; if (Number.isFinite(q) && q >= 0.5 && q <= 1) captureQuality = q;
if (f.camera_enabled === 'false') {
const camBtns = document.getElementById('camBtns');
if (camBtns) camBtns.style.display = 'none';
}
} catch (e) { /* ignore */ } } catch (e) { /* ignore */ }
checkPreviousSend(); checkPreviousSend();
})(); })();
@@ -241,12 +341,22 @@ function removePhoto() {
} }
async function openCam() { async function openCam() {
if (camOpening) return;
if (stream && camModal.classList.contains('open')) return;
camOpening = true;
try { try {
stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'user', width: { ideal: captureWidth }, height: { ideal: captureHeight } } }); const s = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'user', width: { ideal: captureWidth }, height: { ideal: captureHeight } } });
if (camModal.classList.contains('open')) {
s.getTracks().forEach(t => t.stop());
return;
}
stream = s;
camVideo.srcObject = stream; camVideo.srcObject = stream;
camModal.classList.add('open'); camModal.classList.add('open');
} catch (e) { } catch (e) {
alert('Не удалось открыть камеру: ' + e.message); alert('Не удалось открыть камеру: ' + e.message);
} finally {
camOpening = false;
} }
} }
@@ -256,6 +366,10 @@ function closeCam() {
} }
function capture() { function capture() {
if (!camVideo.videoWidth || !camVideo.videoHeight) {
notify('Камера ещё не готова, попробуйте через секунду', true);
return;
}
camCanvas.width = camVideo.videoWidth; camCanvas.width = camVideo.videoWidth;
camCanvas.height = camVideo.videoHeight; camCanvas.height = camVideo.videoHeight;
const ctx = camCanvas.getContext('2d'); const ctx = camCanvas.getContext('2d');
@@ -325,6 +439,10 @@ form.addEventListener('submit', async (e) => {
removePhotoBtn.addEventListener('click', removePhoto); removePhotoBtn.addEventListener('click', removePhoto);
document.getElementById('openCamBtn').addEventListener('click', openCam); document.getElementById('openCamBtn').addEventListener('click', openCam);
preview.addEventListener('click', openCam);
preview.addEventListener('keydown', e => {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openCam(); }
});
filesClearBtn.addEventListener('click', clearFiles); filesClearBtn.addEventListener('click', clearFiles);
document.getElementById('filesBtn').addEventListener('click', () => document.getElementById('filesInput').click()); document.getElementById('filesBtn').addEventListener('click', () => document.getElementById('filesInput').click());
document.getElementById('cancelCamBtn').addEventListener('click', closeCam); document.getElementById('cancelCamBtn').addEventListener('click', closeCam);
+9
View File
@@ -19,6 +19,7 @@ let searchTimer = null;
let view = localStorage.getItem('journalView') === 'cards' ? 'cards' : 'list'; let view = localStorage.getItem('journalView') === 'cards' ? 'cards' : 'list';
let aiCorrecting = false; let aiCorrecting = false;
let editDeletePhoto = false; let editDeletePhoto = false;
let editAiSuggestion = null;
let linkSettings = null; let linkSettings = null;
async function loadLinkSettings() { async function loadLinkSettings() {
@@ -395,6 +396,7 @@ async function openEdit(id) {
if (!e) return; if (!e) return;
editId = id; editId = id;
editDeletePhoto = false; editDeletePhoto = false;
editAiSuggestion = null;
fillAutocomplete(); fillAutocomplete();
document.getElementById('editName').value = e.student_name; document.getElementById('editName').value = e.student_name;
document.getElementById('editDesc').value = e.description; document.getElementById('editDesc').value = e.description;
@@ -1140,6 +1142,7 @@ function acceptAiCorrect() {
const suggestion = document.getElementById('aiSuggestion').textContent; const suggestion = document.getElementById('aiSuggestion').textContent;
if (suggestion && suggestion !== 'Обработка...' && !suggestion.startsWith('Ошибка:')) { if (suggestion && suggestion !== 'Обработка...' && !suggestion.startsWith('Ошибка:')) {
document.getElementById('editDesc').value = suggestion; document.getElementById('editDesc').value = suggestion;
editAiSuggestion = suggestion.trim();
} }
closeAiCorrect(); closeAiCorrect();
} }
@@ -1190,6 +1193,11 @@ async function recheckAi(id) {
refreshEntryCard(id); refreshEntryCard(id);
} }
function editSourceFor(description) {
if (!editAiSuggestion) return 'manual';
return description === editAiSuggestion ? 'ai' : 'ai_manual';
}
async function saveEdit() { async function saveEdit() {
if (!editId) return; if (!editId) return;
const name = document.getElementById('editName').value.trim(); const name = document.getElementById('editName').value.trim();
@@ -1205,6 +1213,7 @@ async function saveEdit() {
formData.append('group_id', group_id); formData.append('group_id', group_id);
formData.append('description', description); formData.append('description', description);
formData.append('module_id', moduleId); formData.append('module_id', moduleId);
formData.append('edit_source', editSourceFor(description));
const photoFiles = document.getElementById('editPhoto').files; const photoFiles = document.getElementById('editPhoto').files;
for (const f of photoFiles) formData.append('photo', f); for (const f of photoFiles) formData.append('photo', f);
+164
View File
@@ -0,0 +1,164 @@
const PAGE_SIZE = 25;
let nfItems = [];
let nfTotal = 0;
let nfUnread = 0;
let nfUnreadOnly = false;
let nfLoading = false;
async function nfMarkRead(id) {
const res = await fetch(`${API}/api/notifications/${id}/read`, { method: 'POST', headers: hdr() });
if (!res.ok) { showToast('Не удалось отметить уведомление'); return; }
const item = nfItems.find(n => n.id === id);
if (item) item.read = true;
nfUnread = Math.max(0, nfUnread - 1);
if (typeof notifyList !== 'undefined') {
const shared = notifyList.find(n => n.id === id);
if (shared) shared.read = true;
}
if (nfUnreadOnly) {
nfItems = nfItems.filter(n => n.id !== id);
nfTotal = Math.max(0, nfTotal - 1);
loadNotifyPage(true);
return;
}
renderNotifyPage();
if (typeof setNotifyUnread === 'function') setNotifyUnread(nfUnread);
}
async function nfDelete(id) {
if (!confirm('Удалить уведомление у всех пользователей?')) return;
const res = await fetch(`${API}/api/notifications/${id}`, { method: 'DELETE', headers: hdr() });
if (!res.ok) { showToast('Не удалось удалить уведомление'); return; }
if (typeof notifyList !== 'undefined') notifyList = notifyList.filter(n => n.id !== id);
loadNotifyPage(true);
}
async function nfMarkAllRead() {
const res = await fetch(`${API}/api/notifications/read-all`, { method: 'POST', headers: hdr() });
if (!res.ok) { showToast('Не удалось отметить уведомления'); return; }
showToast('Все уведомления прочитаны');
if (typeof notifyList !== 'undefined') notifyList.forEach(n => { n.read = true; });
if (typeof setNotifyUnread === 'function') setNotifyUnread(0);
loadNotifyPage(true);
}
async function nfClearAll() {
if (!confirm('Удалить все уведомления у всех пользователей? Действие необратимо.')) return;
const res = await fetch(`${API}/api/notifications`, { method: 'DELETE', headers: hdr() });
if (!res.ok) { showToast('Не удалось очистить уведомления'); return; }
const data = await res.json().catch(() => ({}));
showToast(`Удалено уведомлений: ${data.deleted || 0}`);
if (typeof notifyList !== 'undefined') notifyList = [];
if (typeof setNotifyUnread === 'function') setNotifyUnread(0);
loadNotifyPage(true);
}
function onNotificationArrived() {
loadNotifyPage(true);
}
function onNotifyListChanged() {
loadNotifyPage(true);
}
document.getElementById('nfList').addEventListener('click', e => {
const readBtn = e.target.closest('[data-nf-read]');
if (readBtn) { e.preventDefault(); nfMarkRead(parseInt(readBtn.dataset.nfRead, 10)); return; }
const delBtn = e.target.closest('[data-nf-del]');
if (delBtn) { e.preventDefault(); nfDelete(parseInt(delBtn.dataset.nfDel, 10)); return; }
if (e.target.closest('a[href]')) {
const card = e.target.closest('[data-nf-card]');
if (card) nfMarkRead(parseInt(card.dataset.nfCard, 10));
}
});
document.getElementById('notifyFilter').addEventListener('click', e => {
const btn = e.target.closest('button[data-filter]');
if (!btn) return;
nfUnreadOnly = btn.dataset.filter === 'unread';
document.querySelectorAll('#notifyFilter button').forEach(b => b.classList.toggle('active', b === btn));
loadNotifyPage(true);
});
document.getElementById('nfReadAllBtn').addEventListener('click', nfMarkAllRead);
document.getElementById('nfClearBtn').addEventListener('click', nfClearAll);
document.getElementById('nfMoreBtn').addEventListener('click', () => loadNotifyPage(false));
document.getElementById('nfReloadBtn').addEventListener('click', () => loadNotifyPage(true));
(async () => {
if (!(await requireAdminPage())) return;
buildSidebar(document.body.dataset.page);
const clearBtn = document.getElementById('nfClearBtn');
if (clearBtn) clearBtn.hidden = !isAdmin();
const authStatus = document.getElementById('authStatus');
if (authStatus) authStatus.classList.add('loaded');
await loadNotifyPage(true);
})();
function nfIcon(type) {
return typeof NOTIFY_ICONS !== 'undefined' && NOTIFY_ICONS[type] ? NOTIFY_ICONS[type] : 'bell';
}
function nfLevelClass(level) {
return level === 'warning' || level === 'critical' ? ` level-${level}` : '';
}
function nfCardHtml(n) {
const when = typeof notifyTime === 'function' ? notifyTime(n.created_at) : '';
const linkAttr = typeof attrEsc === 'function' ? attrEsc(n.link) : esc(n.link);
return `<div class="notify-card${n.read ? '' : ' unread'}${nfLevelClass(n.level)}" data-nf-card="${n.id}">
<span class="notify-ic"><i data-lucide="${nfIcon(n.type)}"></i></span>
<span class="notify-body">
<span class="notify-title">${esc(n.title)}</span>
${n.body ? `<span class="notify-text">${esc(n.body)}</span>` : ''}
<span class="notify-time">${esc(when)}${n.read ? '' : ' · не прочитано'}</span>
</span>
<span class="notify-card-actions">
${n.link ? `<a class="btn-primary ghost" style="padding:6px 12px;font-size:.78rem" href="${linkAttr}">Открыть</a>` : ''}
${n.read ? '' : `<button type="button" class="notify-link" data-nf-read="${n.id}">Прочитано</button>`}
${isAdmin() ? `<button type="button" class="notify-link" style="color:#ef4444" data-nf-del="${n.id}">Удалить</button>` : ''}
</span>
</div>`;
}
function renderNotifyPage() {
const list = document.getElementById('nfList');
const state = document.getElementById('nfState');
const more = document.getElementById('nfMoreBtn');
if (!list) return;
list.innerHTML = nfItems.length
? nfItems.map(nfCardHtml).join('')
: `<div class="notify-empty">${nfUnreadOnly ? 'Непрочитанных уведомлений нет' : 'Уведомлений пока нет'}</div>`;
if (state) {
state.textContent = nfItems.length
? `Показано ${nfItems.length} из ${nfTotal} · непрочитанных: ${nfUnread}`
: `Непрочитанных: ${nfUnread}`;
}
if (more) more.hidden = nfItems.length >= nfTotal;
renderIcons();
}
async function loadNotifyPage(reset) {
if (nfLoading) return;
nfLoading = true;
const offset = reset ? 0 : nfItems.length;
try {
const q = new URLSearchParams({ limit: String(PAGE_SIZE), offset: String(offset) });
if (nfUnreadOnly) q.set('unread', '1');
const res = await fetch(`${API}/api/notifications?${q.toString()}`, { headers: hdr() });
if (!res.ok) { showToast('Ошибка загрузки уведомлений'); return; }
const data = await res.json();
const items = data.items || [];
nfItems = reset ? items : nfItems.concat(items);
nfTotal = Number(data.total) || 0;
nfUnread = Number(data.unread) || 0;
renderNotifyPage();
if (typeof setNotifyUnread === 'function') setNotifyUnread(nfUnread);
} catch {
showToast('Ошибка сети');
} finally {
nfLoading = false;
}
}
+144
View File
@@ -0,0 +1,144 @@
const PAGE_SIZE = 24;
let page = 1;
let totalPhotos = 0;
let currentPhotos = [];
let groupsLoaded = false;
let studentsLoaded = false;
let sourcesLoaded = false;
let searchTimer = null;
function humanSize(b) {
if (!b) return '';
if (b < 1024) return b + ' Б';
if (b < 1048576) return (b / 1024).toFixed(1) + ' КБ';
return (b / 1048576).toFixed(1) + ' МБ';
}
function photoLink(p) {
if (p.source_type === 'entry') return `journal.html#entry-${p.source_id}`;
if (p.source_type === 'entry_photo') return `journal.html#entry-${p.source_id}`;
if (p.source_type === 'group_photo') return 'groups.html';
if (p.source_type === 'student_photo') return 'students.html';
if (p.source_type === 'module_photo') return 'modules.html';
return '';
}
function photoCardHTML(p) {
const url = p.path;
const meta = [];
if (p.student_name) meta.push(esc(p.student_name));
if (p.group_name) meta.push(esc(p.group_name));
const link = photoLink(p);
return `
<div class="ph-card">
<img src="${esc(thumbSrc(url))}" data-img="${esc(url)}" loading="lazy" decoding="async" alt="">
<div class="ph-body">
<span class="ph-badge">${esc(p.source_label)}</span>
<div class="ph-title">${esc(p.title || 'Без описания')}</div>
${meta.length ? `<div class="ph-meta">${meta.join(' · ')}</div>` : ''}
<div class="ph-time">${new Date(p.created_at).toLocaleString('ru')}</div>
${link ? `<a class="ph-link" href="${link}">Открыть источник →</a>` : ''}
</div>
</div>
`;
}
function renderGrid() {
const grid = document.getElementById('grid');
if (!currentPhotos.length) {
grid.innerHTML = '<div class="empty" style="grid-column:1/-1">Фотографий не найдено</div>';
return;
}
grid.innerHTML = currentPhotos.map(photoCardHTML).join('');
}
async function loadGroupOptions() {
if (groupsLoaded) return;
const sel = document.getElementById('groupFilter');
while (sel.options.length > 1) sel.remove(1);
const res = await fetch(`${API}/api/groups`, { headers: hdr() });
const groups = await res.json();
groups.forEach(g => {
const o = document.createElement('option');
o.value = g.id; o.textContent = g.name;
sel.appendChild(o);
});
groupsLoaded = true;
}
async function loadStudentOptions() {
if (studentsLoaded) return;
const dl = document.getElementById('studentFilterList');
const res = await fetch(`${API}/api/students/names`, { headers: hdr() });
const names = await res.json();
dl.innerHTML = names.map(n => `<option value="${esc(n)}">`).join('');
studentsLoaded = true;
}
function fillSourceOptions() {
if (sourcesLoaded) return;
const sel = document.getElementById('sourceFilter');
const labels = [];
currentPhotos.forEach(p => { if (!labels.includes(p.source_label)) labels.push(p.source_label); });
const known = ['Главное фото записи', 'Фото записи', 'Фотохроника группы', 'Фото ученика', 'Тема модуля'];
known.concat(labels.filter(l => !known.includes(l))).forEach(label => {
const o = document.createElement('option');
o.value = label; o.textContent = label;
sel.appendChild(o);
});
sourcesLoaded = true;
}
async function loadPhotos() {
const p = new URLSearchParams();
const q = document.getElementById('searchInput').value;
const sn = document.getElementById('studentFilter').value;
const gid = document.getElementById('groupFilter').value;
const df = document.getElementById('dateFrom').value;
const dt = document.getElementById('dateTo').value;
if (q) p.set('search', q);
if (sn) p.set('student_name', sn);
if (gid) p.set('group_id', gid);
if (df) p.set('date_from', df);
if (dt) p.set('date_to', dt);
p.set('limit', PAGE_SIZE);
p.set('offset', (page - 1) * PAGE_SIZE);
const res = await fetch(`${API}/api/photos?${p}`, { headers: hdr() });
if (!res.ok) {
document.getElementById('grid').innerHTML = '<div class="empty" style="grid-column:1/-1">Не удалось загрузить фотографии</div>';
return;
}
const data = await res.json();
const srcFilter = document.getElementById('sourceFilter').value;
currentPhotos = srcFilter ? data.photos.filter(x => x.source_label === srcFilter) : data.photos;
totalPhotos = srcFilter ? currentPhotos.length : data.total;
fillSourceOptions();
renderGrid();
renderPager(document.getElementById('pager'), page, Math.max(1, Math.ceil(totalPhotos / PAGE_SIZE)), goPage);
}
function goPage(p) {
page = p;
loadPhotos();
}
document.getElementById('studentFilter').addEventListener('change', () => { page = 1; loadPhotos(); });
document.getElementById('groupFilter').addEventListener('change', () => { page = 1; loadPhotos(); });
document.getElementById('sourceFilter').addEventListener('change', () => { page = 1; loadPhotos(); });
document.getElementById('dateFrom').addEventListener('change', () => { page = 1; loadPhotos(); });
document.getElementById('dateTo').addEventListener('change', () => { page = 1; loadPhotos(); });
document.getElementById('searchInput').addEventListener('input', () => {
clearTimeout(searchTimer);
searchTimer = setTimeout(() => { page = 1; loadPhotos(); }, 300);
});
document.getElementById('imgModal').addEventListener('click', e => e.currentTarget.classList.remove('open'));
(async () => {
if (await checkAuth()) {
buildSidebar('photos');
await loadGroupOptions();
await loadStudentOptions();
loadPhotos();
}
})();
+273 -22
View File
@@ -1,5 +1,6 @@
const DIRTY_FIELDS = ['systemName', 'spamInterval', 'footerLeft', 'footerRight', 'aiPrompt', 'aiAutoCheck', 'shareShowMessage', 'shareShowDate', 'shareShowNames', 'shareShowPhotos', 'cookieNoticeText', 'photoResolution', 'photoQuality', 'photoEngine']; const DIRTY_FIELDS = ['systemName', 'spamInterval', 'footerLeft', 'footerRight', 'aiPrompt', 'aiAutoCheck', 'shareShowMessage', 'shareShowDate', 'shareShowNames', 'shareShowPhotos', 'cookieNoticeText', 'photoResolution', 'photoQuality', 'photoEngine', 'camEnabled', 'trashPurgeDays', 'notifyEnabled', 'notifyRetentionDays'];
const SECTION_IDS = ['sec-system', 'sec-brand', 'sec-antispam', 'sec-share', 'sec-ai', 'sec-backup', 'sec-form', 'sec-bans']; const NOTIFY_IDS = [];
const SECTION_IDS = ['sec-system', 'sec-stack', 'sec-brand', 'sec-antispam', 'sec-share', 'sec-ai', 'sec-backup', 'sec-trash', 'sec-form', 'sec-notify', 'sec-bans'];
const SPAM_MAX = 10080; const SPAM_MAX = 10080;
const BAN_REASONS = { const BAN_REASONS = {
'honeypot': 'Антиспам-поле', 'honeypot': 'Антиспам-поле',
@@ -10,6 +11,7 @@ const BAN_REASONS = {
let currentLogo = ''; let currentLogo = '';
let logoOriginal = ''; let logoOriginal = '';
let currentSystemName = '';
function renderLogoPreview() { function renderLogoPreview() {
const img = document.getElementById('logoPreviewImg'); const img = document.getElementById('logoPreviewImg');
@@ -153,6 +155,38 @@ function initSectionNav() {
}); });
} }
async function renderNotifyTypes(s) {
const el = document.getElementById('notifyTypes');
if (!el) return;
const enabledEl = document.getElementById('notifyEnabled');
if (enabledEl) enabledEl.checked = s.notify_enabled !== 'false';
const retentionEl = document.getElementById('notifyRetentionDays');
if (retentionEl) retentionEl.value = s.notify_retention_days !== undefined && s.notify_retention_days !== '' ? s.notify_retention_days : '30';
NOTIFY_IDS.length = 0;
let types = [];
try {
const res = await fetch(`${API}/api/notifications/meta`, { headers: hdr() });
if (res.ok) types = (await res.json()).types || [];
} catch {}
if (!types.length) {
el.innerHTML = '<span class="hint">Не удалось загрузить список событий</span>';
return;
}
el.innerHTML = types.map(t => {
NOTIFY_IDS.push(t.key);
if (!DIRTY_FIELDS.includes(t.key)) DIRTY_FIELDS.push(t.key);
const raw = s[t.key];
const value = raw !== undefined && raw !== '' ? raw : (t.default_enabled ? 'true' : 'false');
const badge = t.admin_only ? '<span class="notify-type-badge">только админ</span>' : '';
return `<label class="toggle">
<input type="checkbox" id="${esc(t.key)}" ${value !== 'false' ? 'checked' : ''}>
<span class="toggle-track"><span class="toggle-thumb"></span></span>
<span class="toggle-text"><b>${esc(t.label)}${badge}</b><small>${esc(t.hint || '')}</small></span>
</label>`;
}).join('');
renderIcons();
}
async function loadSettings() { async function loadSettings() {
const [sRes, stRes] = await Promise.all([ const [sRes, stRes] = await Promise.all([
fetch(`${API}/api/settings`, { headers: hdr() }), fetch(`${API}/api/settings`, { headers: hdr() }),
@@ -161,10 +195,12 @@ async function loadSettings() {
if (!sRes.ok) { showError('Не удалось загрузить настройки'); return; } if (!sRes.ok) { showError('Не удалось загрузить настройки'); return; }
const s = await sRes.json(); const s = await sRes.json();
document.getElementById('systemName').value = s.system_name !== undefined ? s.system_name : 'WhatIDo'; document.getElementById('systemName').value = s.system_name !== undefined ? s.system_name : 'WhatIDo';
currentSystemName = s.system_name || 'WhatIDo';
currentLogo = s.system_logo || ''; currentLogo = s.system_logo || '';
logoOriginal = currentLogo; logoOriginal = currentLogo;
renderLogoPreview(); renderLogoPreview();
if (s.spam_interval_min !== undefined) document.getElementById('spamInterval').value = s.spam_interval_min; if (s.spam_interval_min !== undefined) document.getElementById('spamInterval').value = s.spam_interval_min;
document.getElementById('trashPurgeDays').value = s.trash_purge_days !== undefined ? s.trash_purge_days : '30';
document.getElementById('footerLeft').value = s.footer_left || ''; document.getElementById('footerLeft').value = s.footer_left || '';
document.getElementById('footerRight').value = s.footer_right || ''; document.getElementById('footerRight').value = s.footer_right || '';
document.getElementById('aiPrompt').value = s.ai_prompt || ''; document.getElementById('aiPrompt').value = s.ai_prompt || '';
@@ -181,6 +217,8 @@ async function loadSettings() {
const pe = document.getElementById('photoEngine'); const pe = document.getElementById('photoEngine');
pe.value = ['auto', 'server', 'client'].includes(s.photo_enhance_engine) ? s.photo_enhance_engine : 'auto'; pe.value = ['auto', 'server', 'client'].includes(s.photo_enhance_engine) ? s.photo_enhance_engine : 'auto';
if (!pe.value) pe.value = 'auto'; if (!pe.value) pe.value = 'auto';
document.getElementById('camEnabled').checked = s.camera_enabled !== 'false';
await renderNotifyTypes(s);
snapshotDirty(); snapshotDirty();
updateSpamUi(); updateSpamUi();
loadAiQueue(); loadAiQueue();
@@ -214,20 +252,32 @@ async function refreshStats() {
} }
} }
async function loadSystemInfo() { async function loadSystemInfo(btn) {
const el = document.getElementById('systemInfo'); const el = document.getElementById('systemInfo');
if (!el) return; if (!el) return;
if (btn) btn.classList.add('spinning');
try { try {
const res = await fetch(`${API}/api/system-info`, { headers: hdr() }); const res = await fetch(`${API}/api/system-info`, { headers: hdr() });
if (res.ok) { if (res.ok) {
renderSystemInfo(await res.json()); const data = await res.json();
renderSystemInfo(data);
renderStackInfo(data.stack);
const stamp = 'обновлено ' + new Date().toLocaleTimeString('ru', { hour: '2-digit', minute: '2-digit' });
const live = document.getElementById('systemLive'); const live = document.getElementById('systemLive');
if (live) live.textContent = 'обновлено ' + new Date().toLocaleTimeString('ru', { hour: '2-digit', minute: '2-digit' }); if (live) live.textContent = stamp;
const stackLive = document.getElementById('stackLive');
if (stackLive) stackLive.textContent = stamp;
return; return;
} }
el.innerHTML = '<div class="sys-card err">Ошибка загрузки</div>'; el.innerHTML = '<div class="sys-card err">Ошибка загрузки</div>';
const st = document.getElementById('stackInfo');
if (st) st.innerHTML = '<div class="sys-card err">Ошибка загрузки</div>';
} catch { } catch {
el.innerHTML = '<div class="sys-card err">Ошибка сети</div>'; el.innerHTML = '<div class="sys-card err">Ошибка сети</div>';
const st = document.getElementById('stackInfo');
if (st) st.innerHTML = '<div class="sys-card err">Ошибка сети</div>';
} finally {
if (btn) btn.classList.remove('spinning');
} }
} }
@@ -236,6 +286,7 @@ function renderSystemInfo(si) {
const photos = si.photos || {}; const photos = si.photos || {};
const files = si.files || {}; const files = si.files || {};
const uploads = si.uploads || {}; const uploads = si.uploads || {};
const storage = si.storage || {};
const disk = si.disk; const disk = si.disk;
let tablesHtml = ''; let tablesHtml = '';
@@ -281,10 +332,150 @@ function renderSystemInfo(si) {
<div class="sys-item"><span class="sys-label">Файлов</span><span class="sys-value">${uploads.count || 0}</span></div> <div class="sys-item"><span class="sys-label">Файлов</span><span class="sys-value">${uploads.count || 0}</span></div>
<div class="sys-item"><span class="sys-label">Размер</span><span class="sys-value">${esc(uploads.size || '0 B')}</span></div> <div class="sys-item"><span class="sys-label">Размер</span><span class="sys-value">${esc(uploads.size || '0 B')}</span></div>
</div> </div>
<div class="sys-card">
<div class="sys-title">Хранилище</div>
<div class="sys-item"><span class="sys-label">Драйвер</span><span class="sys-value">${storage.driver === 's3' ? 'S3' : 'Локальное'}</span></div>
${storage.bucket ? `<div class="sys-item"><span class="sys-label">Бакет</span><span class="sys-value">${esc(storage.bucket)}${storage.prefix ? '/' + esc(storage.prefix) : ''}</span></div>` : ''}
<div class="sys-item"><span class="sys-label">Объектов</span><span class="sys-value">${storage.count || 0}</span></div>
<div class="sys-item"><span class="sys-label">Размер</span><span class="sys-value">${esc(storage.size || '0 B')}</span></div>
</div>
${diskHtml || '<div class="sys-card"><div class="sys-title">Диск</div><div class="sys-meta">Недоступно</div></div>'} ${diskHtml || '<div class="sys-card"><div class="sys-title">Диск</div><div class="sys-meta">Недоступно</div></div>'}
`; `;
} }
function fmtDuration(sec) {
const s = Math.max(0, Math.round(Number(sec) || 0));
if (!s) return '—';
const d = Math.floor(s / 86400);
const h = Math.floor((s % 86400) / 3600);
const m = Math.floor((s % 3600) / 60);
if (d) return `${d} ${plural(d, 'день', 'дня', 'дней')} ${h} ${plural(h, 'час', 'часа', 'часов')}`;
if (h) return `${h} ${plural(h, 'час', 'часа', 'часов')} ${m} ${plural(m, 'минута', 'минуты', 'минут')}`;
if (m) return `${m} ${plural(m, 'минута', 'минуты', 'минут')}`;
return `${s} ${plural(s, 'секунда', 'секунды', 'секунд')}`;
}
function stackState(cls, text) {
return `<span class="sys-inline"><span class="worker-dot ${cls}"></span>${esc(text)}</span>`;
}
function stackBar(pct, color) {
const p = Math.min(100, Math.max(0, Math.round(pct || 0)));
return `<div class="sys-bar-wrap"><div class="sys-bar" style="width:${p}%; background:${color}"></div></div>`;
}
function stackRow(label, value) {
if (value === null || value === undefined || value === '') return '';
return `<div class="sys-item"><span class="sys-label">${esc(label)}</span><span class="sys-value">${value}</span></div>`;
}
function renderStackInfo(stack) {
const el = document.getElementById('stackInfo');
if (!el) return;
if (!stack) {
el.innerHTML = '<div class="sys-card err">Стек недоступен</div>';
return;
}
const app = stack.app || {};
const rt = stack.runtime || {};
const db = stack.database || {};
const cache = stack.cache || {};
const st = stack.storage || {};
const deps = stack.deps || {};
const load = Array.isArray(rt.cpu_load) ? rt.cpu_load : [];
const cpus = rt.cpus || 0;
let memPct = 0;
if (rt.mem_total_bytes) memPct = Math.round(((rt.mem_total_bytes - (rt.mem_free_bytes || 0)) / rt.mem_total_bytes) * 100);
const memColor = memPct > 90 ? '#ef4444' : memPct > 70 ? '#f59e0b' : '#16a34a';
const loadPct = cpus && load[0] ? Math.min(100, (load[0] / cpus) * 100) : 0;
const loadColor = loadPct > 90 ? '#ef4444' : loadPct > 60 ? '#f59e0b' : '#16a34a';
const depsList = Object.entries(deps).filter(([, v]) => v);
const depsHtml = depsList.length
? '<details class="sys-details"><summary>Библиотеки</summary><div class="sys-table-list">' +
depsList.map(([name, v]) => `<div class="sys-table-row"><span>${esc(name)}</span><span>${esc(v)}</span></div>`).join('') +
'</div></details>'
: '';
const appTitle = [currentSystemName || app.name, app.version].filter(Boolean).join(' ');
const build = app.commit
? `${esc(appTitle)} · <span title="${esc(app.commit_date || '')}">${esc(app.commit)}</span>`
: esc(appTitle);
const cacheState = cache.driver === 'redis' && cache.ready
? stackState('on', 'подключён')
: cache.enabled
? stackState('err', 'нет связи — в памяти')
: stackState('warn', 'отключён — в памяти');
const cacheTitle = cache.driver === 'redis'
? `Redis${cache.version ? ' ' + cache.version : ''}`
: 'In-memory';
const s3 = st.driver === 's3';
const where = s3
? stackRow('Бакет', esc(`${st.bucket || '—'}${st.region ? ` · ${st.region}` : ''}`))
: stackRow('Каталог', esc(st.dir || '—'));
el.innerHTML = `
<div class="sys-card">
<div class="sys-title">Приложение</div>
${stackRow('Версия', build)}
${stackRow('Node.js', esc(app.node || '—'))}
${stackRow('Процесс', esc(`PID ${app.pid} · ${app.rss || '—'}`))}
${stackRow('Аптайм', esc(fmtDuration(app.uptime_s)))}
<div class="sys-meta">Куча: ${esc(app.heap_used || '—')}${app.name ? ` · пакет ${esc(app.name)}` : ''}</div>
${depsHtml}
</div>
<div class="sys-card${memPct > 90 ? ' danger' : memPct > 70 ? ' warn' : ''}">
<div class="sys-title">Сервер</div>
${stackRow('ОС', esc(rt.os || '—'))}
${stackRow('Ядро', esc(rt.kernel || '—'))}
${stackRow('Архитектура', esc(`${rt.arch || '—'} · ${cpus || '—'} ядер`))}
${rt.cpu_model ? `<div class="sys-meta">${esc(rt.cpu_model)}</div>` : ''}
${stackRow('Загрузка', esc(load.length ? load.map(v => v.toFixed(2)).join(' / ') : '—'))}
${load.length ? stackBar(loadPct, loadColor) : ''}
${stackRow('Память', esc(`${rt.mem_free || '—'} свободно из ${rt.mem_total || '—'}`))}
${stackBar(memPct, memColor)}
${stackRow('Аптайм', esc(fmtDuration(rt.uptime_s)))}
${stackRow('Среда', rt.container ? esc(rt.container) : esc('без контейнера'))}
</div>
<div class="sys-card">
<div class="sys-title">База данных</div>
${stackRow('Состояние', db.version ? stackState('on', 'отвечает') : stackState('err', 'нет связи'))}
${stackRow('Движок', esc(`${db.engine || 'PostgreSQL'}${db.version ? ' ' + db.version : ''}`))}
${stackRow('Адрес', esc(db.host || '—'))}
${stackRow('Соединения', esc(`${db.pool_idle ?? 0} свободно из ${db.pool_total ?? 0}${db.pool_waiting ? ` · в очереди ${db.pool_waiting}` : ''}`))}
</div>
<div class="sys-card">
<div class="sys-title">Кэш</div>
${stackRow('Состояние', cacheState)}
${stackRow('Движок', esc(cacheTitle))}
${stackRow('Адрес', esc(cache.host || '—'))}
${stackRow('Ключей', esc(cache.keys === null || cache.keys === undefined ? '—' : String(cache.keys)))}
${stackRow('Память', esc(cache.used_memory || '—'))}
${stackRow('Попадания / промахи', esc(`${cache.hits ?? 0} / ${cache.misses ?? 0}`))}
${cache.fallback_ops ? stackRow('Операций в памяти', esc(String(cache.fallback_ops))) : ''}
${stackRow('Аптайм', esc(fmtDuration(cache.uptime_s)))}
</div>
<div class="sys-card">
<div class="sys-title">Хранилище</div>
${stackRow('Состояние', stackState('on', s3 ? 'S3-совместимое' : 'локальные файлы'))}
${stackRow('Драйвер', esc(s3 ? 's3' : 'local'))}
${s3 && st.endpoint ? stackRow('Адрес', esc(st.endpoint)) : ''}
${where}
${stackRow('Локальный кеш', s3 ? esc(st.local_fallback ? 'включён' : 'выключен') : esc('—'))}
${s3 ? stackRow('Локальные копии', esc(st.keep_local ? 'хранятся' : 'удаляются')) : ''}
</div>
`;
}
async function loadAiProfiles() { async function loadAiProfiles() {
const listEl = document.getElementById('aiProfilesList'); const listEl = document.getElementById('aiProfilesList');
const select = document.getElementById('aiActiveProfile'); const select = document.getElementById('aiActiveProfile');
@@ -490,6 +681,18 @@ document.addEventListener('keydown', e => {
if (e.key === 'Escape' && document.getElementById('aiProfileModal').classList.contains('open')) closeAiProfileForm(); if (e.key === 'Escape' && document.getElementById('aiProfileModal').classList.contains('open')) closeAiProfileForm();
}); });
function notifySettingsPayload(retentionDays) {
const out = {
notify_enabled: document.getElementById('notifyEnabled').checked ? 'true' : 'false',
notify_retention_days: String(retentionDays),
};
NOTIFY_IDS.forEach(id => {
const el = document.getElementById(id);
if (el) out[id] = el.checked ? 'true' : 'false';
});
return out;
}
async function saveSettings() { async function saveSettings() {
const v = spamInput.value; const v = spamInput.value;
const n = Number(v); const n = Number(v);
@@ -500,6 +703,18 @@ async function saveSettings() {
return; return;
} }
clearFieldError('spamInterval'); clearFieldError('spamInterval');
const trashValue = document.getElementById('trashPurgeDays').value;
const trashDays = Math.round(Number(trashValue));
if (trashValue === '' || !Number.isFinite(trashDays) || trashDays < 1 || trashDays > 365) {
showError('Проверьте поле «Время до безвозвратного удаления» (от 1 до 365 дней)');
return;
}
const notifyRetentionValue = document.getElementById('notifyRetentionDays').value;
const notifyRetentionDays = Math.round(Number(notifyRetentionValue));
if (notifyRetentionValue === '' || !Number.isFinite(notifyRetentionDays) || notifyRetentionDays < 1 || notifyRetentionDays > 365) {
showError('Проверьте поле «Хранение уведомлений» (от 1 до 365 дней)');
return;
}
const btn = document.getElementById('saveBtn'); const btn = document.getElementById('saveBtn');
btn.dataset.loadingText = 'Сохранение...'; btn.dataset.loadingText = 'Сохранение...';
setBtnLoading(btn, true); setBtnLoading(btn, true);
@@ -522,6 +737,9 @@ async function saveSettings() {
photo_capture_resolution: document.getElementById('photoResolution').value, photo_capture_resolution: document.getElementById('photoResolution').value,
photo_capture_quality: String(document.getElementById('photoQuality').value), photo_capture_quality: String(document.getElementById('photoQuality').value),
photo_enhance_engine: document.getElementById('photoEngine').value, photo_enhance_engine: document.getElementById('photoEngine').value,
camera_enabled: document.getElementById('camEnabled').checked ? 'true' : 'false',
trash_purge_days: String(trashDays),
...notifySettingsPayload(notifyRetentionDays),
} }) } })
}); });
if (res.ok) { if (res.ok) {
@@ -540,25 +758,43 @@ async function saveSettings() {
} }
} }
function formatBackupSize(b) {
if (!b || b < 0) return '';
if (b >= 1073741824) return (b / 1073741824).toFixed(2) + ' ГБ';
return (b / 1048576).toFixed(1) + ' МБ';
}
async function downloadBackup() { async function downloadBackup() {
const btn = document.getElementById('backupBtn'); const btn = document.getElementById('backupBtn');
const st = document.getElementById('backupStatus');
btn.dataset.loadingText = 'Формирование...'; btn.dataset.loadingText = 'Формирование...';
setBtnLoading(btn, true); setBtnLoading(btn, true);
if (st) { st.classList.remove('err'); st.textContent = 'Формирование архива на сервере…'; }
try { try {
const res = await fetch(`${API}/api/backup`, { headers: hdr() }); const res = await fetch(`${API}/api/backup`, { method: 'POST', headers: hdr() });
if (!res.ok) { showError('Ошибка скачивания: ' + (await res.text())); return; } const data = await res.json().catch(() => ({}));
const blob = await res.blob(); if (!res.ok) {
const cd = res.headers.get('Content-Disposition') || ''; if (st) { st.classList.add('err'); st.textContent = 'Не удалось сформировать бэкап'; }
const m = cd.match(/filename="([^"]+)"/); showError('Ошибка формирования бэкапа: ' + (data.error || res.status));
const name = m ? m[1] : `whatido-backup-${new Date().toISOString().slice(0, 10)}.json.gz`; return;
const url = URL.createObjectURL(blob); }
const head = await fetch(data.url, { method: 'HEAD' }).catch(() => null);
if (head && !head.ok) {
if (st) { st.classList.add('err'); st.textContent = 'Ссылка на архив устарела, попробуйте ещё раз'; }
showError('Ошибка скачивания бэкапа');
return;
}
const a = document.createElement('a'); const a = document.createElement('a');
a.href = url; a.href = data.url;
a.download = name; a.download = data.filename || 'whatido-backup.tar.gz';
document.body.appendChild(a);
a.click(); a.click();
URL.revokeObjectURL(url); a.remove();
showToast('Бэкап скачан'); const size = formatBackupSize(data.size);
} catch { if (st) st.textContent = `Архив ${data.filename}${size ? ` (${size})` : ''} — скачивается браузером`;
showToast('Бэкап сформирован' + (size ? ': ' + size : ''));
} catch (err) {
if (st) { st.classList.add('err'); st.textContent = 'Не удалось сформировать бэкап: ' + err.message; }
showError('Ошибка сети при формировании бэкапа'); showError('Ошибка сети при формировании бэкапа');
} finally { } finally {
setBtnLoading(btn, false); setBtnLoading(btn, false);
@@ -647,10 +883,25 @@ document.getElementById('saveBtn').addEventListener('click', saveSettings);
document.getElementById('resetBtn').addEventListener('click', revertChanges); document.getElementById('resetBtn').addEventListener('click', revertChanges);
const systemRefreshBtn = document.getElementById('systemRefreshBtn'); const systemRefreshBtn = document.getElementById('systemRefreshBtn');
if (systemRefreshBtn) systemRefreshBtn.addEventListener('click', async () => { if (systemRefreshBtn) systemRefreshBtn.addEventListener('click', () => loadSystemInfo(systemRefreshBtn));
systemRefreshBtn.classList.add('spinning');
await loadSystemInfo(); const stackRefreshBtn = document.getElementById('stackRefreshBtn');
systemRefreshBtn.classList.remove('spinning'); if (stackRefreshBtn) stackRefreshBtn.addEventListener('click', () => loadSystemInfo(stackRefreshBtn));
const notifyTestBtn = document.getElementById('notifyTestBtn');
if (notifyTestBtn) notifyTestBtn.addEventListener('click', async () => {
notifyTestBtn.classList.add('spinning');
try {
const res = await fetch(`${API}/api/notifications/test`, { method: 'POST', headers: hdr() });
const data = await res.json().catch(() => ({}));
if (!res.ok) { showError(data.error || 'Ошибка отправки уведомления'); return; }
if (data.delivered) showToast('Тестовое уведомление отправлено');
else showError('Уведомление не создано: выключен общий переключатель или событие «Тестовое уведомление»');
} catch {
showError('Ошибка сети');
} finally {
notifyTestBtn.classList.remove('spinning');
}
}); });
const statsRefreshBtn = document.getElementById('statsRefreshBtn'); const statsRefreshBtn = document.getElementById('statsRefreshBtn');
@@ -737,12 +988,12 @@ window.addEventListener('beforeunload', e => {
(async () => { (async () => {
if (await requireAdminPage()) { if (await requireAdminPage()) {
buildSidebar(document.body.dataset.page); buildSidebar(document.body.dataset.page);
bindDirty();
initSectionNav(); initSectionNav();
updateSpamUi(); updateSpamUi();
const authStatus = document.getElementById('authStatus'); const authStatus = document.getElementById('authStatus');
try { try {
await loadSettings(); await loadSettings();
bindDirty();
if (authStatus) authStatus.classList.add('loaded'); if (authStatus) authStatus.classList.add('loaded');
} catch { } catch {
if (authStatus) { authStatus.classList.remove('loaded'); authStatus.classList.add('err'); authStatus.textContent = 'Ошибка загрузки настроек'; } if (authStatus) { authStatus.classList.remove('loaded'); authStatus.classList.add('err'); authStatus.textContent = 'Ошибка загрузки настроек'; }
+177 -13
View File
@@ -1,7 +1,14 @@
const PAGE_SIZE = 20; const PAGE_SIZE = 20;
let currentEntries = []; let currentEntries = [];
let currentGroups = [];
let pendingEntries = [];
let pendingGroups = [];
let page = 1; let page = 1;
let totalEntries = 0; let totalEntries = 0;
let totalGroups = 0;
let pendingTotalEntries = 0;
let pendingTotalGroups = 0;
let purgeDays = 30;
function isImageFile(name) { function isImageFile(name) {
return /\.(jpe?g|jfif|png|gif|webp|bmp|avif|svg|ico)$/i.test(name || ''); return /\.(jpe?g|jfif|png|gif|webp|bmp|avif|svg|ico)$/i.test(name || '');
@@ -28,32 +35,129 @@ function entryHTML(e) {
<div class="trash-time">Удалена: ${new Date(e.deleted_at).toLocaleString('ru')}</div> <div class="trash-time">Удалена: ${new Date(e.deleted_at).toLocaleString('ru')}</div>
</div> </div>
<button class="restore-btn" data-restore="${e.id}" title="Восстановить">Восстановить</button> <button class="restore-btn" data-restore="${e.id}" title="Восстановить">Восстановить</button>
<button class="del-btn" data-hard="${e.id}" title="Удалить навсегда (вместе с файлами)">&times;</button> <button class="del-btn" data-hard="${e.id}" title="Пометить на удаление (через ${purgeDays} дней будет удалена безвозвратно)">&times;</button>
</div> </div>
`; `;
} }
function groupHTML(g) {
return `
<div class="card">
${g.cover_path ? `<img src="${thumbSrc(g.cover_path)}" data-img="${API}${g.cover_path}" loading="lazy" decoding="async" alt="">` : ''}
<div class="info">
<div class="name">${esc(g.name)}</div>
${g.branch_name ? `<div class="group">${esc(g.branch_name)}</div>` : ''}
<div class="trash-time">Удалена: ${new Date(g.deleted_at).toLocaleString('ru')}</div>
</div>
<button class="restore-btn" data-restore-group="${g.id}" title="Восстановить">Восстановить</button>
<button class="del-btn" data-hard-group="${g.id}" title="Пометить на удаление (через ${purgeDays} дней будет удалена безвозвратно вместе с записями и фото)">&times;</button>
</div>
`;
}
function pendingEntryHTML(e) {
return `
<div class="card pending">
${e.photo_path ? `<img src="${thumbSrc(e.photo_path)}" data-img="${API}${e.photo_path}" loading="lazy" decoding="async" alt="">` : ''}
<div class="info">
<div class="name">${esc(e.student_name)}</div>
<div class="group">${esc(e.group_name)}</div>
<div class="desc">${esc(e.description)}</div>
${filesHTML(e.files)}
<div class="trash-time warn">Удаление через ${purgeDays} дн.: ${new Date(e.purge_at).toLocaleString('ru')}</div>
</div>
<button class="restore-btn" data-unschedule="${e.id}" title="Вернуть в корзину">В корзину</button>
</div>
`;
}
function pendingGroupHTML(g) {
return `
<div class="card pending">
${g.cover_path ? `<img src="${thumbSrc(g.cover_path)}" data-img="${API}${g.cover_path}" loading="lazy" decoding="async" alt="">` : ''}
<div class="info">
<div class="name">${esc(g.name)}</div>
${g.branch_name ? `<div class="group">${esc(g.branch_name)}</div>` : ''}
<div class="trash-time warn">Удаление через ${purgeDays} дн.: ${new Date(g.purge_at).toLocaleString('ru')}</div>
</div>
<button class="restore-btn" data-unschedule-group="${g.id}" title="Вернуть в корзину">В корзину</button>
</div>
`;
}
function renderGroups() {
const grid = document.getElementById('groupsGrid');
grid.className = 'grid';
grid.innerHTML = currentGroups.length
? currentGroups.map(groupHTML).join('')
: '<div class="empty">Групп в корзине нет</div>';
document.getElementById('groupsInfo').textContent =
totalGroups > 0 ? `— групп в корзине: ${totalGroups}` : '';
}
function renderGrid() { function renderGrid() {
const grid = document.getElementById('grid'); const grid = document.getElementById('grid');
grid.className = 'grid'; grid.className = 'grid';
grid.innerHTML = currentEntries.length grid.innerHTML = currentEntries.length
? currentEntries.map(entryHTML).join('') ? currentEntries.map(entryHTML).join('')
: '<div class="empty">Корзина пуста</div>'; : '<div class="empty">Записей в корзине нет</div>';
document.getElementById('entriesInfo').textContent =
totalEntries > 0 ? `— записей в корзине: ${totalEntries}` : '';
document.getElementById('trashInfo').textContent = document.getElementById('trashInfo').textContent =
totalEntries > 0 ? `Записей в корзине: ${totalEntries}` : ''; totalEntries + totalGroups > 0
document.getElementById('emptyBtn').disabled = totalEntries === 0; ? `В корзине: записей ${totalEntries}, групп ${totalGroups}`
: 'Корзина пуста';
document.getElementById('emptyBtn').disabled = totalEntries + totalGroups === 0;
renderPager(document.getElementById('pager'), page, Math.max(1, Math.ceil(totalEntries / PAGE_SIZE)), goPage); renderPager(document.getElementById('pager'), page, Math.max(1, Math.ceil(totalEntries / PAGE_SIZE)), goPage);
} }
function renderPending() {
document.getElementById('pendingGroupsGrid').className = 'grid';
document.getElementById('pendingGroupsGrid').innerHTML = pendingGroups.length
? pendingGroups.map(pendingGroupHTML).join('')
: '<div class="empty">Нет групп, помеченных на удаление</div>';
document.getElementById('pendingGrid').className = 'grid';
document.getElementById('pendingGrid').innerHTML = pendingEntries.length
? pendingEntries.map(pendingEntryHTML).join('')
: '<div class="empty">Нет записей, помеченных на удаление</div>';
document.getElementById('pendingGroupsInfo').textContent =
pendingTotalGroups > 0 ? `— ${pendingTotalGroups}` : '';
document.getElementById('pendingEntriesInfo').textContent =
pendingTotalEntries > 0 ? `— ${pendingTotalEntries}` : '';
const n = pendingTotalEntries + pendingTotalGroups;
document.getElementById('pendingInfo').textContent =
n > 0 ? `— будет удалено: ${n}` : '';
const hint = document.getElementById('pendingToggleHint');
if (hint) {
hint.textContent = n > 0
? `Помечено: записей ${pendingTotalEntries}, групп ${pendingTotalGroups} — безвозвратно удалятся через ${purgeDays} дней`
: 'Данных, помеченных на удаление, нет';
}
document.getElementById('pendingSection').hidden = !document.getElementById('pendingToggle').checked;
}
async function loadTrash() { async function loadTrash() {
const p = new URLSearchParams(); const p = new URLSearchParams();
p.set('limit', PAGE_SIZE); p.set('limit', PAGE_SIZE);
p.set('offset', (page - 1) * PAGE_SIZE); p.set('offset', (page - 1) * PAGE_SIZE);
const res = await fetch(`${API}/api/trash?${p}`, { headers: hdr() }); const [trashRes, pendRes] = await Promise.all([
const data = await res.json(); fetch(`${API}/api/trash?${p}`, { headers: hdr() }),
fetch(`${API}/api/trash/pending`, { headers: hdr() }),
]);
const data = await trashRes.json();
const pend = await pendRes.json();
currentEntries = data.entries; currentEntries = data.entries;
totalEntries = data.total; totalEntries = data.total;
currentGroups = data.groups;
totalGroups = data.total_groups;
pendingEntries = pend.entries;
pendingTotalEntries = pend.total;
pendingGroups = pend.groups;
pendingTotalGroups = pend.total_groups;
if (pend.purge_days > 0) purgeDays = pend.purge_days;
renderGroups();
renderGrid(); renderGrid();
renderPending();
} }
function goPage(p) { page = p; loadTrash(); } function goPage(p) { page = p; loadTrash(); }
@@ -65,22 +169,55 @@ async function restoreEntry(id) {
} }
async function hardDelete(id) { async function hardDelete(id) {
if (!confirm('Удалить запись навсегда? Файлы и фото будут удалены безвозвратно.')) return; if (!confirm(`Пометить запись на удаление? Она будет безвозвратно удалена через ${purgeDays} дней вместе с файлами и фото.`)) return;
await fetch(`${API}/api/entries/${id}/permanent`, { method: 'DELETE', headers: hdr() }); const res = await fetch(`${API}/api/entries/${id}/permanent`, { method: 'DELETE', headers: hdr() });
showToast('Запись удалена навсегда'); if (res.ok) { showToast(`Запись помечена на удаление через ${purgeDays} дн.`); loadTrash(); }
loadTrash(); else { const err = await res.json(); showToast(err.error || 'Ошибка'); }
}
async function restoreGroup(id) {
const res = await fetch(`${API}/api/groups/${id}/restore`, { method: 'PUT', headers: hdr() });
if (res.ok) { showToast('Группа восстановлена'); loadTrash(); }
else { const err = await res.json(); showToast(err.error || 'Ошибка восстановления'); }
}
async function hardDeleteGroup(id, name) {
if (!confirm(`Пометить группу «${name}» на удаление? Записи журнала, фото и файлы группы будут безвозвратно удалены через ${purgeDays} дней, ученики открепятся.`)) return;
const res = await fetch(`${API}/api/groups/${id}/permanent`, { method: 'DELETE', headers: hdr() });
if (res.ok) { showToast(`Группа помечена на удаление через ${purgeDays} дн.`); loadTrash(); }
else { const err = await res.json(); showToast(err.error || 'Ошибка'); }
}
async function unscheduleEntry(id) {
const res = await fetch(`${API}/api/entries/${id}/unschedule`, { method: 'PUT', headers: hdr() });
if (res.ok) { showToast('Запись возвращена в корзину'); loadTrash(); }
else { const err = await res.json(); showToast(err.error || 'Ошибка'); }
}
async function unscheduleGroup(id) {
const res = await fetch(`${API}/api/groups/${id}/unschedule`, { method: 'PUT', headers: hdr() });
if (res.ok) { showToast('Группа возвращена в корзину'); loadTrash(); }
else { const err = await res.json(); showToast(err.error || 'Ошибка'); }
} }
async function emptyTrash() { async function emptyTrash() {
if (totalEntries === 0) return; const parts = [];
if (!confirm(`Удалить все записи из корзины (${totalEntries})? Файлы будут удалены безвозвратно.`)) return; if (totalEntries) parts.push(`записей: ${totalEntries}`);
if (totalGroups) parts.push(`групп: ${totalGroups}`);
if (!parts.length) return;
if (!confirm(`Пометить всё из корзины (${parts.join(', ')}) на удаление? Данные будут безвозвратно удалены через ${purgeDays} дней.`)) return;
const res = await fetch(`${API}/api/trash`, { method: 'DELETE', headers: hdr() }); const res = await fetch(`${API}/api/trash`, { method: 'DELETE', headers: hdr() });
if (res.ok) { showToast('Корзина очищена'); page = 1; loadTrash(); } if (res.ok) { showToast(`Всё помечено на удаление через ${purgeDays} дн.`); page = 1; loadTrash(); }
else { const err = await res.json(); alert(err.error || 'Ошибка'); } else { const err = await res.json(); alert(err.error || 'Ошибка'); }
} }
document.getElementById('emptyBtn').addEventListener('click', emptyTrash); document.getElementById('emptyBtn').addEventListener('click', emptyTrash);
document.getElementById('imgModal').addEventListener('click', e => e.currentTarget.classList.remove('open')); document.getElementById('imgModal').addEventListener('click', e => e.currentTarget.classList.remove('open'));
document.getElementById('pendingToggle').addEventListener('change', () => {
const open = document.getElementById('pendingToggle').checked;
document.getElementById('pendingSection').hidden = !open;
if (open) loadTrash();
});
document.getElementById('grid').addEventListener('click', e => { document.getElementById('grid').addEventListener('click', e => {
const card = e.target.closest('.card'); const card = e.target.closest('.card');
@@ -91,6 +228,33 @@ document.getElementById('grid').addEventListener('click', e => {
if (hd && (e.target === hd || hd.contains(e.target))) { hardDelete(parseInt(hd.dataset.hard, 10)); } if (hd && (e.target === hd || hd.contains(e.target))) { hardDelete(parseInt(hd.dataset.hard, 10)); }
}); });
document.getElementById('groupsGrid').addEventListener('click', e => {
const card = e.target.closest('.card');
if (!card) return;
const rs = card.querySelector('[data-restore-group]');
if (rs && (e.target === rs || rs.contains(e.target))) { restoreGroup(parseInt(rs.dataset.restoreGroup, 10)); return; }
const hd = card.querySelector('[data-hard-group]');
if (hd && (e.target === hd || hd.contains(e.target))) {
const id = parseInt(hd.dataset.hardGroup, 10);
const g = currentGroups.find(x => String(x.id) === String(id));
hardDeleteGroup(id, g ? g.name : id);
}
});
document.getElementById('pendingGrid').addEventListener('click', e => {
const card = e.target.closest('.card');
if (!card) return;
const us = card.querySelector('[data-unschedule]');
if (us && (e.target === us || us.contains(e.target))) { unscheduleEntry(parseInt(us.dataset.unschedule, 10)); }
});
document.getElementById('pendingGroupsGrid').addEventListener('click', e => {
const card = e.target.closest('.card');
if (!card) return;
const us = card.querySelector('[data-unschedule-group]');
if (us && (e.target === us || us.contains(e.target))) { unscheduleGroup(parseInt(us.dataset.unscheduleGroup, 10)); }
});
(async () => { (async () => {
if (await checkAuth()) { if (await checkAuth()) {
buildSidebar(document.body.dataset.page); buildSidebar(document.body.dataset.page);
+43
View File
@@ -0,0 +1,43 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Уведомления — Админ-панель</title>
<link rel="stylesheet" href="admin.css">
</head>
<body data-page="notifications">
<div class="layout">
<div class="sidebar" id="sidebar"></div>
<div class="main">
<div class="page-head">
<h2>Уведомления</h2>
<p class="page-sub">События системы: новые записи в журнале, обработка фото нейросетью, блокировки IP и другое</p>
<span class="page-status" id="authStatus"><span class="spinner dark"></span> Загрузка…</span>
<button class="head-btn" id="nfReloadBtn" type="button" title="Обновить список"><i data-lucide="rotate-ccw"></i> Обновить</button>
</div>
<div class="notify-page-head">
<div class="view-toggle" id="notifyFilter">
<button type="button" data-filter="all" class="active">Все</button>
<button type="button" data-filter="unread">Непрочитанные</button>
</div>
<span class="grow"></span>
<button class="btn-primary ghost" id="nfReadAllBtn" type="button"><i data-lucide="check"></i> Прочитать все</button>
<button class="btn-primary danger-ghost" id="nfClearBtn" type="button" hidden><i data-lucide="trash-2"></i> Очистить всё</button>
</div>
<div class="hint" id="nfState"></div>
<div class="notify-page-list" id="nfList"></div>
<div style="display:flex;justify-content:center;margin-top:16px">
<button class="btn-primary ghost" id="nfMoreBtn" type="button" hidden>Показать ещё</button>
</div>
</div>
</div>
<div class="toast" id="toast"></div>
<script src="vendor/lucide.min.js"></script>
<script src="admin.js"></script>
<script src="js/notifications.js"></script>
</body>
</html>
+49
View File
@@ -0,0 +1,49 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Фото — Админ-панель</title>
<link rel="stylesheet" href="admin.css">
<style>
.ph-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:14px}
.ph-card{background:var(--card);border:1px solid var(--border);border-radius:12px;overflow:hidden;display:flex;flex-direction:column;min-width:0}
.ph-card img{width:100%;aspect-ratio:4/3;object-fit:cover;display:block;cursor:zoom-in;background:var(--bg);transition:opacity .15s}
.ph-card:hover img{opacity:.9}
.ph-card .ph-body{padding:10px 12px;display:flex;flex-direction:column;gap:4px;min-width:0}
.ph-badge{align-self:flex-start;background:var(--bg);border:1px solid var(--border);color:var(--muted);border-radius:999px;padding:2px 8px;font-size:.68rem;font-weight:700}
.ph-title{font-size:.82rem;font-weight:600;overflow:hidden;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;word-break:break-word}
.ph-meta{font-size:.72rem;color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.ph-time{font-size:.7rem;color:var(--muted)}
.ph-link{font-size:.72rem;color:var(--accent);text-decoration:none}
.ph-link:hover{text-decoration:underline}
</style>
</head>
<body data-page="photos">
<div class="layout">
<div class="sidebar" id="sidebar"></div>
<div class="main">
<h2>Фото</h2>
<div class="filters">
<div><label>Поиск</label><input type="text" id="searchInput" class="search-input" placeholder="Описание или имя"></div>
<div><label>Ученик</label><input type="text" id="studentFilter" list="studentFilterList" placeholder="Все / введите имя..."><datalist id="studentFilterList"></datalist></div>
<div><label>Группа</label><select id="groupFilter"><option value="">Все группы</option></select></div>
<div><label>Источник</label><select id="sourceFilter"><option value="">Все источники</option></select></div>
<div><label>С</label><input type="date" id="dateFrom"></div>
<div><label>По</label><input type="date" id="dateTo"></div>
</div>
<div class="ph-grid" id="grid"><div class="empty" style="grid-column:1/-1">Загрузка...</div></div>
<div class="pager" id="pager"></div>
</div>
</div>
<div class="modal-overlay" id="imgModal">
<img id="imgModalSrc">
</div>
<div class="toast" id="toast"></div>
<script src="vendor/lucide.min.js"></script>
<script src="admin.js"></script>
<script src="js/photos.js"></script>
</body>
</html>
+62 -1
View File
@@ -20,13 +20,16 @@
<div class="settings-layout"> <div class="settings-layout">
<aside class="settings-nav" id="settingsNav"> <aside class="settings-nav" id="settingsNav">
<a href="#sec-system" data-nav="sec-system" class="active"><i data-lucide="bar-chart-3"></i> Система</a> <a href="#sec-system" data-nav="sec-system" class="active"><i data-lucide="bar-chart-3"></i> Система</a>
<a href="#sec-stack" data-nav="sec-stack"><i data-lucide="server"></i> Стек</a>
<a href="#sec-brand" data-nav="sec-brand"><i data-lucide="palette"></i> Брендинг</a> <a href="#sec-brand" data-nav="sec-brand"><i data-lucide="palette"></i> Брендинг</a>
<a href="#sec-antispam" data-nav="sec-antispam"><i data-lucide="shield"></i> Антиспам</a> <a href="#sec-antispam" data-nav="sec-antispam"><i data-lucide="shield"></i> Антиспам</a>
<a href="#sec-share" data-nav="sec-share"><i data-lucide="link"></i> Публичные ссылки</a> <a href="#sec-share" data-nav="sec-share"><i data-lucide="link"></i> Публичные ссылки</a>
<a href="#sec-form" data-nav="sec-form"><i data-lucide="file-text"></i> Форма ответа</a> <a href="#sec-form" data-nav="sec-form"><i data-lucide="file-text"></i> Форма ответа</a>
<a href="#sec-photo" data-nav="sec-photo"><i data-lucide="camera"></i> Фото</a> <a href="#sec-photo" data-nav="sec-photo"><i data-lucide="camera"></i> Фото</a>
<a href="#sec-notify" data-nav="sec-notify"><i data-lucide="bell"></i> Уведомления</a>
<a href="#sec-ai" data-nav="sec-ai"><i data-lucide="bot"></i> ИИ</a> <a href="#sec-ai" data-nav="sec-ai"><i data-lucide="bot"></i> ИИ</a>
<a href="#sec-backup" data-nav="sec-backup"><i data-lucide="database"></i> Бэкапы</a> <a href="#sec-backup" data-nav="sec-backup"><i data-lucide="database"></i> Бэкапы</a>
<a href="#sec-trash" data-nav="sec-trash"><i data-lucide="trash-2"></i> Корзина</a>
<a href="#sec-bans" data-nav="sec-bans"><i data-lucide="shield-off"></i> Блокировки</a> <a href="#sec-bans" data-nav="sec-bans"><i data-lucide="shield-off"></i> Блокировки</a>
</aside> </aside>
@@ -43,6 +46,19 @@
</div> </div>
</div> </div>
<div class="settings-card wide" id="sec-stack">
<div class="card-head">
<div class="ic"><i data-lucide="server"></i></div>
<h3>Статус стека</h3>
<button class="icon-btn" id="stackRefreshBtn" type="button" title="Обновить" aria-label="Обновить стек"><i data-lucide="refresh-cw"></i></button>
<span class="head-live" id="stackLive" title="Версии и состояние сервисов"></span>
</div>
<p class="hint">На чём работает приложение прямо сейчас: версии компонентов, состояние сервисов и нагрузка. Обновление перезагружает данные и этого блока, и системной информации выше.</p>
<div id="stackInfo" class="sys-grid">
<div class="sys-card loading">Загрузка…</div>
</div>
</div>
<div class="settings-card" id="sec-brand"> <div class="settings-card" id="sec-brand">
<div class="card-head"> <div class="card-head">
<div class="ic"><i data-lucide="palette"></i></div> <div class="ic"><i data-lucide="palette"></i></div>
@@ -173,7 +189,7 @@
<h3>Резервное копирование</h3> <h3>Резервное копирование</h3>
<button class="icon-btn" id="statsRefreshBtn" type="button" title="Обновить статистику" aria-label="Обновить статистику"><i data-lucide="refresh-cw"></i></button> <button class="icon-btn" id="statsRefreshBtn" type="button" title="Обновить статистику" aria-label="Обновить статистику"><i data-lucide="refresh-cw"></i></button>
</div> </div>
<p class="hint">Скачайте архив со всеми данными (ученики, группы, журнал, настройки) и фотографиями. Восстановление полностью заменит текущие данные.</p> <p class="hint">Скачайте архив со всеми данными (ученики, группы, журнал, настройки) и фотографиями. Архив формируется на сервере, скачивание выполняет браузер — при обрыве связи загрузку можно продолжить. Восстановление полностью заменит текущие данные.</p>
<div class="data-stats" id="dataStats"><span class="chip">Загрузка статистики…</span></div> <div class="data-stats" id="dataStats"><span class="chip">Загрузка статистики…</span></div>
<div class="card-foot"> <div class="card-foot">
<button class="btn-primary ghost" id="backupBtn" type="button"><i data-lucide="download"></i> Скачать бэкап</button> <button class="btn-primary ghost" id="backupBtn" type="button"><i data-lucide="download"></i> Скачать бэкап</button>
@@ -181,6 +197,7 @@
<input type="file" id="restoreFile" accept=".tar.gz,.gz,application/gzip" style="display:none"> <input type="file" id="restoreFile" accept=".tar.gz,.gz,application/gzip" style="display:none">
</div> </div>
<div class="restore-status" id="restoreStatus"></div> <div class="restore-status" id="restoreStatus"></div>
<div class="restore-status" id="backupStatus"></div>
</div> </div>
<div class="settings-card" id="sec-form"> <div class="settings-card" id="sec-form">
@@ -207,6 +224,11 @@
<h3>Фотография с веб-камеры</h3> <h3>Фотография с веб-камеры</h3>
</div> </div>
<p class="hint">Разрешение и качество снимка на форме ответа ученика. Камера может выдать разрешение, ближайшее к выбранному.</p> <p class="hint">Разрешение и качество снимка на форме ответа ученика. Камера может выдать разрешение, ближайшее к выбранному.</p>
<label class="toggle">
<input type="checkbox" id="camEnabled">
<span class="toggle-track"><span class="toggle-thumb"></span></span>
<span class="toggle-text"><b>Кнопка «Камера»</b><small>Показывать кнопку «Камера» на форме ответа. Если выключено, фото делается кликом по превью «Нажми чтобы сделать фото»</small></span>
</label>
<div class="settings-row"> <div class="settings-row">
<div class="settings-field"> <div class="settings-field">
<label for="photoResolution">Разрешение</label> <label for="photoResolution">Разрешение</label>
@@ -237,6 +259,45 @@
</div> </div>
</div> </div>
<div class="settings-card" id="sec-trash">
<div class="card-head">
<div class="ic"><i data-lucide="trash-2"></i></div>
<h3>Корзина</h3>
</div>
<p class="hint">При очистке корзины или удалении пункта данные не удаляются сразу, а помечаются на удаление и исчезают из корзины. Через указанное количество дней они удаляются автоматически и безвозвратно вместе с файлами. До этого помеченные данные можно вернуть в корзину и восстановить (страница «Корзина»).</p>
<div class="settings-field">
<label for="trashPurgeDays">Время до безвозвратного удаления</label>
<div class="unit-row">
<input type="number" id="trashPurgeDays" class="settings-input" min="1" max="365" step="1">
<span class="unit">дней</span>
</div>
<span class="hint">От 1 до 365 дней</span>
</div>
</div>
<div class="settings-card wide" id="sec-notify">
<div class="card-head">
<div class="ic"><i data-lucide="bell"></i></div>
<h3>Уведомления</h3>
<button class="icon-btn" id="notifyTestBtn" type="button" title="Отправить тестовое уведомление" aria-label="Тестовое уведомление"><i data-lucide="send"></i></button>
</div>
<p class="hint">Уведомления собираются в «колокольчике» админ-панели и на странице «Уведомления». Здесь выбирается, о каких событиях сообщать: новые записи журнала, обработка фото нейросетью, блокировки IP, бэкапы и другое.</p>
<label class="toggle">
<input type="checkbox" id="notifyEnabled">
<span class="toggle-track"><span class="toggle-thumb"></span></span>
<span class="toggle-text"><b>Уведомления включены</b><small>Общий выключатель: при снятии галочки не создаётся ни одно уведомление</small></span>
</label>
<div class="settings-field">
<label for="notifyRetentionDays">Хранение уведомлений</label>
<div class="unit-row">
<input type="number" id="notifyRetentionDays" class="settings-input" min="1" max="365" step="1">
<span class="unit">дней</span>
</div>
<span class="hint">От 1 до 365 дней, старые уведомления удаляются автоматически</span>
</div>
<div class="notify-types" id="notifyTypes"><span class="hint">Загрузка…</span></div>
</div>
<div class="settings-card" id="sec-bans"> <div class="settings-card" id="sec-bans">
<div class="card-head"> <div class="card-head">
<div class="ic"><i data-lucide="shield-off"></i></div> <div class="ic"><i data-lucide="shield-off"></i></div>
+20
View File
@@ -15,9 +15,29 @@
<span class="trash-muted" id="trashInfo"></span> <span class="trash-muted" id="trashInfo"></span>
<button class="danger-btn" id="emptyBtn" type="button">Очистить корзину</button> <button class="danger-btn" id="emptyBtn" type="button">Очистить корзину</button>
</div> </div>
<label class="toggle pending-toggle" id="pendingToggleRow">
<input type="checkbox" id="pendingToggle">
<span class="toggle-track"><span class="toggle-thumb"></span></span>
<span class="toggle-text"><b>Показать помеченные на удаление</b><small id="pendingToggleHint">Данные не удаляются сразу: они помечаются и исчезают из корзины. Здесь их можно вернуть в корзину и восстановить</small></span>
</label>
<div class="trash-section" id="pendingSection" hidden>
<h3>Помечены на удаление <span class="trash-muted" id="pendingInfo"></span></h3>
<p class="trash-muted">Эти данные будут удалены автоматически и безвозвратно вместе с файлами. Чтобы отменить удаление, нажмите «В корзину» — данные вернутся в корзину, откуда их можно восстановить.</p>
<h4>Группы <span class="trash-muted" id="pendingGroupsInfo"></span></h4>
<div class="grid" id="pendingGroupsGrid"><div class="empty">Загрузка...</div></div>
<h4>Записи журнала <span class="trash-muted" id="pendingEntriesInfo"></span></h4>
<div class="grid" id="pendingGrid"><div class="empty">Загрузка...</div></div>
</div>
<div class="trash-section" id="groupsSection">
<h3>Группы <span class="trash-muted" id="groupsInfo"></span></h3>
<div class="grid" id="groupsGrid"><div class="empty">Загрузка...</div></div>
</div>
<div class="trash-section">
<h3>Записи журнала <span class="trash-muted" id="entriesInfo"></span></h3>
<div class="grid" id="grid"><div class="empty">Загрузка...</div></div> <div class="grid" id="grid"><div class="empty">Загрузка...</div></div>
<div class="pager" id="pager"></div> <div class="pager" id="pager"></div>
</div> </div>
</div>
</div> </div>
<div class="modal-overlay" id="imgModal"> <div class="modal-overlay" id="imgModal">
+464
View File
@@ -0,0 +1,464 @@
const { createClient } = require('redis');
const INCR_EXPIRE_LUA = `
local n = redis.call('INCR', KEYS[1])
if n == 1 then redis.call('PEXPIRE', KEYS[1], ARGV[1]) end
return n
`;
const RATE_LIMIT_DEC_LUA = `
local hits = redis.call('DECR', KEYS[1])
if hits < 0 then
redis.call('SET', KEYS[1], '0', 'PX', ARGV[1])
hits = 0
end
return hits
`;
const SCAN_BATCH = 200;
function parseInfoSections(chunks) {
const out = new Map();
for (const chunk of chunks) {
for (const line of String(chunk || '').split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const sep = trimmed.indexOf(':');
if (sep < 1) continue;
out.set(trimmed.slice(0, sep).trim(), trimmed.slice(sep + 1).trim());
}
}
return out;
}
function createMemoryBackend() {
const store = new Map();
const listeners = new Map();
function live(key) {
const e = store.get(key);
if (!e) return null;
if (e.exp && e.exp <= Date.now()) {
store.delete(key);
return null;
}
return e;
}
return {
name: 'memory',
async get(key) {
const e = live(key);
return e ? e.value : undefined;
},
async set(key, value, ttlMs) {
store.set(key, { value, exp: ttlMs ? Date.now() + ttlMs : 0 });
},
async del(key) {
store.delete(key);
},
async dropPrefix(prefix) {
for (const key of store.keys()) if (key.startsWith(prefix)) store.delete(key);
},
async clear() {
store.clear();
},
async incr(key, ttlMs) {
const e = live(key);
if (!e) {
store.set(key, { value: 1, exp: ttlMs ? Date.now() + ttlMs : 0 });
return 1;
}
e.value = (typeof e.value === 'number' ? e.value : 0) + 1;
return e.value;
},
async rateLimitDec(key, ttlMs) {
const e = live(key);
if (!e) {
store.set(key, { value: 0, exp: Date.now() + ttlMs });
return 0;
}
e.value = Math.max(0, (typeof e.value === 'number' ? e.value : 0) - 1);
return e.value;
},
async publish(channel, payload) {
const subs = listeners.get(channel);
if (!subs || !subs.size) return 0;
for (const fn of [...subs]) {
try { fn(payload); } catch (e) { console.error('Cache listener failed:', e.message); }
}
return subs.size;
},
on(channel, fn) {
if (!listeners.has(channel)) listeners.set(channel, new Set());
const set = listeners.get(channel);
set.add(fn);
return () => { set.delete(fn); };
},
sweep() {
const now = Date.now();
for (const [key, e] of store) if (e.exp && e.exp <= now) store.delete(key);
},
};
}
function createRedis({ url, prefix } = {}) {
const namespace = `${prefix && String(prefix).trim() ? String(prefix).trim() : 'whatido'}:`;
const memory = createMemoryBackend();
const handlers = new Map();
const subscribed = new Set();
const stats = { hits: 0, misses: 0, writes: 0, drops: 0, errors: 0, publishes: 0, fallbackOps: 0 };
const CONNECT_TIMEOUT_MS = Math.max(500, parseInt(process.env.REDIS_CONNECT_TIMEOUT_MS || '5000', 10) || 5000);
let client = null;
let sub = null;
let connecting = null;
let warned = false;
let sweeper = null;
let closed = false;
function enabled() {
return typeof url === 'string' && url.trim() !== '';
}
function ready() {
return Boolean(client && client.isReady);
}
function noteError(where, err) {
if (closed) return;
stats.errors += 1;
if (warned) return;
warned = true;
console.error(`Redis ${where}: ${err && err.message ? err.message : err} — используется in-memory`);
}
function withTimeout(promise, ms, label) {
let timer = null;
const guard = new Promise((_, reject) => {
timer = setTimeout(() => reject(new Error(`${label} timeout ${ms}ms`)), ms);
if (timer.unref) timer.unref();
});
promise.catch(() => {});
return Promise.race([promise, guard]).finally(() => { if (timer) clearTimeout(timer); });
}
const fullKey = key => namespace + key;
async function via(fn, fallback) {
if (!ready()) {
stats.fallbackOps += 1;
return fallback();
}
try {
return await fn(client);
} catch (e) {
noteError('command', e);
stats.fallbackOps += 1;
return fallback();
}
}
async function scanDelete(client, target, isGlob) {
let cursor = '0';
do {
const res = await client.scan(cursor, { MATCH: (isGlob ? target : target + '*'), COUNT: SCAN_BATCH });
cursor = String(res.cursor);
if (res.keys.length) await client.del(res.keys);
} while (cursor !== '0');
}
function deliver(channel, message) {
const set = handlers.get(channel);
if (!set) return;
for (const fn of [...set]) {
try { fn(message); } catch (e) { console.error('Redis handler failed:', e.message); }
}
}
async function subscribeChannel(channel) {
if (!sub || !sub.isOpen || subscribed.has(channel)) return;
try {
await sub.subscribe(channel, message => deliver(channel, message));
subscribed.add(channel);
} catch (e) {
noteError('subscribe', e);
}
}
async function resubscribeAll() {
subscribed.clear();
for (const channel of handlers.keys()) await subscribeChannel(channel);
}
const api = {
isEnabled: enabled,
isReady: ready,
namespace,
stats: () => ({ ...stats, enabled: enabled(), ready: ready(), namespace }),
async info() {
const base = { ...stats, enabled: enabled(), ready: ready(), namespace };
const offline = {
...base,
driver: 'memory',
version: null,
server_uptime_s: null,
used_memory_bytes: null,
used_memory_human: null,
keys: null,
};
if (!ready()) return offline;
try {
const [raw, dbsize, server] = await Promise.all([
client.info('memory'),
client.dbSize(),
client.info('server'),
]);
const fields = parseInfoSections([raw, server]);
return {
...base,
driver: 'redis',
version: fields.get('redis_version') || null,
server_uptime_s: fields.has('uptime_in_seconds') ? Number(fields.get('uptime_in_seconds')) || null : null,
used_memory_bytes: fields.has('used_memory') ? Number(fields.get('used_memory')) || null : null,
used_memory_human: fields.get('used_memory_human') || null,
keys: Number(dbsize) || 0,
};
} catch (e) {
noteError('info', e);
return {
...base,
driver: 'redis',
version: null,
server_uptime_s: null,
used_memory_bytes: null,
used_memory_human: null,
keys: null,
};
}
},
async get(key) {
const raw = await via(
c => c.get(fullKey(key)),
() => memory.get(fullKey(key))
);
if (raw === null || raw === undefined) {
stats.misses += 1;
return undefined;
}
stats.hits += 1;
try {
return JSON.parse(raw);
} catch (e) {
return raw;
}
},
async set(key, value, ttlMs) {
const payload = JSON.stringify(value === undefined ? null : value);
stats.writes += 1;
return via(
c => (ttlMs ? c.set(fullKey(key), payload, { PX: ttlMs }) : c.set(fullKey(key), payload)),
() => memory.set(fullKey(key), value, ttlMs)
);
},
async del(key) {
return via(
c => c.del(fullKey(key)),
() => memory.del(fullKey(key))
);
},
async dropPrefix(keyPrefix) {
const target = fullKey(keyPrefix);
stats.drops += 1;
return via(
c => scanDelete(c, target),
() => memory.dropPrefix(target)
);
},
async dropMatch(pattern) {
const target = fullKey(pattern);
stats.drops += 1;
return via(
c => scanDelete(c, target, true),
() => memory.dropPrefix(target.split('*')[0])
);
},
async clear() {
stats.drops += 1;
return via(
c => scanDelete(c, namespace),
() => memory.clear()
);
},
async wrap(key, ttlMs, fn) {
const hit = await api.get(key);
if (hit !== undefined) return hit;
const value = await fn();
await api.set(key, value, ttlMs);
return value;
},
async incr(key, ttlMs) {
return via(
c => c.eval(INCR_EXPIRE_LUA, { keys: [fullKey(key)], arguments: [String(ttlMs || 0)] }),
() => memory.incr(fullKey(key), ttlMs)
);
},
rateLimitStore(id, defaultWindowMs) {
let windowMs = defaultWindowMs || 60 * 1000;
const base = fullKey(`rl:${id}:`);
const bucketFor = key => {
const bucket = Math.floor(Date.now() / windowMs);
return { key: `${base}${key}:${bucket}`, resetTime: (bucket + 1) * windowMs };
};
return {
async init(options) {
if (options && Number.isFinite(options.windowMs) && options.windowMs > 0) {
windowMs = options.windowMs;
}
},
async increment(key) {
const { key: redisKey, resetTime } = bucketFor(key);
const ttlMs = windowMs + Math.ceil(windowMs / 10);
const hits = await via(
c => c.eval(INCR_EXPIRE_LUA, { keys: [redisKey], arguments: [String(ttlMs)] }),
() => memory.incr(redisKey, ttlMs)
);
return { totalHits: Number(hits), resetTime: new Date(resetTime) };
},
async decrement(key) {
const { key: redisKey, resetTime } = bucketFor(key);
const ttlMs = Math.max(1, resetTime - Date.now());
await via(
c => c.eval(RATE_LIMIT_DEC_LUA, { keys: [redisKey], arguments: [String(ttlMs)] }),
() => memory.rateLimitDec(redisKey, ttlMs)
);
},
async resetKey(key) {
const targets = [];
for (let i = 0; i < 2; i += 1) {
targets.push(`${base}${key}:${Math.floor(Date.now() / windowMs) - i}`);
}
return via(
c => c.del(targets),
async () => { for (const k of targets) await memory.del(k); }
);
},
async resetAll() {
return via(
c => scanDelete(c, base),
() => memory.dropPrefix(base)
);
},
};
},
async publish(channel, payload) {
stats.publishes += 1;
const message = typeof payload === 'string' ? payload : JSON.stringify(payload);
if (!ready()) {
stats.fallbackOps += 1;
return memory.publish(channel, message);
}
try {
await client.publish(channel, message);
return 1;
} catch (e) {
noteError('publish', e);
stats.fallbackOps += 1;
return memory.publish(channel, message);
}
},
on(channel, fn) {
if (!handlers.has(channel)) handlers.set(channel, new Set());
handlers.get(channel).add(fn);
memory.on(channel, fn);
if (sub && sub.isOpen) subscribeChannel(channel);
return () => {
const set = handlers.get(channel);
if (set) set.delete(fn);
};
},
async connect() {
if (!enabled()) {
console.log('Redis: REDIS_URL не задан, используется in-memory кэш');
return false;
}
if (connecting) return connecting;
if (ready()) return true;
closed = false;
connecting = (async () => {
if (!sweeper) {
sweeper = setInterval(() => memory.sweep(), 60 * 1000);
sweeper.unref();
}
try {
client = createClient({
url,
socket: { reconnectStrategy: retries => Math.min(200 + retries * 200, 5000) },
});
client.on('error', e => noteError('connection', e));
client.on('ready', () => {
warned = false;
ensureSubscriber().catch(e => noteError('subscriber', e));
});
await withTimeout(client.connect(), CONNECT_TIMEOUT_MS, 'connect');
await ensureSubscriber();
const pong = await withTimeout(client.ping(), CONNECT_TIMEOUT_MS, 'ping');
console.log(`Redis connected (${pong}), namespace=${namespace}`);
return true;
} catch (e) {
noteError('connect', e);
return false;
} finally {
connecting = null;
}
})();
return connecting;
},
async close() {
closed = true;
if (sweeper) {
clearInterval(sweeper);
sweeper = null;
}
const pending = [sub, client].filter(Boolean);
sub = null;
client = null;
subscribed.clear();
await Promise.all(pending.map(async c => {
try { await withTimeout(c.quit(), 2000, 'quit'); } catch (e) {
try { c.destroy(); } catch (err) {}
}
}));
},
};
async function ensureSubscriber() {
if (closed || !ready()) return;
if (!sub) {
sub = client.duplicate();
sub.on('error', e => noteError('subscriber', e));
sub.on('ready', () => { resubscribeAll().catch(e => noteError('subscribe', e)); });
}
if (!sub.isOpen) await withTimeout(sub.connect(), CONNECT_TIMEOUT_MS, 'subscriber connect');
if (sub.isReady) await resubscribeAll();
}
return api;
}
module.exports = { createRedis, createMemoryBackend };
+148
View File
@@ -0,0 +1,148 @@
const assert = require('assert');
const { createRedis } = require('./redis');
function loadEnv() {
const fs = require('fs');
const path = require('path');
const file = path.join(__dirname, '.env');
if (!fs.existsSync(file)) return;
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];
}
}
async function main() {
loadEnv();
const url = process.env.REDIS_URL || `redis://:${process.env.REDIS_PASSWORD || ''}@127.0.0.1:6379`;
const prefix = 'test-' + Date.now();
const cache = createRedis({ url, prefix });
const connected = await cache.connect();
console.log('connect:', connected, 'ready:', cache.isReady());
assert.strictEqual(connected, true, 'must connect');
assert.strictEqual(cache.isReady(), true, 'must be ready');
await cache.set('setting:foo', 'bar', 60000);
assert.strictEqual(await cache.get('setting:foo'), 'bar', 'string roundtrip');
await cache.set('obj', { a: 1, b: [2, 3], c: null }, 60000);
assert.deepStrictEqual(await cache.get('obj'), { a: 1, b: [2, 3], c: null }, 'json roundtrip');
assert.strictEqual(await cache.get('missing'), undefined, 'miss returns undefined');
await cache.set('setting:ttl', 'x', 120);
await new Promise(r => setTimeout(r, 250));
assert.strictEqual(await cache.get('setting:ttl'), undefined, 'ttl expiry');
await cache.set('groups:1', 'g1', 60000);
await cache.set('groups:2', 'g2', 60000);
await cache.set('students:1', 's1', 60000);
await cache.dropPrefix('groups:');
assert.strictEqual(await cache.get('groups:1'), undefined, 'dropPrefix removes groups:1');
assert.strictEqual(await cache.get('groups:2'), undefined, 'dropPrefix removes groups:2');
assert.strictEqual(await cache.get('students:1'), 's1', 'dropPrefix keeps students:1');
await cache.set('fail:login:1.2.3.4', 3, 60000);
await cache.set('fail:login:5.6.7.8', 9, 60000);
await cache.dropMatch('fail:*:1.2.3.4');
assert.strictEqual(await cache.get('fail:login:1.2.3.4'), undefined, 'dropMatch suffix removes target');
assert.strictEqual(await cache.get('fail:login:5.6.7.8'), 9, 'dropMatch keeps other ip');
const c1 = await cache.incr('counter:a', 60000);
const c2 = await cache.incr('counter:a', 60000);
assert.strictEqual(c1, 1, 'incr first = 1');
assert.strictEqual(c2, 2, 'incr second = 2');
await cache.incr('counter:short', 120);
await new Promise(r => setTimeout(r, 250));
assert.strictEqual(await cache.incr('counter:short', 60000), 1, 'incr ttl set on first call');
const hits = [];
const received = new Promise(resolve => {
cache.on('test:chan', msg => { hits.push(msg); resolve(msg); });
});
await new Promise(r => setTimeout(r, 200));
await cache.publish('test:chan', { hello: 'world' });
const got = await Promise.race([received, new Promise(r => setTimeout(() => r('TIMEOUT'), 3000))]);
assert.notStrictEqual(got, 'TIMEOUT', 'pubsub must deliver');
assert.deepStrictEqual(JSON.parse(got), { hello: 'world' }, 'pubsub payload');
assert.strictEqual(hits.length, 1, 'pubsub delivered exactly once (no double delivery)');
let wraps = 0;
const v1 = await cache.wrap('wrap:key', 60000, async () => { wraps += 1; return 'computed'; });
const v2 = await cache.wrap('wrap:key', 60000, async () => { wraps += 1; return 'other'; });
assert.strictEqual(v1, 'computed', 'wrap returns computed');
assert.strictEqual(v2, 'computed', 'wrap returns cached');
assert.strictEqual(wraps, 1, 'wrap computed once');
const store = cache.rateLimitStore('test', 60000);
await store.init({ windowMs: 60000 });
const r1 = await store.increment('ip1');
const r2 = await store.increment('ip1');
const r3 = await store.increment('ip2');
assert.strictEqual(r1.totalHits, 1, 'rl first');
assert.strictEqual(r2.totalHits, 2, 'rl second');
assert.strictEqual(r3.totalHits, 1, 'rl separate key');
assert.ok(r2.resetTime > Date.now(), 'rl resetTime in future');
assert.ok(r2.resetTime instanceof Date, 'rl resetTime is a Date');
assert.strictEqual(r1.resetTime.getTime(), r2.resetTime.getTime(), 'rl same window');
const ttl = await cache.info();
await store.decrement('ip1');
assert.strictEqual((await store.increment('ip1')).totalHits, 2, 'decrement then increment');
await store.resetKey('ip1');
assert.strictEqual((await store.increment('ip1')).totalHits, 1, 'resetKey clears');
await store.resetAll();
assert.strictEqual((await store.increment('ip1')).totalHits, 1, 'resetAll clears');
await cache.del('obj');
assert.strictEqual(await cache.get('obj'), undefined, 'del');
const info = await cache.info();
console.log('info:', JSON.stringify(info));
assert.strictEqual(info.driver, 'redis', 'info driver is redis');
assert.ok(info.used_memory_bytes > 0, 'info has memory');
await cache.clear();
assert.strictEqual(await cache.get('students:1'), undefined, 'clear removes all');
const other = createRedis({ url, prefix: prefix + '-other' });
await other.connect();
await other.set('iso', 'yes', 60000);
assert.strictEqual(await cache.get('iso'), undefined, 'namespaces are isolated');
await other.close();
await cache.close();
assert.strictEqual(cache.isReady(), false, 'closed');
const offline = createRedis({ url: 'redis://127.0.0.1:1/', prefix: 'off-' + Date.now() });
const okConn = await offline.connect();
assert.strictEqual(okConn, false, 'bad url must not throw');
await offline.set('k', 'v', 1000);
assert.strictEqual(await offline.get('k'), 'v', 'fallback set/get works');
await offline.set('groups:a', 1, 1000);
await offline.set('students:a', 1, 1000);
await offline.dropPrefix('groups:');
assert.strictEqual(await offline.get('groups:a'), undefined, 'fallback dropPrefix');
assert.strictEqual(await offline.get('students:a'), 1, 'fallback dropPrefix isolation');
assert.strictEqual(await offline.incr('c', 1000), 1, 'fallback incr');
assert.strictEqual(await offline.incr('c', 1000), 2, 'fallback incr 2');
const offStore = offline.rateLimitStore('t', 60000);
assert.strictEqual((await offStore.increment('k1')).totalHits, 1, 'fallback rl');
assert.strictEqual((await offStore.increment('k1')).totalHits, 2, 'fallback rl 2');
let localHit = null;
const localRecv = new Promise(r => { offline.on('c', m => { localHit = m; r(m); }); });
await offline.publish('c', { x: 1 });
assert.deepStrictEqual(JSON.parse(await Promise.race([localRecv, new Promise(r => setTimeout(() => r('T'), 2000))])), { x: 1 }, 'fallback local pubsub');
assert.deepStrictEqual(JSON.parse(localHit), { x: 1 }, 'fallback local payload');
await offline.close();
const disabled = createRedis({});
assert.strictEqual(await disabled.connect(), false, 'disabled connect false');
await disabled.set('a', 1, 1000);
assert.strictEqual(await disabled.get('a'), 1, 'disabled memory works');
await disabled.close();
console.log('\nALL REDIS TESTS PASSED');
process.exit(0);
}
main().catch(e => { console.error('FAILED:', e.message); console.error(e.stack); process.exit(1); });
+5 -2
View File
@@ -10,10 +10,13 @@ mkdir -p backups
echo ">> Экспорт БД" echo ">> Экспорт БД"
docker compose exec -T db pg_dump -U app -d whereldo | gzip > backups/db.sql.gz docker compose exec -T db pg_dump -U app -d whereldo | gzip > backups/db.sql.gz
echo ">> Копирование фотографий" echo ">> Экспорт файлов из хранилища"
rm -rf backups/_uploads rm -rf backups/_uploads
mkdir -p backups/_uploads mkdir -p backups/_uploads
docker cp "whatido-app-1:/app/uploads/." "backups/_uploads/" docker compose exec -T app rm -rf /tmp/whatido-export
docker compose exec -T app node scripts/storage-sync.js export /tmp/whatido-export
docker cp "whatido-app-1:/tmp/whatido-export/." "backups/_uploads/"
docker compose exec -T app rm -rf /tmp/whatido-export
rm -rf backups/_uploads/.thumbs rm -rf backups/_uploads/.thumbs
echo ">> Сборка архива" echo ">> Сборка архива"
+53
View File
@@ -0,0 +1,53 @@
#!/usr/bin/env bash
# Обновление кода на сервере: pull ветки, сборка образа с версией коммита, перезапуск app.
# Использование: ./scripts/deploy.sh [ветка] (по умолчанию master либо $DEPLOY_BRANCH)
set -euo pipefail
cd "$(dirname "$0")/.."
BRANCH="${1:-${DEPLOY_BRANCH:-master}}"
if docker compose version >/dev/null 2>&1; then
DC="docker compose"
else
DC="docker-compose"
fi
echo ">> Рабочая копия"
if [ -n "$(git status --porcelain --untracked-files=no -- . ':(exclude)public/version.json')" ]; then
git status --short
echo "Есть незакоммиченные изменения. Задайте DEPLOY_ALLOW_DIRTY=1, чтобы продолжить."
[ "${DEPLOY_ALLOW_DIRTY:-0}" = "1" ] || exit 1
fi
echo ">> Обновление ветки $BRANCH"
git fetch origin --prune
git checkout "$BRANCH"
git pull --ff-only origin "$BRANCH"
GIT_COMMIT="$(git rev-parse HEAD)"
GIT_COMMIT_DATE="$(git log -1 --format=%cI)"
export GIT_COMMIT GIT_COMMIT_DATE
echo ">> Сборка: $GIT_COMMIT ($GIT_COMMIT_DATE)"
$DC build --build-arg "GIT_COMMIT=$GIT_COMMIT" --build-arg "GIT_COMMIT_DATE=$GIT_COMMIT_DATE" app
$DC up -d --force-recreate app
echo ">> Проверка"
$DC exec -T app cat /app/public/version.json
HOST_MD5="$(md5sum server.js | cut -d' ' -f1)"
APP_MD5="$($DC exec -T app md5sum /app/server.js | tr -d '\r' | cut -d' ' -f1)"
if [ "$HOST_MD5" = "$APP_MD5" ]; then
echo "server.js в контейнере совпадает с рабочей копией: $APP_MD5"
else
echo "ВНИМАНИЕ: server.js в контейнере ($APP_MD5) не совпадает с рабочей копией ($HOST_MD5)"
fi
if command -v curl >/dev/null 2>&1; then
echo ">> /version.json"
for _ in $(seq 1 20); do
curl -skf --max-time 3 https://127.0.0.1:3443/version.json >/dev/null 2>&1 && break
sleep 1
done
curl -sk --max-time 5 https://127.0.0.1:3443/version.json || echo "Не удалось получить /version.json (приложение ещё поднимается или образ собран без аргументов версии)"
echo
fi
$DC ps
+128
View File
@@ -0,0 +1,128 @@
const path = require('path');
const { createStorage } = require('../storage');
const SKIP_PREFIXES = ['.thumbs/', '.cache/'];
function parseArgs(argv) {
const opts = { dryRun: false, deleteLocal: false, verifyOnly: false, concurrency: 3, prefix: null, help: false };
for (const arg of argv.slice(2)) {
if (arg === '--dry-run') opts.dryRun = true;
else if (arg === '--delete-local') opts.deleteLocal = true;
else if (arg === '--keep-local') opts.deleteLocal = false;
else if (arg === '--verify-only') opts.verifyOnly = true;
else if (arg.startsWith('--concurrency=')) opts.concurrency = Math.max(1, parseInt(arg.split('=')[1], 10) || 1);
else if (arg.startsWith('--prefix=')) opts.prefix = arg.split('=')[1].replace(/^\/+|\/+$/g, '');
else if (arg === '--help' || arg === '-h') opts.help = true;
}
return opts;
}
function usage() {
console.log(`Миграция uploads/ -> S3 (MinIO).
Использование:
node scripts/migrate-to-s3.js [ключи]
Ключи:
--dry-run показать, что будет загружено, ничего не менять
--verify-only только проверить наличие объектов в S3
--delete-local удалить локальные файлы после успешной проверки
--keep-local оставить локальные файлы (по умолчанию)
--concurrency=N параллельных загрузок (по умолчанию 3)
--prefix=PREFIX префикс ключей внутри бакета
Переменные окружения: STORAGE_DRIVER=s3, S3_ENDPOINT, S3_BUCKET, S3_ACCESS_KEY, S3_SECRET_KEY.`);
}
async function runPool(items, limit, worker) {
const queue = items.slice();
const results = { ok: 0, skip: 0, fail: 0 };
const runners = Array.from({ length: Math.min(limit, queue.length) }, async () => {
while (queue.length) {
const item = queue.shift();
const res = await worker(item);
if (res === 'ok') results.ok++;
else if (res === 'skip') results.skip++;
else results.fail++;
}
});
await Promise.all(runners);
return results;
}
async function main() {
const opts = parseArgs(process.argv);
if (opts.help) return usage();
const dir = path.join(__dirname, '..', 'uploads');
const source = createStorage({ dir, driver: 'local' });
const target = createStorage({ driver: 's3', prefix: opts.prefix || undefined });
if (!target.isRemote()) {
console.error('STORAGE_DRIVER не s3: задайте STORAGE_DRIVER=s3 и переменные S3_*.');
process.exit(2);
}
if (!target.bucket) {
console.error('Не задан S3_BUCKET.');
process.exit(2);
}
const all = await source.listAll('');
const objects = all.filter(o => !SKIP_PREFIXES.some(p => o.key.startsWith(p)));
const totalBytes = objects.reduce((sum, o) => sum + (o.size || 0), 0);
if (!opts.dryRun) await target.ensureBucket();
console.log(`Локально: ${objects.length} файлов, ${(totalBytes / 1048576).toFixed(2)} МБ`);
console.log(`Бакет: ${target.bucket}${opts.prefix ? ' префикс ' + opts.prefix : ''} (${process.env.S3_ENDPOINT || 'endpoint по умолчанию'})`);
if (opts.dryRun) console.log('Режим --dry-run: изменения не применяются.');
const failures = [];
const result = await runPool(objects, opts.concurrency, async (obj) => {
const localPath = source.localFile(obj.key);
if (!localPath) {
failures.push(`${obj.key}: некорректный путь`);
return 'fail';
}
try {
const remote = await target.head(obj.key);
if (remote && remote.size === obj.size) {
if (opts.deleteLocal && !opts.dryRun) source.unlinkLocalOnly(obj.key);
return 'skip';
}
if (opts.verifyOnly) {
failures.push(`${obj.key}: нет объекта в S3`);
return 'fail';
}
if (opts.dryRun) {
console.log(` + ${obj.key} (${obj.size} Б)`);
return 'ok';
}
await target.putFile(obj.key, localPath);
const check = await target.head(obj.key);
if (!check || check.size !== obj.size) {
failures.push(`${obj.key}: размер не совпал после загрузки`);
return 'fail';
}
if (opts.deleteLocal) source.unlinkLocalOnly(obj.key);
return 'ok';
} catch (err) {
failures.push(`${obj.key}: ${err.message}`);
return 'fail';
}
});
const usage = await target.usage().catch(() => null);
console.log(`Загружено: ${result.ok}, уже было: ${result.skip}, ошибок: ${result.fail}`);
if (usage) console.log(`В бакете: ${usage.count} объектов, ${(usage.size_bytes / 1048576).toFixed(2)} МБ`);
if (failures.length) {
console.error('Ошибки:');
for (const f of failures.slice(0, 50)) console.error(' - ' + f);
if (failures.length > 50) console.error(` ... ещё ${failures.length - 50}`);
process.exit(1);
}
if (!opts.deleteLocal && !opts.dryRun) {
console.log('Локальные файлы оставлены. Удалить после проверки: node scripts/migrate-to-s3.js --delete-local');
}
}
main().catch(err => {
console.error('Миграция не выполнена:', err.message);
process.exit(1);
});
+11 -2
View File
@@ -28,8 +28,17 @@ echo ">> Восстановление БД (чистая схема)"
docker compose exec -T db psql -U app -d whereldo -c 'DROP SCHEMA public CASCADE; CREATE SCHEMA public;' >/dev/null docker compose exec -T db psql -U app -d whereldo -c 'DROP SCHEMA public CASCADE; CREATE SCHEMA public;' >/dev/null
gzip -dc "$TMP/db.sql.gz" | docker compose exec -T db psql -U app -d whereldo gzip -dc "$TMP/db.sql.gz" | docker compose exec -T db psql -U app -d whereldo
echo ">> Восстановление фотографий" echo ">> Загрузка файлов в хранилище"
docker cp "$TMP/_uploads/." "whatido-app-1:/app/uploads/" docker compose start app
for _ in $(seq 1 30); do
docker compose exec -T app true >/dev/null 2>&1 && break
sleep 1
done
docker compose exec -T app rm -rf /tmp/whatido-import
docker compose exec -T app mkdir -p /tmp/whatido-import
docker cp "$TMP/_uploads/." "whatido-app-1:/tmp/whatido-import/"
docker compose exec -T app node scripts/storage-sync.js import /tmp/whatido-import
docker compose exec -T app rm -rf /tmp/whatido-import
echo ">> Запуск приложения" echo ">> Запуск приложения"
docker compose start app docker compose start app
+39
View File
@@ -0,0 +1,39 @@
const path = require('path');
const { createStorage } = require('../storage');
function usage() {
console.log(`Экспорт/импорт файлов хранилища (для scripts/backup.sh и scripts/restore.sh).
Использование:
node scripts/storage-sync.js export <каталог> выгрузить все объекты в каталог
node scripts/storage-sync.js import <каталог> загрузить все файлы из каталога в хранилище
Работает с текущим драйвером из переменных окружения (local — ./uploads, s3 — бакет MinIO/S3).`);
}
async function main() {
const [, , mode, dirArg] = process.argv;
if (!mode || !dirArg || mode === '--help' || mode === '-h') {
usage();
return process.exit(mode ? 0 : 2);
}
const dir = path.resolve(dirArg);
const storage = createStorage({ dir: path.join(__dirname, '..', 'uploads') });
if (mode === 'export') {
const count = await storage.downloadAll(dir);
console.log(`Экспортировано объектов: ${count} -> ${dir}`);
return;
}
if (mode === 'import') {
const count = await storage.uploadTree(dir);
console.log(`Импортировано файлов: ${count} <- ${dir}`);
return;
}
usage();
process.exit(2);
}
main().catch(err => {
console.error('storage-sync failed:', err.message);
process.exit(1);
});
+1373 -294
View File
File diff suppressed because it is too large Load Diff
+543
View File
@@ -0,0 +1,543 @@
const fs = require('fs');
const path = require('path');
const { pipeline } = require('stream/promises');
const {
S3Client,
PutObjectCommand,
GetObjectCommand,
HeadObjectCommand,
DeleteObjectCommand,
CopyObjectCommand,
ListObjectsV2Command,
HeadBucketCommand,
CreateBucketCommand,
} = require('@aws-sdk/client-s3');
const MIME_TYPES = {
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.jfif': 'image/jpeg',
'.png': 'image/png',
'.gif': 'image/gif',
'.webp': 'image/webp',
'.bmp': 'image/bmp',
'.avif': 'image/avif',
'.ico': 'image/x-icon',
'.heic': 'image/heic',
'.heif': 'image/heif',
'.pdf': 'application/pdf',
'.txt': 'text/plain; charset=utf-8',
'.md': 'text/markdown; charset=utf-8',
'.html': 'text/html; charset=utf-8',
'.htm': 'text/html; charset=utf-8',
'.zip': 'application/zip',
'.rar': 'application/vnd.rar',
'.7z': 'application/x-7z-compressed',
'.doc': 'application/msword',
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
};
const SAFE_SEGMENT = /^[\w,.()-]+$/;
const NON_OBJECT_PREFIXES = ['.thumbs/', '.cache/'];
function mimeFor(name) {
const m = String(name || '').toLowerCase().match(/\.[a-z0-9]+$/);
return (m && MIME_TYPES[m[0]]) || 'application/octet-stream';
}
function normalizeKey(input) {
if (typeof input !== 'string' || !input) return null;
let key = input.replace(/\\/g, '/').replace(/^\/+/, '');
if (key.startsWith('uploads/')) key = key.slice('uploads/'.length);
if (!key || key.length > 255 || key.includes('..')) return null;
const segments = key.split('/');
if (segments.some(seg => !SAFE_SEGMENT.test(seg))) return null;
return key;
}
function isObjectKey(key) {
return !NON_OBJECT_PREFIXES.some(p => key.startsWith(p));
}
function createStorage(options = {}) {
const driver = options.driver || process.env.STORAGE_DRIVER || 'local';
const dir = path.resolve(options.dir || path.join(__dirname, 'uploads'));
const cacheDir = path.resolve(options.cacheDir || path.join(dir, '.cache'));
const localFallback = options.localFallback !== undefined
? !!options.localFallback
: process.env.STORAGE_LOCAL_FALLBACK !== '0';
const keepLocal = options.keepLocal !== undefined
? !!options.keepLocal
: process.env.STORAGE_KEEP_LOCAL === '1';
const bucket = options.bucket || process.env.S3_BUCKET || '';
const prefix = String(options.prefix !== undefined ? options.prefix : process.env.S3_PREFIX || '').replace(/^\/+|\/+$/g, '');
const cacheMaxAgeMs = Math.max(0, parseInt(options.cacheMaxAgeHours || process.env.STORAGE_CACHE_MAX_AGE_HOURS || '168', 10) || 0) * 3600 * 1000;
const remote = driver === 's3';
const client = remote
? new S3Client({
region: options.region || process.env.S3_REGION || 'us-east-1',
endpoint: options.endpoint || process.env.S3_ENDPOINT || undefined,
forcePathStyle: options.forcePathStyle !== undefined
? !!options.forcePathStyle
: process.env.S3_FORCE_PATH_STYLE !== '0',
credentials: {
accessKeyId: options.accessKey || process.env.S3_ACCESS_KEY || '',
secretAccessKey: options.secretKey || process.env.S3_SECRET_KEY || '',
},
})
: null;
function isRemote() {
return remote;
}
function objectKey(key) {
const k = normalizeKey(key);
if (!k) return null;
return prefix ? `${prefix}/${k}` : k;
}
function localFile(key) {
const k = normalizeKey(key);
if (!k) return null;
const abs = path.resolve(dir, ...k.split('/'));
if (abs !== dir && !abs.startsWith(dir + path.sep)) return null;
return abs;
}
function cacheFile(key) {
const k = normalizeKey(key);
if (!k) return null;
return path.join(cacheDir, ...k.split('/'));
}
function keyFromPath(p) {
return normalizeKey(p);
}
function localExists(key) {
const fp = localFile(key);
if (!fp) return false;
try {
return fs.statSync(fp).isFile();
} catch {
return false;
}
}
function unlinkLocalOnly(key) {
for (const fp of [localFile(key), cacheFile(key)]) {
if (!fp) continue;
try {
if (fs.existsSync(fp)) fs.unlinkSync(fp);
} catch {}
}
}
function isMissingError(err) {
if (!err) return false;
const code = err.name || err.Code || err.code;
const status = err.$metadata && err.$metadata.httpStatusCode;
return code === 'NoSuchKey' || code === 'NotFound' || code === 'ENOENT' || status === 404;
}
async function put(key, body, opts = {}) {
const objKey = objectKey(key);
if (!objKey) return false;
if (!remote) {
const fp = localFile(key);
if (!fp) return false;
fs.mkdirSync(path.dirname(fp), { recursive: true });
if (Buffer.isBuffer(body) || typeof body === 'string') fs.writeFileSync(fp, body);
else await pipeline(body, fs.createWriteStream(fp));
return true;
}
await client.send(new PutObjectCommand({
Bucket: bucket,
Key: objKey,
Body: body,
ContentType: opts.contentType || mimeFor(key),
ContentLength: opts.contentLength,
}));
return true;
}
async function putFile(key, localPath, opts = {}) {
const objKey = objectKey(key);
if (!objKey) return false;
let size = null;
try {
size = fs.statSync(localPath).size;
} catch {
return false;
}
if (!remote) {
const fp = localFile(key);
if (!fp) return false;
fs.mkdirSync(path.dirname(fp), { recursive: true });
if (path.resolve(localPath) !== fp) fs.copyFileSync(localPath, fp);
return true;
}
await client.send(new PutObjectCommand({
Bucket: bucket,
Key: objKey,
Body: fs.createReadStream(localPath),
ContentLength: size,
ContentType: opts.contentType || mimeFor(key),
}));
return true;
}
async function head(key) {
const objKey = objectKey(key);
if (!objKey) return null;
if (!remote) {
const fp = localFile(key);
if (!fp) return null;
try {
const st = fs.statSync(fp);
if (!st.isFile()) return null;
return { key: normalizeKey(key), size: st.size, contentType: mimeFor(key), etag: null };
} catch {
return null;
}
}
try {
const out = await client.send(new HeadObjectCommand({ Bucket: bucket, Key: objKey }));
return {
key: normalizeKey(key),
size: out.ContentLength || 0,
contentType: out.ContentType || mimeFor(key),
etag: out.ETag ? out.ETag.replace(/"/g, '') : null,
};
} catch (err) {
if (isMissingError(err)) return null;
throw err;
}
}
async function exists(key) {
if (localFallback && localExists(key)) return true;
return !!(await head(key));
}
async function sizeOf(key) {
const info = await head(key);
return info ? info.size : 0;
}
async function getStream(key) {
const objKey = objectKey(key);
if (!objKey) return null;
if (!remote) {
const fp = localFile(key);
if (!fp || !fs.existsSync(fp)) return null;
return { stream: fs.createReadStream(fp), contentLength: fs.statSync(fp).size, contentType: mimeFor(key) };
}
try {
const out = await client.send(new GetObjectCommand({ Bucket: bucket, Key: objKey }));
return { stream: out.Body, contentLength: out.ContentLength || 0, contentType: out.ContentType || mimeFor(key) };
} catch (err) {
if (isMissingError(err)) return null;
throw err;
}
}
async function getBuffer(key) {
const fp = await localize(key);
if (fp) return fs.readFileSync(fp);
return null;
}
async function del(key) {
const k = normalizeKey(key);
if (!k) return false;
unlinkLocalOnly(k);
if (!remote) return true;
const objKey = objectKey(k);
try {
await client.send(new DeleteObjectCommand({ Bucket: bucket, Key: objKey }));
} catch (err) {
if (!isMissingError(err)) throw err;
}
return true;
}
async function copyObject(srcKey, dstKey) {
const src = normalizeKey(srcKey);
const dst = normalizeKey(dstKey);
if (!src || !dst) return false;
if (!remote) {
const from = localFile(src);
const to = localFile(dst);
if (!from || !to || !fs.existsSync(from)) return false;
fs.mkdirSync(path.dirname(to), { recursive: true });
fs.copyFileSync(from, to);
return true;
}
try {
await client.send(new CopyObjectCommand({
Bucket: bucket,
Key: objectKey(dst),
CopySource: `${bucket}/${objectKey(src)}`,
ContentType: mimeFor(dst),
MetadataDirective: 'REPLACE',
}));
return true;
} catch (err) {
if (isMissingError(err)) return false;
throw err;
}
}
async function listAll(listPrefix = '') {
const out = [];
if (!remote) {
const base = listPrefix ? localFile(listPrefix) : dir;
if (!base || !fs.existsSync(base)) return out;
const walk = (abs, rel) => {
for (const name of fs.readdirSync(abs)) {
const childAbs = path.join(abs, name);
const childRel = rel ? `${rel}/${name}` : name;
let st;
try {
st = fs.statSync(childAbs);
} catch {
continue;
}
if (st.isDirectory()) walk(childAbs, childRel);
else out.push({ key: childRel, size: st.size });
}
};
walk(base, listPrefix ? normalizeKey(listPrefix) || '' : '');
return out;
}
let token = null;
const fullPrefix = prefix ? `${prefix}/${listPrefix}` : listPrefix;
do {
const res = await client.send(new ListObjectsV2Command({
Bucket: bucket,
Prefix: fullPrefix || undefined,
ContinuationToken: token || undefined,
}));
for (const item of res.Contents || []) {
const key = prefix ? String(item.Key).slice(prefix.length + 1) : String(item.Key);
out.push({ key, size: item.Size || 0 });
}
token = res.IsTruncated ? res.NextContinuationToken : null;
} while (token);
return out;
}
async function localize(key, opts = {}) {
const k = normalizeKey(key);
if (!k) return null;
if (localExists(k)) return localFile(k);
if (!remote) return null;
const cf = cacheFile(k);
if (cf && fs.existsSync(cf)) {
const info = await head(k);
if (info && info.size === fs.statSync(cf).size) return cf;
try { fs.unlinkSync(cf); } catch {}
}
const source = await getStream(k);
if (!source) return null;
fs.mkdirSync(path.dirname(cf), { recursive: true });
const tmp = `${cf}.${process.pid}.${Date.now()}.tmp`;
await pipeline(source.stream, fs.createWriteStream(tmp));
if (opts.useCache === false) return tmp;
fs.renameSync(tmp, cf);
return cf;
}
async function persist(key, localPath) {
const k = normalizeKey(key);
if (!k) return false;
const src = path.resolve(localPath);
if (!fs.existsSync(src)) return false;
if (!remote) return true;
const info = await head(k);
if (info && info.size === fs.statSync(src).size) {
if (!keepLocal) unlinkLocalOnly(k);
return true;
}
await putFile(k, src);
if (!keepLocal) {
try { fs.unlinkSync(src); } catch {}
const cf = cacheFile(k);
if (cf && fs.existsSync(cf)) {
try { fs.unlinkSync(cf); } catch {}
}
}
return true;
}
async function streamTo(res, key, opts = {}) {
const k = normalizeKey(key);
if (!k) return false;
if (localFallback && localExists(k)) {
const fp = localFile(k);
if (opts.cacheControl) res.setHeader('Cache-Control', opts.cacheControl);
if (opts.download) {
res.download(fp, opts.name || path.basename(fp), opts.callback || (() => {}));
return true;
}
res.setHeader('Content-Type', opts.contentType || mimeFor(k));
res.sendFile(fp);
return true;
}
const source = await getStream(k);
if (!source) return false;
if (opts.cacheControl) res.setHeader('Cache-Control', opts.cacheControl);
res.setHeader('Content-Type', opts.contentType || source.contentType || mimeFor(k));
if (source.contentLength) res.setHeader('Content-Length', String(source.contentLength));
if (opts.download) {
const name = opts.name || path.basename(k);
const ascii = name.replace(/[^\x20-\x7E]/g, '_').replace(/"/g, '');
res.setHeader('Content-Disposition', `attachment; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(name)}`);
}
await pipeline(source.stream, res);
return true;
}
async function downloadAll(destDir, opts = {}) {
fs.mkdirSync(destDir, { recursive: true });
const root = path.resolve(destDir);
const objects = await listAll('');
let count = 0;
for (const obj of objects) {
if (!isObjectKey(obj.key)) continue;
const k = normalizeKey(obj.key);
if (!k) continue;
const target = path.resolve(root, ...k.split('/'));
if (!target.startsWith(root + path.sep)) continue;
const source = await getStream(k);
if (!source) continue;
fs.mkdirSync(path.dirname(target), { recursive: true });
await pipeline(source.stream, fs.createWriteStream(target));
count++;
}
if (opts.pruneThumbs !== false) {
const thumbs = path.join(root, '.thumbs');
if (fs.existsSync(thumbs)) fs.rmSync(thumbs, { recursive: true, force: true });
}
return count;
}
async function uploadTree(srcDir) {
const root = path.resolve(srcDir);
if (!fs.existsSync(root)) return 0;
let count = 0;
const walk = async (abs, rel) => {
for (const name of fs.readdirSync(abs)) {
const childAbs = path.join(abs, name);
const childRel = rel ? `${rel}/${name}` : name;
let st;
try {
st = fs.statSync(childAbs);
} catch {
continue;
}
if (st.isDirectory()) {
if (name === '.thumbs' || name === '.cache') continue;
await walk(childAbs, childRel);
continue;
}
const k = normalizeKey(childRel);
if (!k) continue;
await putFile(k, childAbs);
count++;
}
};
await walk(root, '');
return count;
}
async function ensureBucket() {
if (!remote || !bucket) return false;
try {
await client.send(new HeadBucketCommand({ Bucket: bucket }));
return true;
} catch (err) {
const status = err && err.$metadata && err.$metadata.httpStatusCode;
const name = err && (err.name || err.Code);
if (status && status !== 404 && name !== 'NotFound' && name !== 'NoSuchBucket') throw err;
}
try {
await client.send(new CreateBucketCommand({ Bucket: bucket }));
return true;
} catch (err) {
const name = err && (err.name || err.Code);
if (name === 'BucketAlreadyOwnedByYou' || name === 'BucketAlreadyExists') return true;
throw err;
}
}
async function usage() {
const objects = (await listAll('')).filter(o => isObjectKey(o.key));
return {
count: objects.length,
size_bytes: objects.reduce((sum, o) => sum + (o.size || 0), 0),
bucket: remote ? bucket : null,
prefix: prefix || null,
};
}
function pruneCache(now = Date.now()) {
if (!cacheMaxAgeMs || !fs.existsSync(cacheDir)) return 0;
let removed = 0;
const walk = (abs) => {
for (const name of fs.readdirSync(abs)) {
const childAbs = path.join(abs, name);
let st;
try {
st = fs.statSync(childAbs);
} catch {
continue;
}
if (st.isDirectory()) {
walk(childAbs);
continue;
}
if (now - st.mtimeMs > cacheMaxAgeMs) {
try { fs.unlinkSync(childAbs); removed++; } catch {}
}
}
};
walk(cacheDir);
return removed;
}
return {
bucket,
cacheDir,
isRemote,
keyFromPath,
localFile,
unlinkLocalOnly,
normalizeKey,
put,
putFile,
head,
exists,
sizeOf,
getStream,
getBuffer,
del,
copyObject,
listAll,
localize,
persist,
streamTo,
downloadAll,
uploadTree,
ensureBucket,
usage,
pruneCache,
};
}
module.exports = { createStorage, mimeFor, normalizeKey };
+167 -48
View File
@@ -3,17 +3,55 @@ const IDLE_MAX_MS = 60000;
const REQUEST_TIMEOUT_MS = parseInt(process.env.AI_REQUEST_TIMEOUT_MS || '120000', 10); const REQUEST_TIMEOUT_MS = parseInt(process.env.AI_REQUEST_TIMEOUT_MS || '120000', 10);
const MAX_INPUT_CHARS = 2000; const MAX_INPUT_CHARS = 2000;
const MIN_TEXT_CHARS = 4; const MIN_TEXT_CHARS = 4;
const path = require('path');
const fs = require('fs');
const crypto = require('crypto'); const crypto = require('crypto');
const { textDiff, FIELD_LABELS } = require('./diff');
const PHOTO_MAX_ATTEMPTS = 3; const PHOTO_MAX_ATTEMPTS = 3;
const PHOTO_AI_TIMEOUT_MS = 300000; const PHOTO_AI_TIMEOUT_MS = 300000;
const PHOTO_SOFT_MAX_RETRIES = Math.max(1, parseInt(process.env.PHOTO_AI_SOFT_MAX_RETRIES || '60', 10) || 60);
const PHOTO_SOFT_BACKOFF_MS = Math.max(1000, parseInt(process.env.PHOTO_AI_SOFT_BACKOFF_MS || '10000', 10) || 10000);
const PHOTO_SOFT_BACKOFF_MAX_MS = Math.max(PHOTO_SOFT_BACKOFF_MS, parseInt(process.env.PHOTO_AI_SOFT_BACKOFF_MAX_MS || '300000', 10) || 300000);
function createPhotoEnhanceWorker({ pool, getSetting, logAudit, invalidateEntries, sharp, photoAiUrl, uploadsDir, originalsDir }) { function isTimeoutFailure(e) {
const name = e && e.name;
return name === 'TimeoutError' || name === 'AbortError';
}
function isSoftStatus(status) {
return status === 408 || status === 425 || status === 429 || status >= 500;
}
function failureReason(e) {
const parts = [];
let cur = e;
for (let i = 0; cur && i < 5; i++) {
const code = cur.code || (cur.errors && cur.errors.code);
if (code && !parts.includes(code)) parts.push(code);
cur = cur.cause;
}
return parts.join(', ');
}
function softFailure(message, e) {
const reason = e ? failureReason(e) || (e.message || '') : '';
const err = new Error(reason ? `${message} (${reason})` : message);
err.soft = true;
return err;
}
function createPhotoEnhanceWorker({ pool, getSetting, logAudit, invalidateEntries, sharp, photoAiUrl, storage, bus, notifyEvent }) {
const AI_URL = photoAiUrl || process.env.PHOTO_AI_URL || ''; const AI_URL = photoAiUrl || process.env.PHOTO_AI_URL || '';
const IDLE_MIN = 2000; const IDLE_MIN = 2000;
const IDLE_MAX = 30000; const IDLE_MAX = 30000;
async function sendNotification(entryId, payload) {
if (!notifyEvent) return;
try {
await notifyEvent(entryId, payload);
} catch (e) {
console.error('Photo worker notification failed:', e.message);
}
}
let started = false; let started = false;
let stopped = false; let stopped = false;
let idleMs = IDLE_MIN; let idleMs = IDLE_MIN;
@@ -21,12 +59,16 @@ function createPhotoEnhanceWorker({ pool, getSetting, logAudit, invalidateEntrie
let processing = false; let processing = false;
let currentId = null; let currentId = null;
let startedAt = null; let startedAt = null;
const stats = { jobs: 0, done: 0, errors: 0, last_at: null, last_error: null }; const stats = { jobs: 0, done: 0, errors: 0, soft_retries: 0, last_at: null, last_error: null };
const softTries = new Map();
const CONFIG = { const CONFIG = {
idle_min_ms: IDLE_MIN, idle_min_ms: IDLE_MIN,
idle_max_ms: IDLE_MAX, idle_max_ms: IDLE_MAX,
ai_timeout_ms: PHOTO_AI_TIMEOUT_MS, ai_timeout_ms: PHOTO_AI_TIMEOUT_MS,
max_attempts: PHOTO_MAX_ATTEMPTS, max_attempts: PHOTO_MAX_ATTEMPTS,
soft_max_retries: PHOTO_SOFT_MAX_RETRIES,
soft_backoff_ms: PHOTO_SOFT_BACKOFF_MS,
soft_backoff_max_ms: PHOTO_SOFT_BACKOFF_MAX_MS,
ai_url: AI_URL, ai_url: AI_URL,
}; };
@@ -37,10 +79,17 @@ function createPhotoEnhanceWorker({ pool, getSetting, logAudit, invalidateEntrie
}); });
} }
function notify() { function wakeLocal() {
if (wake) wake(); if (wake) wake();
} }
function notify() {
wakeLocal();
if (bus) bus.publish();
}
if (bus) bus.subscribe(wakeLocal);
async function isEnabled() { async function isEnabled() {
const v = await getSetting('photo_worker_enabled', 'true'); const v = await getSetting('photo_worker_enabled', 'true');
return String(v) !== 'false'; return String(v) !== 'false';
@@ -71,30 +120,7 @@ function createPhotoEnhanceWorker({ pool, getSetting, logAudit, invalidateEntrie
} }
} }
function safeUnlinkPath(p) { async function enhanceWithSharp(srcKey, params) {
try {
if (!p) return;
const abs = path.resolve(p);
const root = path.resolve(uploadsDir);
if (abs !== root && abs.startsWith(root + path.sep)) fs.unlinkSync(abs);
} catch (e) { console.error('photo worker unlink:', e.message); }
}
function backupOldFile(oldRelPath) {
if (!oldRelPath) return null;
const oldAbs = path.join(uploadsDir, String(oldRelPath).replace(/^\/+/, '').replace(/^uploads\//, ''));
if (!fs.existsSync(oldAbs)) return null;
const backupName = crypto.randomBytes(12).toString('hex') + (path.extname(oldAbs) || '.jpg');
const backupPath = path.join(originalsDir, backupName);
try {
fs.renameSync(oldAbs, backupPath);
return `/uploads/.originals/${backupName}`;
} catch (e) {
console.error('photo worker backup failed:', e.message);
return null;
}
}
async function enhanceWithSharp(srcAbs, params) {
if (!sharp) throw new Error('sharp недоступен на сервере'); if (!sharp) throw new Error('sharp недоступен на сервере');
const p = params || {}; const p = params || {};
const clamp = (v, min, max, def) => { const clamp = (v, min, max, def) => {
@@ -106,7 +132,9 @@ function createPhotoEnhanceWorker({ pool, getSetting, logAudit, invalidateEntrie
const saturate = clamp(p.saturate, 0, 300, 100); const saturate = clamp(p.saturate, 0, 300, 100);
const sharpAmt = clamp(p.sharp, 0, 100, 0); const sharpAmt = clamp(p.sharp, 0, 100, 0);
const denoise = clamp(p.denoise, 0, 100, 0); const denoise = clamp(p.denoise, 0, 100, 0);
let pipeline = sharp(srcAbs).rotate(); const srcPath = await storage.localize(srcKey);
if (!srcPath) throw new Error('Файл фото не найден');
let pipeline = sharp(srcPath).rotate();
if (denoise > 0) pipeline = pipeline.median(denoise > 70 ? 5 : 3); if (denoise > 0) pipeline = pipeline.median(denoise > 70 ? 5 : 3);
pipeline = pipeline.modulate({ brightness: brightness / 100, saturation: saturate / 100 }); pipeline = pipeline.modulate({ brightness: brightness / 100, saturation: saturate / 100 });
if (contrast !== 100) { if (contrast !== 100) {
@@ -117,25 +145,40 @@ function createPhotoEnhanceWorker({ pool, getSetting, logAudit, invalidateEntrie
pipeline = pipeline.sharpen({ sigma: 0.5 + (sharpAmt / 100) * 1.5, m1: 0, m2: 1 + sharpAmt / 50 }); pipeline = pipeline.sharpen({ sigma: 0.5 + (sharpAmt / 100) * 1.5, m1: 0, m2: 1 + sharpAmt / 50 });
} }
const newName = crypto.randomBytes(12).toString('hex') + '.jpg'; const newName = crypto.randomBytes(12).toString('hex') + '.jpg';
await pipeline.jpeg({ quality: 92, mozjpeg: true }).toFile(path.join(uploadsDir, newName)); const out = await pipeline.jpeg({ quality: 92, mozjpeg: true }).toBuffer();
await storage.put(newName, out);
return `/uploads/${newName}`; return `/uploads/${newName}`;
} }
async function runAiEnhance(srcAbs) { async function runAiEnhance(srcKey) {
if (!AI_URL) throw new Error('PHOTO_AI_URL не настроен'); if (!AI_URL) throw new Error('PHOTO_AI_URL не настроен');
const buf = fs.readFileSync(srcAbs); const buf = await storage.getBuffer(srcKey);
if (!buf) throw new Error('Файл фото не найден');
const fd = new FormData(); const fd = new FormData();
fd.append('image', new Blob([buf], { type: 'image/jpeg' }), 'photo.jpg'); fd.append('image', new Blob([buf], { type: 'image/jpeg' }), 'photo.jpg');
fd.append('scale', '2'); fd.append('scale', '2');
const resp = await fetch(AI_URL.replace(/\/+$/, '') + '/enhance', { let resp;
try {
resp = await fetch(AI_URL.replace(/\/+$/, '') + '/enhance', {
method: 'POST', method: 'POST',
body: fd, body: fd,
signal: AbortSignal.timeout(PHOTO_AI_TIMEOUT_MS), signal: AbortSignal.timeout(PHOTO_AI_TIMEOUT_MS),
}); });
if (!resp.ok) throw new Error('AI service error: ' + resp.status); } catch (e) {
if (isTimeoutFailure(e)) {
throw new Error(`ИИ-сервис не ответил за ${Math.round(PHOTO_AI_TIMEOUT_MS / 1000)} с`);
}
throw softFailure('ИИ-сервис недоступен', e);
}
if (!resp.ok) {
const retryAfter = resp.headers.get('retry-after');
const detail = `ИИ-сервис ответил ${resp.status}${retryAfter ? ` (Retry-After: ${retryAfter})` : ''}`;
if (isSoftStatus(resp.status)) throw softFailure(detail);
throw new Error(detail);
}
const out = Buffer.from(await resp.arrayBuffer()); const out = Buffer.from(await resp.arrayBuffer());
const newName = crypto.randomBytes(12).toString('hex') + '.jpg'; const newName = crypto.randomBytes(12).toString('hex') + '.jpg';
fs.writeFileSync(path.join(uploadsDir, newName), out); await storage.put(newName, out);
return `/uploads/${newName}`; return `/uploads/${newName}`;
} }
async function applyResult(job, newPath) { async function applyResult(job, newPath) {
@@ -144,7 +187,51 @@ function createPhotoEnhanceWorker({ pool, getSetting, logAudit, invalidateEntrie
[newPath, job.id] [newPath, job.id]
); );
if (logAudit) await logAudit(null, 'photo.job.preview', { entry_id: job.entry_id, job_id: job.id, action: job.action, after_path: newPath }); if (logAudit) await logAudit(null, 'photo.job.preview', { entry_id: job.entry_id, job_id: job.id, action: job.action, after_path: newPath });
await sendNotification(job.entry_id, {
type: 'photo.job.done',
title: job.action === 'ai' ? 'Фото обработано нейросетью: {student}' : 'Фото улучшено на сервере: {student}',
body: `Группа {group} · запись #${job.entry_id} · результат ждёт применения`,
target: { job_id: job.id, action: job.action, after_path: newPath },
});
} }
async function softRetry(job, message) {
const n = (softTries.get(job.id) || 0) + 1;
if (n <= PHOTO_SOFT_MAX_RETRIES) {
softTries.set(job.id, n);
stats.soft_retries++;
await pool.query(`UPDATE photo_jobs SET status = 'pending', error = $1 WHERE id = $2`, [message, job.id]);
if (logAudit && (n === 1 || n % 10 === 0)) {
await logAudit(null, 'photo.job.soft_retry', {
entry_id: job.entry_id,
job_id: job.id,
soft_attempt: n,
soft_limit: PHOTO_SOFT_MAX_RETRIES,
error: message,
});
}
const delay = Math.min(PHOTO_SOFT_BACKOFF_MS * Math.pow(2, n - 1), PHOTO_SOFT_BACKOFF_MAX_MS);
return { ok: false, delay };
}
softTries.delete(job.id);
const text = `${message} — ИИ-сервис недоступен, мягкие повторы исчерпаны (${PHOTO_SOFT_MAX_RETRIES}), задание остановлено`;
await pool.query(
`UPDATE photo_jobs SET status = 'error', error = $1, finished_at = now() WHERE id = $2`,
[text, job.id]
);
stats.errors++;
stats.last_error = text;
if (logAudit) {
await logAudit(null, 'photo.job.error', { entry_id: job.entry_id, job_id: job.id, error: text, soft_attempts: PHOTO_SOFT_MAX_RETRIES });
}
await sendNotification(job.entry_id, {
type: 'photo.job.error',
title: 'Ошибка обработки фото: {student}',
body: `Группа {group} · запись #${job.entry_id} · ${text}`,
target: { job_id: job.id, action: job.action, error: text },
});
return { ok: true, delay: 0 };
}
async function processOne(job) { async function processOne(job) {
stats.jobs++; stats.jobs++;
const { rows } = await pool.query('SELECT photo_path FROM entries WHERE id = $1', [job.entry_id]); const { rows } = await pool.query('SELECT photo_path FROM entries WHERE id = $1', [job.entry_id]);
@@ -156,35 +243,49 @@ function createPhotoEnhanceWorker({ pool, getSetting, logAudit, invalidateEntrie
stats.errors++; stats.errors++;
stats.last_at = new Date().toISOString(); stats.last_at = new Date().toISOString();
stats.last_error = 'У записи нет фото'; stats.last_error = 'У записи нет фото';
return true; await sendNotification(job.entry_id, {
type: 'photo.job.error',
title: 'Ошибка обработки фото: {student}',
body: `Группа {group} · запись #${job.entry_id} · у записи нет фото для обработки`,
target: { job_id: job.id, action: job.action },
});
return { ok: true, delay: 0 };
} }
const photoPath = rows[0].photo_path; const photoPath = rows[0].photo_path;
const srcAbs = path.join(uploadsDir, String(photoPath).replace(/^\/+/, '').replace(/^uploads\//, ''));
try { try {
const newPath = job.action === 'ai' ? await runAiEnhance(srcAbs) : await enhanceWithSharp(srcAbs, job.params); const newPath = job.action === 'ai' ? await runAiEnhance(photoPath) : await enhanceWithSharp(photoPath, job.params);
await applyResult(job, newPath); await applyResult(job, newPath);
softTries.delete(job.id);
stats.done++; stats.done++;
stats.last_at = new Date().toISOString(); stats.last_at = new Date().toISOString();
stats.last_error = null; stats.last_error = null;
return true; return { ok: true, delay: 0 };
} catch (e) { } catch (e) {
const message = (e && e.message ? e.message : 'error').slice(0, 500); const message = (e && e.message ? e.message : 'error').slice(0, 500);
stats.last_at = new Date().toISOString(); stats.last_at = new Date().toISOString();
stats.last_error = message; stats.last_error = message;
if (e && e.soft) return await softRetry(job, message);
const { rows: cur } = await pool.query('SELECT attempts FROM photo_jobs WHERE id = $1', [job.id]); const { rows: cur } = await pool.query('SELECT attempts FROM photo_jobs WHERE id = $1', [job.id]);
const tries = ((cur[0] && cur[0].attempts) || 0) + 1; const tries = ((cur[0] && cur[0].attempts) || 0) + 1;
if (tries < PHOTO_MAX_ATTEMPTS) { if (tries < PHOTO_MAX_ATTEMPTS) {
await pool.query(`UPDATE photo_jobs SET status = 'pending', attempts = $1, error = $2 WHERE id = $3`, [tries, message, job.id]); await pool.query(`UPDATE photo_jobs SET status = 'pending', attempts = $1, error = $2 WHERE id = $3`, [tries, message, job.id]);
if (logAudit) await logAudit(null, 'photo.job.retry', { entry_id: job.entry_id, job_id: job.id, attempt: tries, error: message }); if (logAudit) await logAudit(null, 'photo.job.retry', { entry_id: job.entry_id, job_id: job.id, attempt: tries, error: message });
return false; return { ok: false, delay: 0 };
} }
softTries.delete(job.id);
await pool.query( await pool.query(
`UPDATE photo_jobs SET status = 'error', attempts = $1, error = $2, finished_at = now() WHERE id = $3`, `UPDATE photo_jobs SET status = 'error', attempts = $1, error = $2, finished_at = now() WHERE id = $3`,
[tries, message, job.id] [tries, message, job.id]
); );
stats.errors++; stats.errors++;
if (logAudit) await logAudit(null, 'photo.job.error', { entry_id: job.entry_id, job_id: job.id, error: message }); if (logAudit) await logAudit(null, 'photo.job.error', { entry_id: job.entry_id, job_id: job.id, error: message });
return true; await sendNotification(job.entry_id, {
type: 'photo.job.error',
title: 'Ошибка обработки фото: {student}',
body: `Группа {group} · запись #${job.entry_id} · ${message}`,
target: { job_id: job.id, action: job.action, error: message },
});
return { ok: true, delay: 0 };
} }
} }
@@ -210,16 +311,16 @@ function createPhotoEnhanceWorker({ pool, getSetting, logAudit, invalidateEntrie
idleMs = IDLE_MIN; idleMs = IDLE_MIN;
processing = true; processing = true;
currentId = job.id; currentId = job.id;
let ok = true; let res = { ok: true, delay: 0 };
try { try {
ok = await processOne(job); res = await processOne(job);
} catch (e) { } catch (e) {
console.error('Photo worker process error:', e); console.error('Photo worker process error:', e);
} finally { } finally {
processing = false; processing = false;
currentId = null; currentId = null;
} }
if (!ok) await sleep(IDLE_MAX); if (!res || !res.ok) await sleep((res && res.delay) || IDLE_MAX);
} }
} }
@@ -250,7 +351,7 @@ function createPhotoEnhanceWorker({ pool, getSetting, logAudit, invalidateEntrie
return { start, notify, getInfo }; return { start, notify, getInfo };
} }
function createEntryAutoChecker({ pool, getSetting, logAudit, aiUrl, defaultPrompt, model }) { function createEntryAutoChecker({ pool, getSetting, logAudit, aiUrl, defaultPrompt, model, bus }) {
const AI_URL = aiUrl || process.env.AI_URL || 'http://text-corrector:8080'; const AI_URL = aiUrl || process.env.AI_URL || 'http://text-corrector:8080';
const MODEL = model || process.env.AI_MODEL || 'qwen2.5-1.5b-instruct-q4_k_m.gguf'; const MODEL = model || process.env.AI_MODEL || 'qwen2.5-1.5b-instruct-q4_k_m.gguf';
const DEFAULT_PROMPT = defaultPrompt || process.env.AI_PROMPT || 'Ты — редактор текстов. Исправь ТОЛЬКО грамматические, орфографические и пунктуационные ошибки в тексте. Приведи к правильному регистру буквы. НЕ меняй слова, структуру предложений, стиль или смысл текста. Верни ТОЛЬКО исправленный текст без пояснений.'; const DEFAULT_PROMPT = defaultPrompt || process.env.AI_PROMPT || 'Ты — редактор текстов. Исправь ТОЛЬКО грамматические, орфографические и пунктуационные ошибки в тексте. Приведи к правильному регистру буквы. НЕ меняй слова, структуру предложений, стиль или смысл текста. Верни ТОЛЬКО исправленный текст без пояснений.';
@@ -283,10 +384,17 @@ function createEntryAutoChecker({ pool, getSetting, logAudit, aiUrl, defaultProm
}); });
} }
function notify() { function wakeLocal() {
if (wake) wake(); if (wake) wake();
} }
function notify() {
wakeLocal();
if (bus) bus.publish();
}
if (bus) bus.subscribe(wakeLocal);
async function isEnabled() { async function isEnabled() {
const v = await getSetting('ai_autocheck_enabled', 'true'); const v = await getSetting('ai_autocheck_enabled', 'true');
return String(v) !== 'false'; return String(v) !== 'false';
@@ -414,7 +522,18 @@ function createEntryAutoChecker({ pool, getSetting, logAudit, aiUrl, defaultProm
if (changed) stats.corrected++; else stats.unchanged++; if (changed) stats.corrected++; else stats.unchanged++;
stats.last_at = new Date().toISOString(); stats.last_at = new Date().toISOString();
stats.last_error = null; stats.last_error = null;
if (logAudit) await logAudit(null, 'entry.ai.auto-check', { id: row.id, changed }); if (logAudit) {
const d = textDiff(String(row.description ?? ''), changed ? checked : original);
await logAudit(null, 'entry.ai.auto-check', {
id: row.id,
source: 'ai',
changed,
fields: changed ? ['description'] : [],
changes: changed
? [{ field: 'description', label: FIELD_LABELS.description, stats: d.stats, diff: d.segments, truncated: d.truncated }]
: []
});
}
return true; return true;
} catch (e) { } catch (e) {
const message = (e && e.message ? e.message : 'error').slice(0, 500); const message = (e && e.message ? e.message : 'error').slice(0, 500);