Часовой пояс и формат времени (24h/12h) перенесены из браузера в настройки
приложения: валидация IANA-зоны, сид в db/init.sql + db/migration.sql, отдача
в GET /api/public-settings, раздел #sec-datetime в настройках с живым превью.
Границы суток в SQL переведены на tzDayStart/tzDayEnd + bindTz ($TZ$) — раньше
$n::date по TIMESTAMPTZ считал дни в UTC (у контейнера TimeZone=UTC) и молча
сдвигал выборку на день; хардкод Europe/Moscow вычищен, now() заменён на
tzWall().
Новый public/js/datetime.js: три семейства хелперов (instant / чистая
DATE-строка без Date() / чистая TIME-строка), подключён на всех страницах
включая публичные share/report/index, initDateTime() встроен в checkAuth().
Прямые toLocale*/getFullYear/toISOString().slice(0,10) в public/ выпилены.
Попутно: bindTz добавляет параметр только при наличии $TZ$ в SQL (иначе запрос
виснет вечно без global error handler) — регрессия закрыта в api.smoketest.js;
починены off-by-one месяца в report.js и группировка по дате в share.js.
HEAD-проба в downloadBackup() сжигала одноразовый тикет: Express 4
прогоняет HEAD через GET-хендлер /api/backup/:token, который удалял
тикет и файл до отдачи архива, поэтому настоящий GET всегда получал
404 «Ссылка на бэкап устарела».
- server.js: GET /api/backup/:token больше не удаляет тикет и файл —
ссылка живёт BACKUP_TTL_MS (30 мин), Range-докачка работает
- вынес dropBackupTicket(), fs.rmSync обёрнут в try/catch
- BACKUP_TICKETS_MAX = 3: лишние тикеты вычищаются по возрасту
- sweepBackupStorage() режет с запасом 5 минут сверх TTL
- settings.js: убрана HEAD-проба, в #backupStatus рендерится реальная
кликабельная ссылка вместо невидимого синтетического <a>
- AGENTS.md: раздел 8 — новое поведение + предупреждение про HEAD
Валидация и нормализация restore вынесены из server.js в backup-restore.js,
покрыты юнит-тестами backup.selftest.js (30+ проверок, без стенда).
В бэкап добавлены audit_log, notifications, notification_reads, banned_ips,
счётчики counts, метаданные приложения и версия формата (принимаются 1..2).
Тикет бэкапа стал одноразовым: файл удаляется сразу после отдачи,
uploadBackup фильтрует расширения. sessions в бэкап не входит намеренно.
sweepOrphanedUploads учитывает аватары, обложки, оригиналы фото и photo_jobs —
иначе restore сносил файлы сразу после восстановления.
storage.getStream при STORAGE_LOCAL_FALLBACK читает локальный файл, а не S3.
INSERT ... ON CONFLICT (key) DO NOTHING вставляет значение только если
ключа ещё нет, поэтому новый шаблон промпта доезжал лишь до свежих
установок — у всех, кто уже пользовался разделом sec-lesson-ai, в settings
оставался старый промпт.
- в db/migration.sql добавлен идемпотентный
UPDATE settings SET value = '<новый промпт>'
WHERE key = 'lesson_ai_prompt' AND value IN ('<старый дефолт>')
по образцу уже существующей миграции для ai_prompt
- условие по value IN (...) обязательно: без него миграция затёрла бы
промпт, отредактированный админом в UI
- в db/init.sql UPDATE не добавляется: файл выполняется только на пустой БД
- AGENTS.md, раздел 2: правило «Сид настроек» (DO NOTHING не обновляет
существующие значения) и «Проверка сида» (прогон в транзакции с откатом)
- AGENTS.md, раздел 3d: смена шаблона промпта — четыре правки, а не три;
все текстовые копии должны быть побайтово идентичны LESSON_AI_DEFAULT_PROMPT
Проверено на postgres:16 в транзакции с откатом, ON_ERROR_STOP=1
- старый дефолт (935 симв.) -> миграция -> новый (2348 симв.)
- повторный прогон ничего не меняет (идемпотентно)
- кастомный промпт админа миграцией не затрагивается
Галочка «Проверить по шаблону» в окне отчёта отправляет текст модели:
совпал с шаблоном — остаётся как есть (skipped), не совпал — переписывается
в деловом виде (done). Обработка идёт в фоне, HTTP-запрос не ждёт модель,
оригинал тьютора сохраняется в text_original.
- схема: text_original/text_ai/ai_status/ai_checked_at/ai_error в
lesson_reports, таблица lesson_report_versions, ensureLessonReportsTable()
- настройки lesson_ai_enabled и lesson_ai_prompt (раздел sec-lesson-ai),
значения только 'true'/'false'
- worker.js: createLessonReportChecker (FOR UPDATE OF lr SKIP LOCKED,
до 3 попыток), хук назовён notifyEvent — notify в createPhotoEnhanceWorker
уже занят будильником
- server.js: wakeLessonAiWorker, onLessonAiDone (версия, аудит с diff,
уведомление lesson.ai.formatted, SSE lesson_report_status), маршруты
/versions, /versions/:id/restore и /ai/revert
- aiComplete вместо aiCorrectText: общий вызов модели с таймаутом
- бэкап/восстановление: lesson_reports и lesson_report_versions в payload
- фронтенд: openLessonVersions/restoreLessonVersion в admin.js, бейджи
статусов в lessons.js, лейблы аудита, renderAuditPager
- docs: раздел 3d в AGENTS.md и Agent Workflow, пункт в README
- тесты: контракт lesson-report и настройки уведомления в api.smoketest.js
Добавлена сущность «что прошли на занятии»:
- lesson_reports (init.sql + migration.sql + ensureLessonReportsTable)
- GET/POST /api/lesson-reports, PUT/DELETE /api/lesson-reports/:id
с branchScope, уникальностью (group_id, lesson_date) и лимитом текста
- уведомление lesson.report (NOTIFY_TYPES + настройка + иконка)
- кэш-префикс lessons: + инвалидация stats:/dashboard:
- восстановление lesson_reports в normalizeRestoreData
- блок recent_lessons в /api/dashboard
Фронтенд:
- public/lessons.html + public/js/lessons.js — список с фильтрами и правкой
- openLessonModal в admin.js — общая модалка из журнала и дашборда
- кнопки в журнале и быстрые действия дашборда
Также исправлен сдвиг индексов параметров в notificationsScope —
$1 уходил повторно в список филиалов из-за params.push без смещения.
- Add UPLOAD_FILE_LIMIT_MB/UPLOAD_TOTAL_LIMIT_MB env (defaults 50/200), compute request timeout from total limit or UPLOAD_REQUEST_TIMEOUT_MS
- Expose upload limits via /api/public-settings and sync in frontend (remove hardcoded 50MB assumption)
- Add byte-range support in storage (getRange/streamRangeTo) and serve Content-Range/Accept-Ranges for S3/local
- Implement inline playable video delivery for browser formats (mp4/m4v/webm/ogv) with ?play=1, range requests, proper 206/416
- Add video modal in journal UI with player and download fallback
- Update docs (AGENTS.md/PRD.md/README.md), styles for video modal, add instructions/TODO.md and screenshots
- Extend MIME types for media
Лимиты были захардкожены в четырёх местах фронтенда и в константах multer,
из-за чего расходились с текстами ошибок на сервере.
- UPLOAD_FILE_LIMIT_MB (50) и UPLOAD_TOTAL_LIMIT_MB (200) читаются из env;
оба multer-конфига (upload, adminUpload) берут fileSize из них, тексты
ошибок собираются из тех же констант вместо литералов
- UPLOAD_REQUEST_TIMEOUT_MS снимает дефолт Node в 5 минут: считается как
UPLOAD_TOTAL_LIMIT_MB * 7500, иначе 200 МБ по мобильной сети не успевают
- GET /api/public-settings отдаёт upload_file_limit_mb / upload_total_limit_mb,
фронтенд читает их вместо собственных констант
Проверено на живом стеке: 20 МБ и 180 МБ суммарно принимаются, 55 МБ и
225 МБ отклоняются с верными сообщениями, скачивание 45 МБ из S3 совпадает
по sha256 с оригиналом, api.smoketest.js — 57 PASS / 0 FAIL.
Добавлена система уведомлений о системных и фоновых событиях (новые записи
журнала, обработка фото, авто-проверка текста, блокировки 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
На странице Настроек появился блок с состоянием стека системы: версии
компонентов, состояние сервисов и нагрузка. Рендерится из нового блока
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
При сохранении записи журнала (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 обновлены
Документация долго описывала 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 дополнен описанием контракта и пунктом в
чеклисте безопасности.
Документация утверждала, что доступ админский и задаётся заголовком
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
работает без промпта.
Добавлен сервис 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).
- 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