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).
This commit is contained in:
dev
2026-09-26 15:26:00 +03:00
parent e4d58d6525
commit 0e38a280d7
11 changed files with 1098 additions and 74 deletions
+61 -1
View File
@@ -22,6 +22,7 @@
- PostgreSQL (pg)
- Multer (загрузка файлов), Tar (бэкапы)
- S3-совместимое хранилище (AWS SDK v3): сервис `s3` (SeaweedFS / MinIO)
- Redis: кэш, rate limit, баны IP, кэш сессий, pub/sub (SSE и воркеры)
- Lucide (иконки UI)
- Docker / Docker Compose
- Tailscale (Serve / Funnel) — публикация по HTTPS
@@ -50,6 +51,7 @@ docker compose up -d --build
- **HTTP** `http://localhost:3003` — редирект на HTTPS
- **HTTPS** `https://localhost:3443` — приложение (самоподписанный сертификат, примите предупреждение браузера)
- **PostgreSQL** — доступен только внутри docker-сети (наружу не публикуется)
- **Redis** — `127.0.0.1:6379` на хосте (только loopback), внутри сети — `redis:6379`
Управление:
@@ -61,6 +63,8 @@ docker compose down # остановка (данные сохраняю
> Приложение **не запустится** без `ADMIN_PASSWORD` (защита от пароля по умолчанию).
> `DB_PASSWORD` задаёт пароль пользователя `app` в PostgreSQL.
> `REDIS_PASSWORD` задаёт пароль Redis. Если сервис `redis` убрать из `docker-compose.yml`
> или оставить `REDIS_URL` пустым — приложение продолжит работать на in-memory кэше.
## Обновление на сервере (деплой)
@@ -97,12 +101,16 @@ docker compose exec app md5sum /app/server.js # совпадает с md5sum
|------------------|--------------------|-------------------------------------|
| `ADMIN_PASSWORD` | — (обязательно) | Пароль администратора (X-Admin-Token). Без него сервер не стартует |
| `DB_PASSWORD` | — (обязательно) | Пароль пользователя `app` в PostgreSQL |
| `REDIS_PASSWORD` | — (обязательно) | Пароль Redis (`--requirepass`) |
| `REDIS_PREFIX` | `whatido` | Префикс ключей Redis — свой для каждого инстанса |
| `REDIS_MAXMEMORY` | `256mb` | Лимит памяти Redis, при переполнении вытесняется LRU |
Пример `.env` (в репозитории — `.env.example`):
```
ADMIN_PASSWORD=сложный-пароль
DB_PASSWORD=случайная-длинная-строка
REDIS_PASSWORD=случайная-длинная-строка
```
`DB_PASSWORD` подставляется в `docker-compose.yml` в `POSTGRES_PASSWORD` и `DATABASE_URL`. Если БД уже была инициализирована ранее, значение `DB_PASSWORD` должно совпадать с фактическим паролем пользователя `app` в БД (иначе приложение не подключится).
@@ -312,8 +320,57 @@ docker compose exec -T app node scripts/migrate-to-s3.js --delete-local
Объём и состав хранилища видны в админке: Настройки → Системная информация (блок «Хранилище»).
## 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`).
### Команды
```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 (нужен запущенный стек)
```
## Бэкапы
В админке (Настройки → Бэкап) можно:
- Скачать полный бэкап — `tar.gz`, содержащий `data.json` (все таблицы) и `uploads/`
@@ -367,12 +424,15 @@ docker compose exec -T app node scripts/migrate-to-s3.js --delete-local
## Структура проекта
```
├── docker-compose.yml # сервисы: app + db + s3 (+ опционально tailscale)
├── docker-compose.yml # сервисы: app + db + redis + s3 (+ опционально tailscale)
├── docker-compose.minio.yml # оверрайд: S3-сервис на MinIO вместо SeaweedFS
├── .env.example # шаблон переменных окружения
├── Dockerfile # сборка образа (Node 20, генерация TLS-сертификата)
├── server.js # Express-приложение
├── storage.js # абстракция хранилища: драйверы local и s3
├── redis.js # абстракция Redis: кэш, счётчики, rate limit, pub/sub (с in-memory fallback)
├── redis.selftest.js # тесты слоя Redis, включая деградацию при недоступном сервере
├── api.smoketest.js # сквозная проверка API по поднятому стеку
├── worker.js # фоновый worker AI-проверки и ИИ-улучшения фото
├── certs/ # cert.pem приложения (монтируется в tailscale, в git не хранится)
├── db/