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:
@@ -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, если отсутствует)
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ 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, S3-совместимое хранилище файлов, Tailscale (Serve/Funnel)
|
- **Stack**: Node.js 20 + Express, PostgreSQL 16, Redis 7, Docker Compose, S3-совместимое хранилище файлов, Tailscale (Serve/Funnel)
|
||||||
- **Architecture**: Single Express server (`server.js`) + storage abstraction (`storage.js`) + 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 + s3 + tailscale), bind-mounted uploads, named volumes for Postgres and S3 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**: Admin-only via `X-Admin-Token` header (value = `ADMIN_PASSWORD` env var). No user sessions.
|
||||||
|
|
||||||
@@ -47,9 +47,24 @@ This document defines how AI agents should work with the WhatIDo codebase. Follo
|
|||||||
- **Cache**: `.thumbs` (WebP miniatures) and `.cache` (originals localized for sharp/zip) live inside `uploads/` and are pruned hourly (`STORAGE_CACHE_MAX_AGE_HOURS`)
|
- **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
|
- **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`
|
||||||
|
|
||||||
### 4. API Patterns
|
### 4. API Patterns
|
||||||
- **Admin routes**: `requireAdmin` middleware (checks `X-Admin-Token`)
|
- **Admin routes**: `requireAdmin` middleware (checks `X-Admin-Token`)
|
||||||
- **Public routes**: `apiLimiter` (300/15min), `entryLimiter` (10/15min), `fileLimiter` (300/15min)
|
- **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`
|
||||||
@@ -62,11 +77,13 @@ This document defines how AI agents should work with the WhatIDo codebase. Follo
|
|||||||
|
|
||||||
### 6. Docker / Compose
|
### 6. Docker / Compose
|
||||||
- **Dockerfile**: Node 20 Alpine, installs deps, generates self-signed TLS cert
|
- **Dockerfile**: Node 20 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
|
||||||
@@ -144,8 +161,18 @@ curl -H "X-Admin-Token: $ADMIN_PASSWORD" http://localhost:3003/api/groups
|
|||||||
docker compose up -d s3
|
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 --dry-run
|
||||||
docker compose exec -T app node scripts/migrate-to-s3.js --verify-only
|
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
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Verify Redis state through `GET /api/system-info` → `cache` (`driver`, `ready`, `hits`, `misses`,
|
||||||
|
`fallbackOps`, `used_memory_human`, `keys`).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Security Checklist (before any change)
|
## Security Checklist (before any change)
|
||||||
@@ -166,6 +193,9 @@ docker compose exec -T app node scripts/migrate-to-s3.js --verify-only
|
|||||||
|------|---------|
|
|------|---------|
|
||||||
| `server.js` | Entire backend (Express, routes, DB, uploads, backup) |
|
| `server.js` | Entire backend (Express, routes, DB, uploads, backup) |
|
||||||
| `storage.js` | Storage abstraction: `local` and `s3` drivers, key normalization, cache/thumb helpers |
|
| `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 |
|
||||||
|
| `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 |
|
| `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 |
|
||||||
@@ -191,6 +221,11 @@ docker compose exec -T app node scripts/migrate-to-s3.js --verify-only
|
|||||||
- ❌ Touch `uploads/` with `fs.*` in request/worker code — use `storage.*` (files may live only in S3)
|
- ❌ 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
|
- ❌ 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 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
|
||||||
@@ -213,6 +248,10 @@ 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
|
# S3 storage status and migration verification
|
||||||
docker compose up -d s3
|
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 --verify-only
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
- PostgreSQL (pg)
|
- PostgreSQL (pg)
|
||||||
- Multer (загрузка файлов), Tar (бэкапы)
|
- Multer (загрузка файлов), Tar (бэкапы)
|
||||||
- S3-совместимое хранилище (AWS SDK v3): сервис `s3` (SeaweedFS / MinIO)
|
- 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
|
||||||
@@ -50,6 +51,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`
|
||||||
|
|
||||||
Управление:
|
Управление:
|
||||||
|
|
||||||
@@ -61,6 +63,8 @@ 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 кэше.
|
||||||
|
|
||||||
## Обновление на сервере (деплой)
|
## Обновление на сервере (деплой)
|
||||||
|
|
||||||
@@ -97,12 +101,16 @@ docker compose exec app md5sum /app/server.js # совпадает с md5sum
|
|||||||
|------------------|--------------------|-------------------------------------|
|
|------------------|--------------------|-------------------------------------|
|
||||||
| `ADMIN_PASSWORD` | — (обязательно) | Пароль администратора (X-Admin-Token). Без него сервер не стартует |
|
| `ADMIN_PASSWORD` | — (обязательно) | Пароль администратора (X-Admin-Token). Без него сервер не стартует |
|
||||||
| `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` в БД (иначе приложение не подключится).
|
||||||
@@ -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/`
|
- Скачать полный бэкап — `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
|
├── docker-compose.minio.yml # оверрайд: S3-сервис на MinIO вместо SeaweedFS
|
||||||
├── .env.example # шаблон переменных окружения
|
├── .env.example # шаблон переменных окружения
|
||||||
├── Dockerfile # сборка образа (Node 20, генерация TLS-сертификата)
|
├── Dockerfile # сборка образа (Node 20, генерация TLS-сертификата)
|
||||||
├── server.js # Express-приложение
|
├── server.js # Express-приложение
|
||||||
├── storage.js # абстракция хранилища: драйверы local и s3
|
├── 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-проверки и ИИ-улучшения фото
|
├── worker.js # фоновый worker AI-проверки и ИИ-улучшения фото
|
||||||
├── certs/ # cert.pem приложения (монтируется в tailscale, в git не хранится)
|
├── certs/ # cert.pem приложения (монтируется в tailscale, в git не хранится)
|
||||||
├── db/
|
├── db/
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
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 } = {}) {
|
||||||
|
const headers = {};
|
||||||
|
if (token) headers['X-Auth-Token'] = token;
|
||||||
|
if (body) headers['Content-Type'] = 'application/json';
|
||||||
|
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 });
|
||||||
|
|
||||||
|
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 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 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); });
|
||||||
@@ -18,6 +18,38 @@ 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: .
|
||||||
@@ -36,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}
|
||||||
@@ -59,6 +93,8 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
volumes:
|
volumes:
|
||||||
- ./uploads:/app/uploads
|
- ./uploads:/app/uploads
|
||||||
|
|
||||||
@@ -170,4 +206,5 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
pgdata:
|
pgdata:
|
||||||
photo-ai-models:
|
photo-ai-models:
|
||||||
|
redis-data:
|
||||||
s3-data:
|
s3-data:
|
||||||
|
|||||||
Generated
+98
@@ -17,6 +17,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"
|
||||||
}
|
}
|
||||||
@@ -963,6 +964,78 @@
|
|||||||
"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": {
|
"node_modules/@smithy/core": {
|
||||||
"version": "3.35.0",
|
"version": "3.35.0",
|
||||||
"resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.35.0.tgz",
|
"resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.35.0.tgz",
|
||||||
@@ -1277,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",
|
||||||
@@ -2463,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",
|
||||||
|
|||||||
@@ -15,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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,432 @@
|
|||||||
|
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 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 };
|
||||||
|
if (!ready()) return { ...base, driver: 'memory', used_memory_bytes: null, used_memory_human: null, keys: null };
|
||||||
|
try {
|
||||||
|
const [raw, dbsize] = await Promise.all([client.info('memory'), client.dbSize()]);
|
||||||
|
let used = null;
|
||||||
|
let human = null;
|
||||||
|
for (const line of String(raw || '').split('\n')) {
|
||||||
|
if (line.startsWith('used_memory:')) used = parseInt(line.slice(13).trim(), 10) || null;
|
||||||
|
else if (line.startsWith('used_memory_human:')) human = line.slice(19).trim();
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
driver: 'redis',
|
||||||
|
used_memory_bytes: used,
|
||||||
|
used_memory_human: human,
|
||||||
|
keys: Number(dbsize) || 0,
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
noteError('info', e);
|
||||||
|
return { ...base, driver: 'redis', 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 };
|
||||||
@@ -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); });
|
||||||
@@ -9,6 +9,7 @@ const { createEntryAutoChecker, createPhotoEnhanceWorker } = require('./worker')
|
|||||||
const { createZipWriter, renderStudentReport } = require('./student-report');
|
const { createZipWriter, renderStudentReport } = require('./student-report');
|
||||||
|
|
||||||
const { createStorage } = require('./storage');
|
const { createStorage } = require('./storage');
|
||||||
|
const { createRedis } = require('./redis');
|
||||||
|
|
||||||
const https = require('https');
|
const https = require('https');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
@@ -55,39 +56,28 @@ lister.on('notification', (msg) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const cacheStore = new Map();
|
const cache = createRedis({ url: process.env.REDIS_URL, prefix: process.env.REDIS_PREFIX });
|
||||||
const SETTINGS_TTL_MS = 30 * 1000;
|
const SETTINGS_TTL_MS = 30 * 1000;
|
||||||
const PUBLIC_TTL_MS = 60 * 1000;
|
const PUBLIC_TTL_MS = 60 * 1000;
|
||||||
const SHARE_TTL_MS = 60 * 1000;
|
const SHARE_TTL_MS = 60 * 1000;
|
||||||
const STATS_TTL_MS = 15 * 1000;
|
const STATS_TTL_MS = 15 * 1000;
|
||||||
const SYSTEM_TTL_MS = 30 * 1000;
|
const SYSTEM_TTL_MS = 30 * 1000;
|
||||||
|
const SESSION_CACHE_TTL_MS = 30 * 1000;
|
||||||
|
|
||||||
function cacheGet(key) {
|
async function cacheGet(key) {
|
||||||
const entry = cacheStore.get(key);
|
return cache.get(key);
|
||||||
if (!entry) return undefined;
|
|
||||||
if (entry.exp && entry.exp <= Date.now()) {
|
|
||||||
cacheStore.delete(key);
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
return entry.value;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function cacheSet(key, value, ttlMs) {
|
async function cacheSet(key, value, ttlMs) {
|
||||||
cacheStore.set(key, { value, exp: ttlMs ? Date.now() + ttlMs : 0 });
|
return cache.set(key, value, ttlMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
function cacheDrop(prefix) {
|
function cacheDrop(keyPrefix) {
|
||||||
for (const key of cacheStore.keys()) {
|
cache.dropPrefix(keyPrefix).catch(err => console.error('Cache drop failed:', err.message));
|
||||||
if (key.startsWith(prefix)) cacheStore.delete(key);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function cacheWrap(key, ttlMs, fn) {
|
async function cacheWrap(key, ttlMs, fn) {
|
||||||
const hit = cacheGet(key);
|
return cache.wrap(key, ttlMs, fn);
|
||||||
if (hit !== undefined) return hit;
|
|
||||||
const value = await fn();
|
|
||||||
cacheSet(key, value, ttlMs);
|
|
||||||
return value;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function scopeKey(user) {
|
function scopeKey(user) {
|
||||||
@@ -101,31 +91,64 @@ function invalidateSettings() { cacheDrop('setting:'); cacheDrop('share:payload:
|
|||||||
function invalidateStudents() { cacheDrop('students:'); }
|
function invalidateStudents() { cacheDrop('students:'); }
|
||||||
function invalidateGroups() { cacheDrop('groups:'); cacheDrop('students:'); cacheDrop('share:payload:'); }
|
function invalidateGroups() { cacheDrop('groups:'); cacheDrop('students:'); cacheDrop('share:payload:'); }
|
||||||
function invalidateEntries() { cacheDrop('entries:'); cacheDrop('students:'); cacheDrop('share:payload:'); }
|
function invalidateEntries() { cacheDrop('entries:'); cacheDrop('students:'); cacheDrop('share:payload:'); }
|
||||||
|
function invalidateSessions() { cacheDrop('session:'); }
|
||||||
|
|
||||||
|
const EVENTS_CHANNEL = 'whatido:events';
|
||||||
|
const AI_WAKE_CHANNEL = 'whatido:wake:ai';
|
||||||
|
const PHOTO_WAKE_CHANNEL = 'whatido:wake:photo';
|
||||||
|
|
||||||
|
function createWorkerBus(channel) {
|
||||||
|
return {
|
||||||
|
publish() {
|
||||||
|
cache.publish(channel, { t: Date.now() }).catch(err => console.error('Publish failed:', err.message));
|
||||||
|
},
|
||||||
|
subscribe(fn) {
|
||||||
|
return cache.on(channel, fn);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const sseClients = new Set();
|
const sseClients = new Set();
|
||||||
function broadcastEntryChanged() {
|
|
||||||
const frame = `event: entries_changed\ndata: ${JSON.stringify({ ts: Date.now() })}\n\n`;
|
function writeFrame(event, data) {
|
||||||
|
const frame = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
||||||
for (const client of sseClients) {
|
for (const client of sseClients) {
|
||||||
try { client.write(frame); } catch (e) { sseClients.delete(client); }
|
try { client.write(frame); } catch (e) { sseClients.delete(client); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function dispatchEvent(payload) {
|
||||||
|
if (payload && payload.type === 'ai_status') {
|
||||||
|
writeFrame('ai_status', {
|
||||||
|
id: payload.id,
|
||||||
|
ai_status: payload.status,
|
||||||
|
ai_error: payload.error || null,
|
||||||
|
description: payload.description === undefined ? null : payload.description,
|
||||||
|
description_ai: payload.description_ai === undefined ? null : payload.description_ai,
|
||||||
|
description_original: payload.description_original === undefined ? null : payload.description_original,
|
||||||
|
ts: Date.now()
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
writeFrame('entries_changed', { ts: Date.now() });
|
||||||
|
}
|
||||||
|
|
||||||
|
function broadcastEntryChanged() {
|
||||||
|
cache.publish(EVENTS_CHANNEL, { type: 'entries_changed' })
|
||||||
|
.catch(err => console.error('Publish failed:', err.message));
|
||||||
|
}
|
||||||
|
|
||||||
function broadcastAiStatus(payload) {
|
function broadcastAiStatus(payload) {
|
||||||
const data = {
|
cache.publish(EVENTS_CHANNEL, { type: 'ai_status', ...payload })
|
||||||
id: payload.id,
|
.catch(err => console.error('Publish failed:', err.message));
|
||||||
ai_status: payload.status,
|
|
||||||
ai_error: payload.error || null,
|
|
||||||
description: payload.description === undefined ? null : payload.description,
|
|
||||||
description_ai: payload.description_ai === undefined ? null : payload.description_ai,
|
|
||||||
description_original: payload.description_original === undefined ? null : payload.description_original,
|
|
||||||
ts: Date.now()
|
|
||||||
};
|
|
||||||
const frame = `event: ai_status\ndata: ${JSON.stringify(data)}\n\n`;
|
|
||||||
for (const client of sseClients) {
|
|
||||||
try { client.write(frame); } catch (e) { sseClients.delete(client); }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cache.on(EVENTS_CHANNEL, message => {
|
||||||
|
let payload = null;
|
||||||
|
try { payload = JSON.parse(message); } catch (e) { return; }
|
||||||
|
if (payload && payload.type) dispatchEvent(payload);
|
||||||
|
});
|
||||||
|
|
||||||
app.get('/api/events', async (req, res) => {
|
app.get('/api/events', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const token = req.headers['x-auth-token'] || req.query.token;
|
const token = req.headers['x-auth-token'] || req.query.token;
|
||||||
@@ -149,12 +172,12 @@ app.get('/api/events', async (req, res) => {
|
|||||||
});
|
});
|
||||||
function invalidateShare() { cacheDrop('share:payload:'); }
|
function invalidateShare() { cacheDrop('share:payload:'); }
|
||||||
function invalidateStats() { cacheDrop('stats:'); cacheDrop('dashboard:'); cacheDrop('system-info'); }
|
function invalidateStats() { cacheDrop('stats:'); cacheDrop('dashboard:'); cacheDrop('system-info'); }
|
||||||
function invalidateAll() { cacheStore.clear(); }
|
function invalidateAll() { cache.clear().catch(err => console.error('Cache clear failed:', err.message)); }
|
||||||
|
|
||||||
const BAN_TTL_MS = 24 * 60 * 60 * 1000;
|
const BAN_TTL_MS = 24 * 60 * 60 * 1000;
|
||||||
const FAIL_WINDOW_MS = 15 * 60 * 1000;
|
const FAIL_WINDOW_MS = 15 * 60 * 1000;
|
||||||
const banMemory = new Map();
|
const banKey = ip => 'ban:' + ip;
|
||||||
const failMemory = new Map();
|
const failKey = (kind, ip) => 'fail:' + kind + ':' + ip;
|
||||||
|
|
||||||
function ipOf(req) {
|
function ipOf(req) {
|
||||||
return String(req.ip || req.socket?.remoteAddress || 'unknown').slice(0, 64);
|
return String(req.ip || req.socket?.remoteAddress || 'unknown').slice(0, 64);
|
||||||
@@ -166,7 +189,7 @@ async function banIP(req, reason, ms) {
|
|||||||
|
|
||||||
async function banIpAddr(ip, reason, ms, actorReq) {
|
async function banIpAddr(ip, reason, ms, actorReq) {
|
||||||
const until = new Date(Date.now() + ms);
|
const until = new Date(Date.now() + ms);
|
||||||
banMemory.set(ip, { reason, banned_until: until.toISOString() });
|
await cache.set(banKey(ip), { reason, banned_until: until.toISOString() }, ms);
|
||||||
await pool.query(
|
await pool.query(
|
||||||
'INSERT INTO banned_ips (ip, reason, banned_until) VALUES ($1, $2, $3) ON CONFLICT (ip) DO UPDATE SET reason = $2, banned_until = $3',
|
'INSERT INTO banned_ips (ip, reason, banned_until) VALUES ($1, $2, $3) ON CONFLICT (ip) DO UPDATE SET reason = $2, banned_until = $3',
|
||||||
[ip, reason, until.toISOString()]
|
[ip, reason, until.toISOString()]
|
||||||
@@ -175,40 +198,53 @@ async function banIpAddr(ip, reason, ms, actorReq) {
|
|||||||
console.log(`IP banned: ${ip} (${reason})`);
|
console.log(`IP banned: ${ip} (${reason})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ipGuard(req, res, next) {
|
async function unbanIpAddr(ip) {
|
||||||
const entry = banMemory.get(ipOf(req));
|
await cache.del(banKey(ip));
|
||||||
if (entry && new Date(entry.banned_until) > new Date()) {
|
await cache.dropMatch('fail:*:' + ip);
|
||||||
return res.status(403).json({ error: 'Доступ заблокирован' });
|
}
|
||||||
|
|
||||||
|
async function ipGuard(req, res, next) {
|
||||||
|
try {
|
||||||
|
const entry = await cache.get(banKey(ipOf(req)));
|
||||||
|
if (entry && new Date(entry.banned_until) > new Date()) {
|
||||||
|
return res.status(403).json({ error: 'Доступ заблокирован' });
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('IP guard failed:', e.message);
|
||||||
}
|
}
|
||||||
next();
|
next();
|
||||||
}
|
}
|
||||||
|
|
||||||
function recordFailure(req, kind, limit, ms) {
|
function recordFailure(req, kind, limit, ms) {
|
||||||
const ip = ipOf(req);
|
const ip = ipOf(req);
|
||||||
const now = Date.now();
|
return cache.incr(failKey(kind, ip), FAIL_WINDOW_MS)
|
||||||
let entry = failMemory.get(kind + ':' + ip);
|
.then(count => {
|
||||||
if (!entry || entry.resetAt <= now) {
|
if (count >= limit) {
|
||||||
entry = { count: 0, resetAt: now + FAIL_WINDOW_MS };
|
return cache.del(failKey(kind, ip))
|
||||||
failMemory.set(kind + ':' + ip, entry);
|
.then(() => banIP(req, kind, ms))
|
||||||
}
|
.catch(err => console.error('Ban error:', err));
|
||||||
entry.count += 1;
|
}
|
||||||
if (entry.count >= limit) {
|
return null;
|
||||||
failMemory.delete(kind + ':' + ip);
|
})
|
||||||
return banIP(req, kind, ms).catch(err => console.error('Ban error:', err));
|
.catch(err => {
|
||||||
}
|
console.error('recordFailure failed:', err.message);
|
||||||
return Promise.resolve();
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let seededBans = new Set();
|
||||||
|
|
||||||
async function loadBans() {
|
async function loadBans() {
|
||||||
const { rows } = await pool.query('SELECT ip, reason, banned_until FROM banned_ips WHERE banned_until > now()');
|
const { rows } = await pool.query('SELECT ip, reason, banned_until FROM banned_ips WHERE banned_until > now()');
|
||||||
const active = new Set();
|
const active = new Set();
|
||||||
for (const r of rows) {
|
for (const r of rows) {
|
||||||
active.add(r.ip);
|
active.add(r.ip);
|
||||||
banMemory.set(r.ip, { reason: r.reason, banned_until: r.banned_until });
|
const ttl = new Date(r.banned_until).getTime() - Date.now();
|
||||||
|
if (ttl > 0) await cache.set(banKey(r.ip), { reason: r.reason, banned_until: r.banned_until }, ttl);
|
||||||
}
|
}
|
||||||
for (const key of banMemory.keys()) {
|
for (const ip of seededBans) {
|
||||||
if (!active.has(key)) banMemory.delete(key);
|
if (!active.has(ip)) await cache.del(banKey(ip));
|
||||||
}
|
}
|
||||||
|
seededBans = active;
|
||||||
}
|
}
|
||||||
|
|
||||||
app.set('trust proxy', 'loopback');
|
app.set('trust proxy', 'loopback');
|
||||||
@@ -218,6 +254,7 @@ const apiLimiter = rateLimit({
|
|||||||
max: 300,
|
max: 300,
|
||||||
standardHeaders: true,
|
standardHeaders: true,
|
||||||
legacyHeaders: false,
|
legacyHeaders: false,
|
||||||
|
store: cache.rateLimitStore('api', 15 * 60 * 1000),
|
||||||
message: { error: 'Слишком много запросов. Попробуйте позже.' },
|
message: { error: 'Слишком много запросов. Попробуйте позже.' },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -226,6 +263,7 @@ const entryLimiter = rateLimit({
|
|||||||
max: 10,
|
max: 10,
|
||||||
standardHeaders: true,
|
standardHeaders: true,
|
||||||
legacyHeaders: false,
|
legacyHeaders: false,
|
||||||
|
store: cache.rateLimitStore('entry', 15 * 60 * 1000),
|
||||||
message: { error: 'Слишком много запросов. Подождите немного.' },
|
message: { error: 'Слишком много запросов. Подождите немного.' },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -234,6 +272,7 @@ const fileLimiter = rateLimit({
|
|||||||
max: 300,
|
max: 300,
|
||||||
standardHeaders: true,
|
standardHeaders: true,
|
||||||
legacyHeaders: false,
|
legacyHeaders: false,
|
||||||
|
store: cache.rateLimitStore('file', 15 * 60 * 1000),
|
||||||
message: { error: 'Слишком много запросов. Попробуйте позже.' },
|
message: { error: 'Слишком много запросов. Попробуйте позже.' },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -421,6 +460,9 @@ function safeUser(u) {
|
|||||||
|
|
||||||
async function loadUserByToken(token) {
|
async function loadUserByToken(token) {
|
||||||
if (!token || typeof token !== 'string') return null;
|
if (!token || typeof token !== 'string') return null;
|
||||||
|
const key = 'session:' + token;
|
||||||
|
const cached = await cache.get(key);
|
||||||
|
if (cached !== undefined) return cached;
|
||||||
const { rows } = await pool.query(
|
const { rows } = await pool.query(
|
||||||
`SELECT u.id, u.username, u.name, u.role, u.is_active,
|
`SELECT u.id, u.username, u.name, u.role, u.is_active,
|
||||||
COALESCE(array_agg(ub.branch_id) FILTER (WHERE ub.branch_id IS NOT NULL), '{}') AS branch_ids
|
COALESCE(array_agg(ub.branch_id) FILTER (WHERE ub.branch_id IS NOT NULL), '{}') AS branch_ids
|
||||||
@@ -432,6 +474,7 @@ async function loadUserByToken(token) {
|
|||||||
[token]
|
[token]
|
||||||
);
|
);
|
||||||
if (!rows.length) return null;
|
if (!rows.length) return null;
|
||||||
|
await cache.set(key, rows[0], SESSION_CACHE_TTL_MS);
|
||||||
return rows[0];
|
return rows[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -585,11 +628,11 @@ const adminUpload = multer({
|
|||||||
});
|
});
|
||||||
|
|
||||||
async function getSetting(key, def) {
|
async function getSetting(key, def) {
|
||||||
const cached = cacheGet('setting:' + key);
|
const cached = await cacheGet('setting:' + key);
|
||||||
if (cached !== undefined) return cached;
|
if (cached !== undefined) return cached;
|
||||||
const { rows } = await pool.query('SELECT value FROM settings WHERE key = $1', [key]);
|
const { rows } = await pool.query('SELECT value FROM settings WHERE key = $1', [key]);
|
||||||
const value = rows.length ? rows[0].value : def;
|
const value = rows.length ? rows[0].value : def;
|
||||||
cacheSet('setting:' + key, value, SETTINGS_TTL_MS);
|
await cacheSet('setting:' + key, value, SETTINGS_TTL_MS);
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -924,6 +967,7 @@ app.post('/api/auth/login', apiLimiter, async (req, res) => {
|
|||||||
|
|
||||||
app.post('/api/auth/logout', requireAuth, async (req, res) => {
|
app.post('/api/auth/logout', requireAuth, async (req, res) => {
|
||||||
await pool.query('DELETE FROM sessions WHERE token = $1', [req.authToken]);
|
await pool.query('DELETE FROM sessions WHERE token = $1', [req.authToken]);
|
||||||
|
await cache.del('session:' + req.authToken);
|
||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -947,8 +991,9 @@ app.post('/api/bans', requireAuth, requireAdmin, async (req, res) => {
|
|||||||
? req.body.reason.trim().slice(0, 100)
|
? req.body.reason.trim().slice(0, 100)
|
||||||
: 'manual';
|
: 'manual';
|
||||||
const hours = Math.min(Math.max(parseInt(req.body?.hours, 10) || 24, 1), 24 * 30);
|
const hours = Math.min(Math.max(parseInt(req.body?.hours, 10) || 24, 1), 24 * 30);
|
||||||
|
const bannedUntil = new Date(Date.now() + hours * 60 * 60 * 1000).toISOString();
|
||||||
await banIpAddr(ip, reason, hours * 60 * 60 * 1000, req);
|
await banIpAddr(ip, reason, hours * 60 * 60 * 1000, req);
|
||||||
res.json({ ok: true, ip, reason, banned_until: banMemory.get(ip).banned_until });
|
res.json({ ok: true, ip, reason, banned_until: bannedUntil });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.delete('/api/bans/:ip', requireAuth, requireAdmin, async (req, res) => {
|
app.delete('/api/bans/:ip', requireAuth, requireAdmin, async (req, res) => {
|
||||||
@@ -957,8 +1002,7 @@ app.delete('/api/bans/:ip', requireAuth, requireAdmin, async (req, res) => {
|
|||||||
return res.status(400).json({ error: 'Некорректный IP' });
|
return res.status(400).json({ error: 'Некорректный IP' });
|
||||||
}
|
}
|
||||||
await pool.query('DELETE FROM banned_ips WHERE ip = $1', [ip]);
|
await pool.query('DELETE FROM banned_ips WHERE ip = $1', [ip]);
|
||||||
banMemory.delete(ip);
|
await unbanIpAddr(ip);
|
||||||
failMemory.forEach((_, key) => { if (key.endsWith(':' + ip)) failMemory.delete(key); });
|
|
||||||
await logAudit(req, 'ip.unban', { ip });
|
await logAudit(req, 'ip.unban', { ip });
|
||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
});
|
});
|
||||||
@@ -1076,6 +1120,7 @@ app.put('/api/users/:id', requireAuth, requireAdmin, async (req, res) => {
|
|||||||
await client.query('DELETE FROM sessions WHERE user_id = $1', [id]);
|
await client.query('DELETE FROM sessions WHERE user_id = $1', [id]);
|
||||||
}
|
}
|
||||||
await client.query('COMMIT');
|
await client.query('COMMIT');
|
||||||
|
invalidateSessions();
|
||||||
await logAudit(req, 'user.update', { id, role: newRole, is_active: isActive });
|
await logAudit(req, 'user.update', { id, role: newRole, is_active: isActive });
|
||||||
const fresh = await pool.query(
|
const fresh = await pool.query(
|
||||||
`SELECT u.id, u.username, u.name, u.role, u.is_active, u.created_at,
|
`SELECT u.id, u.username, u.name, u.role, u.is_active, u.created_at,
|
||||||
@@ -1098,6 +1143,7 @@ app.delete('/api/users/:id', requireAuth, requireAdmin, async (req, res) => {
|
|||||||
return res.status(400).json({ error: 'Нельзя удалить самого себя' });
|
return res.status(400).json({ error: 'Нельзя удалить самого себя' });
|
||||||
}
|
}
|
||||||
await pool.query('DELETE FROM users WHERE id = $1', [req.params.id]);
|
await pool.query('DELETE FROM users WHERE id = $1', [req.params.id]);
|
||||||
|
invalidateSessions();
|
||||||
await logAudit(req, 'user.delete', { id: req.params.id });
|
await logAudit(req, 'user.delete', { id: req.params.id });
|
||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
});
|
});
|
||||||
@@ -3907,6 +3953,7 @@ app.get('/api/system-info', requireAdmin, async (_, res) => {
|
|||||||
const uploadsCount = usage.count;
|
const uploadsCount = usage.count;
|
||||||
|
|
||||||
const diskInfo = getDiskInfo();
|
const diskInfo = getDiskInfo();
|
||||||
|
const cacheStats = await cache.info();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
database: {
|
database: {
|
||||||
@@ -3942,6 +3989,7 @@ app.get('/api/system-info', requireAdmin, async (_, res) => {
|
|||||||
size_bytes: usage.size_bytes,
|
size_bytes: usage.size_bytes,
|
||||||
},
|
},
|
||||||
disk: diskInfo,
|
disk: diskInfo,
|
||||||
|
cache: cacheStats,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
res.json(payload);
|
res.json(payload);
|
||||||
@@ -5245,6 +5293,19 @@ const HTTPS_PORT = process.env.HTTPS_PORT || 3443;
|
|||||||
process.on('unhandledRejection', (err) => { console.error('Unhandled rejection:', err); });
|
process.on('unhandledRejection', (err) => { console.error('Unhandled rejection:', err); });
|
||||||
process.on('uncaughtException', (err) => { console.error('Uncaught exception:', err); });
|
process.on('uncaughtException', (err) => { console.error('Uncaught exception:', err); });
|
||||||
|
|
||||||
|
let shuttingDown = false;
|
||||||
|
for (const signal of ['SIGTERM', 'SIGINT']) {
|
||||||
|
process.on(signal, () => {
|
||||||
|
if (shuttingDown) return;
|
||||||
|
shuttingDown = true;
|
||||||
|
console.log(`${signal}: shutting down`);
|
||||||
|
cache.close()
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => process.exit(0));
|
||||||
|
setTimeout(() => process.exit(0), 5000).unref();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const certPath = path.join(__dirname, 'certs', 'cert.pem');
|
const certPath = path.join(__dirname, 'certs', 'cert.pem');
|
||||||
const keyPath = path.join(__dirname, 'certs', 'key.pem');
|
const keyPath = path.join(__dirname, 'certs', 'key.pem');
|
||||||
|
|
||||||
@@ -5257,6 +5318,7 @@ if (fs.existsSync(certPath) && fs.existsSync(keyPath)) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
|
try { await cache.connect(); } catch (err) { console.error('Redis connect:', err); }
|
||||||
try { await ensureBranchesTable(); } catch (err) { console.error('Branches table:', err); }
|
try { await ensureBranchesTable(); } catch (err) { console.error('Branches table:', err); }
|
||||||
try { await ensureUsersAndFirstAdmin(); } catch (err) { console.error('Users table:', err); }
|
try { await ensureUsersAndFirstAdmin(); } catch (err) { console.error('Users table:', err); }
|
||||||
try { await ensureAuditTable(); } catch (err) { console.error('Audit table:', err); }
|
try { await ensureAuditTable(); } catch (err) { console.error('Audit table:', err); }
|
||||||
@@ -5285,7 +5347,7 @@ if (fs.existsSync(certPath) && fs.existsSync(keyPath)) {
|
|||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
try { storage.pruneCache(); } catch (err) { console.error('Cache prune:', err); }
|
try { storage.pruneCache(); } catch (err) { console.error('Cache prune:', err); }
|
||||||
}, 60 * 60 * 1000).unref();
|
}, 60 * 60 * 1000).unref();
|
||||||
entryAutoChecker = createEntryAutoChecker({ pool, getSetting, logAudit, aiUrl: AI_URL, defaultPrompt: AI_DEFAULT_PROMPT });
|
entryAutoChecker = createEntryAutoChecker({ pool, getSetting, logAudit, aiUrl: AI_URL, defaultPrompt: AI_DEFAULT_PROMPT, bus: createWorkerBus(AI_WAKE_CHANNEL) });
|
||||||
entryAutoChecker.start();
|
entryAutoChecker.start();
|
||||||
console.log('AI auto-check worker started');
|
console.log('AI auto-check worker started');
|
||||||
photoWorker = createPhotoEnhanceWorker({
|
photoWorker = createPhotoEnhanceWorker({
|
||||||
@@ -5297,6 +5359,7 @@ if (fs.existsSync(certPath) && fs.existsSync(keyPath)) {
|
|||||||
photoAiUrl: PHOTO_AI_URL,
|
photoAiUrl: PHOTO_AI_URL,
|
||||||
uploadsDir: UPLOADS_DIR,
|
uploadsDir: UPLOADS_DIR,
|
||||||
storage,
|
storage,
|
||||||
|
bus: createWorkerBus(PHOTO_WAKE_CHANNEL),
|
||||||
});
|
});
|
||||||
photoWorker.start();
|
photoWorker.start();
|
||||||
console.log('Photo enhance worker started');
|
console.log('Photo enhance worker started');
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ const crypto = require('crypto');
|
|||||||
const PHOTO_MAX_ATTEMPTS = 3;
|
const PHOTO_MAX_ATTEMPTS = 3;
|
||||||
const PHOTO_AI_TIMEOUT_MS = 300000;
|
const PHOTO_AI_TIMEOUT_MS = 300000;
|
||||||
|
|
||||||
function createPhotoEnhanceWorker({ pool, getSetting, logAudit, invalidateEntries, sharp, photoAiUrl, uploadsDir, storage }) {
|
function createPhotoEnhanceWorker({ pool, getSetting, logAudit, invalidateEntries, sharp, photoAiUrl, uploadsDir, storage, bus }) {
|
||||||
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;
|
||||||
@@ -37,10 +37,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';
|
||||||
@@ -233,7 +240,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 || 'Ты — редактор текстов. Исправь ТОЛЬКО грамматические, орфографические и пунктуационные ошибки в тексте. Приведи к правильному регистру буквы. НЕ меняй слова, структуру предложений, стиль или смысл текста. Верни ТОЛЬКО исправленный текст без пояснений.';
|
||||||
@@ -266,10 +273,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';
|
||||||
|
|||||||
Reference in New Issue
Block a user