Compare commits
22
Commits
main
...
31542de33b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31542de33b | ||
|
|
2afe676969 | ||
|
|
f31b8deea2 | ||
|
|
4d3df1de37 | ||
|
|
3ecf87146a | ||
|
|
eea42eb704 | ||
|
|
54cf5bbfa4 | ||
|
|
19676c9b64 | ||
|
|
87541a5ce8 | ||
|
|
4b620d3e59 | ||
|
|
cb0e68d04a | ||
|
|
0e38a280d7 | ||
|
|
e4d58d6525 | ||
|
|
ddd49707ae | ||
|
|
8bf54fb95d | ||
|
|
e6c69c8a78 | ||
|
|
e4704c2dc6 | ||
|
|
a72600af13 | ||
|
|
2e1d36bc4d | ||
|
|
d943b77f58 | ||
|
|
a95af7daa7 | ||
|
|
69d46a0e5f |
@@ -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, если отсутствует)
|
||||||
@@ -31,3 +39,29 @@ WG_HANDSHAKE_TIMEOUT=60
|
|||||||
# ИИ-улучшение фото (Real-ESRGAN), пусто = контейнер photo-ai
|
# ИИ-улучшение фото (Real-ESRGAN), пусто = контейнер photo-ai
|
||||||
PHOTO_AI_URL=
|
PHOTO_AI_URL=
|
||||||
PHOTO_AI_MAX_PIXELS=4000000
|
PHOTO_AI_MAX_PIXELS=4000000
|
||||||
|
|
||||||
|
# === Хранилище файлов (S3: SeaweedFS по умолчанию / MinIO) ===
|
||||||
|
# local — файлы в ./uploads (по умолчанию), s3 — объекты в бакете S3/MinIO.
|
||||||
|
STORAGE_DRIVER=local
|
||||||
|
# Читать локальную копию, если объект ещё не перенесён в S3 (1 — включено).
|
||||||
|
STORAGE_LOCAL_FALLBACK=1
|
||||||
|
# Оставлять локальную копию после выгрузки в S3 (1 — оставлять, 0 — удалять).
|
||||||
|
STORAGE_KEEP_LOCAL=0
|
||||||
|
# Сколько часов хранить локальный кэш оригиналов (для sharp/миниатюр), 0 — не чистить.
|
||||||
|
STORAGE_CACHE_MAX_AGE_HOURS=168
|
||||||
|
# Образ S3-сервиса. По умолчанию SeaweedFS (свободный S3-сервер).
|
||||||
|
# Для MinIO: S3_IMAGE=minio/minio:<тег> и запуск через docker-compose.minio.yml.
|
||||||
|
S3_IMAGE=chrislusf/seaweedfs:latest
|
||||||
|
S3_ENDPOINT=http://s3:9000
|
||||||
|
S3_REGION=us-east-1
|
||||||
|
S3_BUCKET=whatido
|
||||||
|
# Логин/пароль S3 (для SeaweedFS — AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY,
|
||||||
|
# для MinIO — MINIO_ROOT_USER/MINIO_ROOT_PASSWORD).
|
||||||
|
S3_ACCESS_KEY=whatido
|
||||||
|
S3_SECRET_KEY=замените-на-длинный-секрет
|
||||||
|
# Path-style адресация (1 — включено, нужно для MinIO и SeaweedFS).
|
||||||
|
S3_FORCE_PATH_STYLE=1
|
||||||
|
# Необязательный префикс ключей внутри бакета (например, prod).
|
||||||
|
S3_PREFIX=
|
||||||
|
# Показывать веб-консоль MinIO (on/off), только для docker-compose.minio.yml
|
||||||
|
MINIO_BROWSER=off
|
||||||
|
|||||||
+3
-3
@@ -14,6 +14,9 @@ backups/
|
|||||||
*.sql.gz
|
*.sql.gz
|
||||||
*.tar.gz
|
*.tar.gz
|
||||||
|
|
||||||
|
# --- Generated build artifacts ---
|
||||||
|
public/version.json
|
||||||
|
|
||||||
# --- Node ---
|
# --- Node ---
|
||||||
node_modules/
|
node_modules/
|
||||||
|
|
||||||
@@ -27,8 +30,5 @@ Thumbs.db
|
|||||||
# --- Playwright test artifacts ---
|
# --- Playwright test artifacts ---
|
||||||
.playwright-mcp/
|
.playwright-mcp/
|
||||||
|
|
||||||
# --- Generated version data ---
|
|
||||||
public/version.json
|
|
||||||
|
|
||||||
# --- Internal audit (not for commit) ---
|
# --- Internal audit (not for commit) ---
|
||||||
SECURITY_AUDIT.md
|
SECURITY_AUDIT.md
|
||||||
|
|||||||
@@ -8,10 +8,10 @@ This document defines how AI agents should work with the WhatIDo codebase. Follo
|
|||||||
|
|
||||||
**WhatIDo** — Accounting system for an educational center: attendance journal, student project works, group gallery, detached files, and public showcase pages (share links).
|
**WhatIDo** — Accounting system for an educational center: attendance journal, student project works, group gallery, detached files, and public showcase pages (share links).
|
||||||
|
|
||||||
- **Stack**: Node.js 20 + Express, PostgreSQL 16, Docker Compose, Tailscale (Serve/Funnel)
|
- **Stack**: Node.js 20 + Express, PostgreSQL 16, Redis 7, Docker Compose, S3-совместимое хранилище файлов, Tailscale (Serve/Funnel)
|
||||||
- **Architecture**: Single Express server (`server.js`) + static frontend in `public/`
|
- **Architecture**: Single Express server (`server.js`) + storage abstraction (`storage.js`) + cache/pub-sub abstraction (`redis.js`) + static frontend in `public/`
|
||||||
- **Deployment**: Docker Compose (app + db + tailscale), bind-mounted uploads, named volume for Postgres data
|
- **Deployment**: Docker Compose (app + db + s3 + tailscale), bind-mounted uploads, named volumes for Postgres and S3 data
|
||||||
- **Auth**: Admin-only via `X-Admin-Token` header (value = `ADMIN_PASSWORD` env var). No user sessions.
|
- **Auth**: сессии в БД. `POST /api/auth/login` (bcrypt) → токен в заголовке `X-Auth-Token`. Роли: `admin` и не-admin, ограниченные филиалами (`user_branches`). `ADMIN_PASSWORD` используется **только** для автосоздания первого админа в пустой БД — это не механизм авторизации API
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -34,13 +34,50 @@ This document defines how AI agents should work with the WhatIDo codebase. Follo
|
|||||||
### 3. File Uploads
|
### 3. File Uploads
|
||||||
- **Multer configs**: `upload` (images only), `adminUpload` (wider allowed ext), `uploadBackup` (restore)
|
- **Multer configs**: `upload` (images only), `adminUpload` (wider allowed ext), `uploadBackup` (restore)
|
||||||
- **Limits**: 10 MB/file, 30 MB total per entry
|
- **Limits**: 10 MB/file, 30 MB total per entry
|
||||||
- **Storage**: `uploads/` bind-mounted to host, filenames = `timestamp-random.ext`
|
- **Staging**: Multer always writes to `uploads/` (`timestamp-random.ext`); a global `res.on('finish')` hook persists each uploaded file through `storage.persist` on successful responses (only when `STORAGE_DRIVER=s3`)
|
||||||
- **HEIC**: Auto-converted to JPEG via `heic-convert`
|
- **HEIC**: Auto-converted to JPEG via `heic-convert`
|
||||||
- **Cleanup**: `safeUnlink` / `sweepOrphanedUploads` — never delete outside `uploads/`
|
- **Cleanup**: `safeUnlink` / `sweepOrphanedUploads` — never delete outside `uploads/` or the configured bucket
|
||||||
|
|
||||||
|
### 3a. Storage (`storage.js`)
|
||||||
|
- **Drivers**: `local` (default, files in `uploads/`) and `s3` (S3-compatible: SeaweedFS by default, MinIO via `docker-compose.minio.yml`)
|
||||||
|
- **Keys are stable**: DB stores `/uploads/<name>`; S3 object keys are the same `<name>` (plus `.originals/<name>`). Never change key format — it would break existing DB rows and URLs
|
||||||
|
- **API**: `put`, `putFile`, `head`, `exists`, `sizeOf`, `getStream`, `getBuffer`, `del`, `copyObject`, `listAll`, `localize`, `persist`, `streamTo`, `downloadAll`, `uploadTree`, `ensureBucket`, `usage`, `pruneCache`
|
||||||
|
- **Rules**: never call `fs.*` on `uploads/` directly in request/worker code — use `storage.*`. `safeUnlink` is the only deletion helper (local + remote, idempotent)
|
||||||
|
- **Read path**: `STORAGE_LOCAL_FALLBACK=1` prefers a local file when it still exists (covers in-flight uploads and partial migration); otherwise the app streams the object from S3
|
||||||
|
- **Cache**: `.thumbs` (WebP miniatures) and `.cache` (originals localized for sharp/zip) live inside `uploads/` and are pruned hourly (`STORAGE_CACHE_MAX_AGE_HOURS`)
|
||||||
|
- **Never publish the S3 API port**: only `127.0.0.1` on the host, file access stays behind app auth/rate limits
|
||||||
|
|
||||||
|
### 3b. Redis (`redis.js`)
|
||||||
|
- **Единственная точка доступа**: `createRedis({ url, prefix })` — все операции кэша/счётчиков/pub-sub идут через неё
|
||||||
|
- **API**: `get`, `set`, `del`, `dropPrefix`, `dropMatch`, `clear`, `wrap`, `incr`, `publish`, `on`, `rateLimitStore`, `info`, `connect`, `close`
|
||||||
|
- **Graceful fallback — обязательное требование**: при недоступном Redis все операции уходят в in-memory backend с той же семантикой. Приложение обязано стартовать и работать без Redis
|
||||||
|
- **Первое подключение ограничено по времени** (`REDIS_CONNECT_TIMEOUT_MS`, 5 с): node-redis не отклоняет `connect()` при недоступном сервере, а повторяет попытки бесконечно — без таймаута старт приложения зависнет навсегда
|
||||||
|
- **Переподключение**: node-redis переподключается сам; по событию `ready` подписки и subscriber-клиент восстанавливаются (`ensureSubscriber`). Не пересоздавать subscriber через `destroy()` — это гонка с внутренним teardown node-redis
|
||||||
|
- **Ключи**: `get`/`set` сами добавляют namespace (`REDIS_PREFIX`, по умолчанию `whatido`), `dropPrefix`/`dropMatch` тоже. В `rateLimitStore` префикс добавляется один раз в `base` — не применяйте `fullKey` повторно
|
||||||
|
- **`scanDelete`**: курсор `SCAN` в node-redis v5 обязан быть строкой, числовой `0` вызовет `TypeError`. Возвращаемое значение курсора — тоже строка, сравнивайте с `'0'`
|
||||||
|
- **`resetTime` в `rateLimitStore.increment` обязан быть `Date`** — express-rate-limit v8 вызывает `resetTime.getTime()`
|
||||||
|
- **Пабликация всегда отдаёт подписчикам строку** (JSON), независимо от бэкенда — иначе fallback и Redis расходятся по формату
|
||||||
|
- **Инвалидация — по префиксу** (`SCAN` + `DEL`), точечного удаления по ключу избегайте
|
||||||
|
- **Ключевые пространства**: `setting:`, `groups:`, `students:`, `entries:`, `stats:`, `dashboard:`, `share:payload:`, `public-settings`, `system-info`, `session:`, `ban:`, `fail:`, `rl:`
|
||||||
|
- **Сессии**: `loadUserByToken` кэширует пользователя на 30 с. Любая мутация `users` / `sessions` / `user_branches` обязана вызывать `invalidateSessions()` или удалять `session:<token>`, иначе деактивированный пользователь сохранит доступ
|
||||||
|
- **Секреты**: пароль только в `REDIS_URL` / `REDIS_PASSWORD`, порт 6379 публикуется лишь на `127.0.0.1`
|
||||||
|
|
||||||
|
### 3c. Уведомления (`server.js`, `worker.js`, `public/`)
|
||||||
|
- **Каталог событий** — только `NOTIFY_TYPES` в `server.js` (тип → `label`, `hint`, `icon`, `level`, `enabled` по умолчанию, `admin`); фронтенд берёт список из `GET /api/notifications/meta`, дублировать каталог в HTML нельзя
|
||||||
|
- **Таблицы**: `notifications` (событие, `admin_only`, `branch_id`) + `notification_reads` (прочтение на пользователя). Изменения схемы — в `db/init.sql` и `db/migration.sql` и в `ensureNotificationsTable()`
|
||||||
|
- **Настройки**: `notify_enabled` (общий), `notify_retention_days` (1–365), `notify_<тип>` (точки типа заменяются на `_`, см. `notifySettingKey`). Значения только `'true'` / `'false'` — `PUT /api/settings` это валидирует
|
||||||
|
- **Создание события** — только через `pushNotification()` / `notifyEntry()`; они сами проверяют переключатели и при выключенном типе возвращают `null`. `notifyEntry` подставляет `{student}` и `{group}` и определяет филиал по группе записи
|
||||||
|
- **Хук в фото-воркере называется `notifyEvent`** — имя `notify` внутри `createPhotoEnhanceWorker` уже занято будильником воркера (`photoWorker.notify()` из `POST /api/photo-jobs/wake`), объявление функции перекрыло бы параметр
|
||||||
|
- **Видимость**: админ видит всё; остальные — `admin_only = false` и `branch_id IS NULL` или филиал из `user_branches`
|
||||||
|
- **Доставка**: запись в БД → `cache.publish('whatido:notifications', row)` → SSE `GET /api/notifications/stream` (клиенты фильтруются по `notificationVisible`). Redis недоступен — работает in-memory pub/sub
|
||||||
|
- **Очистка**: `purgeOldNotifications()` при старте и раз в час по `notify_retention_days`; чтения удаляются каскадом
|
||||||
|
- **Новое событие добавляется вместе с**: записью в `NOTIFY_TYPES`, строками `INSERT INTO settings` в `db/init.sql` + `db/migration.sql`, вызовом `pushNotification`/`notifyEntry` в точке события и парой `icon` из Lucide
|
||||||
|
- **Аудит**: удаление/очистка уведомлений логируется (`notifications.delete`, `notifications.clear`)
|
||||||
|
|
||||||
### 4. API Patterns
|
### 4. API Patterns
|
||||||
- **Admin routes**: `requireAdmin` middleware (checks `X-Admin-Token`)
|
- **Middleware**: `requireAuth` — читает `X-Auth-Token`, 401 без валидной активной сессии. `requireAdmin` — самодостаточный (внутри вызывает `requireAuth`, если `req.user` ещё нет), 403 при `role !== 'admin'`. `optionalAuth` — для публичных страниц с персонализацией
|
||||||
- **Public routes**: `apiLimiter` (300/15min), `entryLimiter` (10/15min), `fileLimiter` (300/15min)
|
- **Филиалы**: `branchScope(user)` / `branchWhere(user, alias)` — для не-admin `user.branch_ids` (из `user_branches`) ограничивают выборку; у `admin` `ids = null` и фильтр не добавляется
|
||||||
|
- **Public routes**: `apiLimiter` (300/15min), `entryLimiter` (10/15min), `fileLimiter` (300/15min) — все на `cache.rateLimitStore(...)`, не на `MemoryStore`
|
||||||
- **Responses**: JSON, `{ error: 'message' }` on failure, data directly on success
|
- **Responses**: JSON, `{ error: 'message' }` on failure, data directly on success
|
||||||
- **Pagination**: `limit` / `offset` query params, return `{ items, total }` or `{ entries, total }`
|
- **Pagination**: `limit` / `offset` query params, return `{ items, total }` or `{ entries, total }`
|
||||||
- **Filters**: `group_id`, `date_from`, `date_to`, `student_name`, `search`, `deleted`
|
- **Filters**: `group_id`, `date_from`, `date_to`, `student_name`, `search`, `deleted`
|
||||||
@@ -48,16 +85,18 @@ This document defines how AI agents should work with the WhatIDo codebase. Follo
|
|||||||
### 5. Frontend (public/)
|
### 5. Frontend (public/)
|
||||||
- Vanilla HTML/CSS/JS, no build step
|
- Vanilla HTML/CSS/JS, no build step
|
||||||
- Each page = single HTML file + shared `admin.js` / `admin.css`
|
- Each page = single HTML file + shared `admin.js` / `admin.css`
|
||||||
- API calls via `fetch` with `X-Admin-Token` from `localStorage`
|
- API calls via `fetch` with `X-Auth-Token` (токен из `localStorage`); `X-Admin-Token` больше не используется и не работает
|
||||||
- Share pages (`share.html`, `links.html`) work without auth
|
- Share pages (`share.html`, `links.html`) work without auth
|
||||||
|
|
||||||
### 6. Docker / Compose
|
### 6. Docker / Compose
|
||||||
- **Dockerfile**: Node 20 Alpine, installs deps, generates self-signed TLS cert
|
- **Dockerfile**: Node 22 Alpine, installs deps, generates self-signed TLS cert
|
||||||
- **docker-compose.yml**: 3 services (db, app, tailscale)
|
- **docker-compose.yml**: сервисы `db`, `app`, `redis`, `s3` (+ опционально `tailscale`, `cloudflared`, `text-corrector`, `photo-ai`)
|
||||||
- `db`: postgres:16-alpine, healthcheck, init.sql mounted
|
- `db`: postgres:16-alpine, healthcheck, init.sql mounted
|
||||||
|
- `redis`: redis:7-alpine, `--requirepass`, AOF, `maxmemory` + `allkeys-lru`, healthcheck, том `redis-data`, порт только на `127.0.0.1`
|
||||||
- `app`: builds from Dockerfile, exposes 3003/3443, mounts uploads
|
- `app`: builds from Dockerfile, exposes 3003/3443, mounts uploads
|
||||||
- `tailscale`: host network, NET_ADMIN, runs `start-tailscale.sh` (funnel to 127.0.0.1:3443)
|
- `tailscale`: host network, NET_ADMIN, runs `start-tailscale.sh` (funnel to 127.0.0.1:3443)
|
||||||
- **Env vars** (required): `ADMIN_PASSWORD`, `DB_PASSWORD`
|
- **Env vars** (required): `ADMIN_PASSWORD`, `DB_PASSWORD`, `REDIS_PASSWORD`
|
||||||
|
- **Env vars** (optional): `REDIS_PREFIX` (default `whatido`), `REDIS_MAXMEMORY` (default `256mb`), `REDIS_CONNECT_TIMEOUT_MS` (default `5000`)
|
||||||
- **Port 443 on host** must be free (tailscale listens directly)
|
- **Port 443 on host** must be free (tailscale listens directly)
|
||||||
|
|
||||||
### 7. Tailscale Publication
|
### 7. Tailscale Publication
|
||||||
@@ -102,6 +141,15 @@ This document defines how AI agents should work with the WhatIDo codebase. Follo
|
|||||||
- Update Multer `fileFilter` functions
|
- Update Multer `fileFilter` functions
|
||||||
- Keep `MAX_TOTAL_UPLOAD_BYTES` and per-file limit in sync
|
- Keep `MAX_TOTAL_UPLOAD_BYTES` and per-file limit in sync
|
||||||
|
|
||||||
|
### Migrate files to S3 / switch storage driver
|
||||||
|
1. `docker compose up -d s3`
|
||||||
|
2. `docker compose exec -T app node scripts/migrate-to-s3.js --dry-run` then without the flag (idempotent, size-checked, keeps local files)
|
||||||
|
3. `docker compose exec -T app node scripts/migrate-to-s3.js --verify-only`
|
||||||
|
4. Set `STORAGE_DRIVER=s3` in `.env`, `docker compose up -d app`
|
||||||
|
5. After verification: `docker compose exec -T app node scripts/migrate-to-s3.js --delete-local`
|
||||||
|
- Rollback: `STORAGE_DRIVER=local` + `docker compose up -d app`
|
||||||
|
- Do not run `--delete-local` before the app serves reads from S3 and the verification passes
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Testing & Verification
|
## Testing & Verification
|
||||||
@@ -115,21 +163,68 @@ docker compose up -d --build
|
|||||||
# Check logs
|
# Check logs
|
||||||
docker compose logs -f app
|
docker compose logs -f app
|
||||||
|
|
||||||
# Test API (replace TOKEN)
|
# Test API (replace LOGIN/PASS; X-Admin-Token больше не работает)
|
||||||
curl -H "X-Admin-Token: $ADMIN_PASSWORD" http://localhost:3003/api/groups
|
TOKEN=$(curl -s -X POST http://localhost:3003/api/auth/login \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d "{\"username\":\"$LOGIN\",\"password\":\"$PASS\"}" | sed -E 's/.*"token":"([a-f0-9]+)".*/\1/')
|
||||||
|
curl -H "X-Auth-Token: $TOKEN" http://localhost:3003/api/auth/me
|
||||||
|
# /api/groups — публичный (optionalAuth), 200 даже без токена:
|
||||||
|
# для проверки авторизации берите /api/auth/me или /api/users
|
||||||
|
|
||||||
# Run backup/restore scripts
|
# Run backup/restore scripts
|
||||||
./scripts/backup.sh
|
./scripts/backup.sh
|
||||||
./scripts/restore.sh backups/whatido-backup-<date>.tar.gz
|
./scripts/restore.sh backups/whatido-backup-<date>.tar.gz
|
||||||
|
|
||||||
|
# Storage checks
|
||||||
|
docker compose up -d s3
|
||||||
|
docker compose exec -T app node scripts/migrate-to-s3.js --dry-run
|
||||||
|
docker compose exec -T app node scripts/migrate-to-s3.js --verify-only
|
||||||
|
|
||||||
|
# Redis checks
|
||||||
|
node redis.selftest.js # unit + degradation, needs redis on 127.0.0.1:6379
|
||||||
|
node api.smoketest.js # e2e, needs running stack
|
||||||
|
docker compose exec redis redis-cli -a "$REDIS_PASSWORD" --no-auth-warning INFO
|
||||||
|
docker compose stop redis && node api.smoketest.js # app must keep working in-memory
|
||||||
|
docker compose start redis # app reconnects on its own
|
||||||
|
|
||||||
|
# Audit diff checks
|
||||||
|
node diff.selftest.js # unit, no stack needed
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`diff.js` builds the audit payload for text changes: word-level segments
|
||||||
|
(`eq`/`del`/`add`), per-step stats (`added_words`, `removed_words`, `chars_before/after`) and a
|
||||||
|
light `summarizeChanges`/`stripDiffs` pair for the audit list. Two rules to keep:
|
||||||
|
the diff payload stored in `audit_log.target` must stay capped (it is rendered raw in the audit
|
||||||
|
UI), and `GET /api/audit` must keep stripping `diff` while `GET /api/audit/:id` returns it —
|
||||||
|
otherwise the list endpoint ships kilobytes of text per row.
|
||||||
|
|
||||||
|
Verify Redis state through `GET /api/system-info` → `cache` (`driver`, `ready`, `hits`, `misses`,
|
||||||
|
`fallbackOps`, `used_memory_human`, `keys`).
|
||||||
|
|
||||||
|
`GET /api/system-info` also returns a `stack` block (built by `getStackInfo()`, outside the
|
||||||
|
response cache so versions and load stay fresh): `app` (Node, PID, RSS/heap, uptime, version from
|
||||||
|
`package.json` + `public/version.json`), `deps` (installed versions of the main packages), `runtime`
|
||||||
|
(OS from `/etc/os-release`, kernel, arch, CPU count/model, loadavg, memory, container detection),
|
||||||
|
`database` (PostgreSQL version, host, pool counters), `cache` (driver, version, ready, keys, memory,
|
||||||
|
hits/misses, fallback ops) and `storage` (driver, endpoint, bucket). Hosts come from `URL.hostname`
|
||||||
|
only — credentials from `DATABASE_URL`/`REDIS_URL` must never reach the payload. The «Статус стека»
|
||||||
|
block on `public/settings.html` renders exactly this payload.
|
||||||
|
|
||||||
|
`api.smoketest.js` also locks the auth contract: only `X-Auth-Token` with a session token
|
||||||
|
authenticates, while `X-Admin-Token`, `Authorization: Bearer` and `ADMIN_PASSWORD` used as a
|
||||||
|
token must all be rejected with 401. If you change the auth scheme, update this test and the
|
||||||
|
Auth notes in this file together — a doc that drifts from the code is the failure mode this
|
||||||
|
guards against.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Security Checklist (before any change)
|
## Security Checklist (before any change)
|
||||||
- [ ] No SQL interpolation — only `$1`, `$2`...
|
- [ ] No SQL interpolation — only `$1`, `$2`...
|
||||||
- [ ] Upload path validation via `isSafeUploadPath` / `safeUnlink`
|
- [ ] Upload path validation via `isSafeUploadPath` / `safeUnlink`
|
||||||
|
- [ ] File I/O через `storage.*`, ключи объектов не выходят за пределы бакета/`uploads/`
|
||||||
- [ ] Rate limiter on new public routes
|
- [ ] Rate limiter on new public routes
|
||||||
- [ ] Admin routes behind `requireAdmin`
|
- [ ] Admin routes behind `requireAdmin`
|
||||||
|
- [ ] New auth paths checked against the contract in `api.smoketest.js`, docs updated in the same change
|
||||||
- [ ] No secrets in code — only via env vars
|
- [ ] No secrets in code — only via env vars
|
||||||
- [ ] Helmet headers present (already global)
|
- [ ] Helmet headers present (already global)
|
||||||
- [ ] CORS disabled (no `cors` middleware)
|
- [ ] CORS disabled (no `cors` middleware)
|
||||||
@@ -141,15 +236,24 @@ curl -H "X-Admin-Token: $ADMIN_PASSWORD" http://localhost:3003/api/groups
|
|||||||
| File | Purpose |
|
| File | Purpose |
|
||||||
|------|---------|
|
|------|---------|
|
||||||
| `server.js` | Entire backend (Express, routes, DB, uploads, backup) |
|
| `server.js` | Entire backend (Express, routes, DB, uploads, backup) |
|
||||||
| `worker.js` | Background AI auto-check worker for entry messages |
|
| `storage.js` | Storage abstraction: `local` and `s3` drivers, key normalization, cache/thumb helpers |
|
||||||
|
| `redis.js` | Redis abstraction: cache, counters, rate-limit store, pub/sub, in-memory fallback |
|
||||||
|
| `redis.selftest.js` | Self-tests for `redis.js`, including behaviour with Redis unavailable |
|
||||||
|
| `diff.js` / `diff.selftest.js` | Word-level text diff and audit change payload; self-tests |
|
||||||
|
| `api.smoketest.js` | End-to-end API smoke test against a running stack |
|
||||||
|
| `worker.js` | Background AI auto-check worker for entry messages + photo enhance worker |
|
||||||
| `db/init.sql` | Initial schema (runs on fresh DB) |
|
| `db/init.sql` | Initial schema (runs on fresh DB) |
|
||||||
| `db/migration.sql` | Idempotent migrations for existing DBs |
|
| `db/migration.sql` | Idempotent migrations for existing DBs |
|
||||||
| `docker-compose.yml` | Service definitions (app, db, tailscale) |
|
| `docker-compose.yml` | Service definitions (app, db, s3, tailscale) |
|
||||||
|
| `docker-compose.minio.yml` | Override: S3 service backed by MinIO instead of SeaweedFS |
|
||||||
| `Dockerfile` | App image build |
|
| `Dockerfile` | App image build |
|
||||||
| `public/*.html` | Frontend pages |
|
| `public/*.html` | Frontend pages |
|
||||||
| `public/admin.js` | Shared frontend logic |
|
| `public/admin.js` | Shared frontend logic |
|
||||||
| `scripts/backup.sh` | Host-level backup script |
|
| `scripts/backup.sh` | Host-level backup script (DB dump + storage export) |
|
||||||
| `scripts/restore.sh` | Host-level restore script |
|
| `scripts/restore.sh` | Host-level restore script (DB dump + storage import) |
|
||||||
|
| `scripts/storage-sync.js` | Export/import all storage objects (used by backup/restore) |
|
||||||
|
| `scripts/migrate-to-s3.js` | One-off/idempotent migration `uploads/` -> S3 bucket |
|
||||||
|
| `scripts/deploy.sh` | Deploy script (pull master, build image with commit version, restart app) |
|
||||||
| `start-tailscale.sh` | Tailscale container entrypoint |
|
| `start-tailscale.sh` | Tailscale container entrypoint |
|
||||||
| `.env.example` | Env var template |
|
| `.env.example` | Env var template |
|
||||||
|
|
||||||
@@ -159,6 +263,14 @@ curl -H "X-Admin-Token: $ADMIN_PASSWORD" http://localhost:3003/api/groups
|
|||||||
|
|
||||||
- ❌ Add dependencies without updating `package.json` and rebuilding
|
- ❌ Add dependencies without updating `package.json` and rebuilding
|
||||||
- ❌ Write files outside `uploads/` or `certs/`
|
- ❌ Write files outside `uploads/` or `certs/`
|
||||||
|
- ❌ Touch `uploads/` with `fs.*` in request/worker code — use `storage.*` (files may live only in S3)
|
||||||
|
- ❌ Run `migrate-to-s3.js --delete-local` before verification and cutover
|
||||||
|
- ❌ Expose the S3 API port publicly (only `127.0.0.1` in compose)
|
||||||
|
- ❌ Expose the Redis port publicly (only `127.0.0.1` in compose)
|
||||||
|
- ❌ Call `fs.*`/`pg` directly for cache, counters or pub/sub — use `redis.js`
|
||||||
|
- ❌ Make Redis a hard dependency: any new Redis-backed path must keep the in-memory fallback
|
||||||
|
- ❌ `await client.connect()` without a timeout — it never rejects while Redis is unreachable
|
||||||
|
- ❌ Cache authorization-relevant data without an invalidation path on the mutation
|
||||||
- ❌ Commit `.env`, `certs/`, `uploads/`, `backups/`, `node_modules/`
|
- ❌ Commit `.env`, `certs/`, `uploads/`, `backups/`, `node_modules/`
|
||||||
- ❌ Expose DB port (5432) outside docker network
|
- ❌ Expose DB port (5432) outside docker network
|
||||||
- ❌ Use `eval`, `Function` constructor, or dynamic code execution
|
- ❌ Use `eval`, `Function` constructor, or dynamic code execution
|
||||||
@@ -172,12 +284,24 @@ curl -H "X-Admin-Token: $ADMIN_PASSWORD" http://localhost:3003/api/groups
|
|||||||
# Full rebuild
|
# Full rebuild
|
||||||
docker compose down && docker compose up -d --build
|
docker compose down && docker compose up -d --build
|
||||||
|
|
||||||
|
# Обновление на сервере (pull master + сборка образа с версией коммита + перезапуск app)
|
||||||
|
./scripts/deploy.sh
|
||||||
|
|
||||||
# App logs
|
# App logs
|
||||||
docker compose logs -f app
|
docker compose logs -f app
|
||||||
|
|
||||||
# DB shell
|
# DB shell
|
||||||
docker compose exec db psql -U app -d whereldo
|
docker compose exec db psql -U app -d whereldo
|
||||||
|
|
||||||
|
# Redis status and cache keys
|
||||||
|
docker compose exec redis redis-cli -a "$REDIS_PASSWORD" --no-auth-warning DBSIZE
|
||||||
|
docker compose exec redis redis-cli -a "$REDIS_PASSWORD" --no-auth-warning KEYS 'whatido:*'
|
||||||
|
|
||||||
|
# S3 storage status and migration verification
|
||||||
|
docker compose up -d s3
|
||||||
|
docker compose exec -T app node scripts/migrate-to-s3.js --verify-only
|
||||||
|
docker compose exec -T app node scripts/migrate-to-s3.js --delete-local
|
||||||
|
|
||||||
# Tailscale status
|
# Tailscale status
|
||||||
docker exec -it whatido-tailscale-1 tailscale status
|
docker exec -it whatido-tailscale-1 tailscale status
|
||||||
|
|
||||||
|
|||||||
+4
-1
@@ -1,10 +1,13 @@
|
|||||||
FROM node:20-alpine
|
FROM node:22-alpine
|
||||||
ENV TZ=Europe/Moscow
|
ENV TZ=Europe/Moscow
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package.json package-lock.json* ./
|
COPY package.json package-lock.json* ./
|
||||||
RUN npm install --omit=dev
|
RUN npm install --omit=dev
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN mkdir -p uploads certs
|
RUN mkdir -p uploads certs
|
||||||
|
ARG GIT_COMMIT=""
|
||||||
|
ARG GIT_COMMIT_DATE=""
|
||||||
|
RUN node -e "require('fs').writeFileSync('public/version.json', JSON.stringify({ full: process.env.GIT_COMMIT || '', short: (process.env.GIT_COMMIT || '').slice(0, 7), date: process.env.GIT_COMMIT_DATE || '' }, null, 2))"
|
||||||
RUN set -e; \
|
RUN set -e; \
|
||||||
V="v$(cut -d. -f1-2 /etc/alpine-release)"; \
|
V="v$(cut -d. -f1-2 /etc/alpine-release)"; \
|
||||||
try_repo() { \
|
try_repo() { \
|
||||||
|
|||||||
@@ -182,7 +182,9 @@ branches
|
|||||||
| GET | `/api/share/:token` | Public | Share page data |
|
| GET | `/api/share/:token` | Public | Share page data |
|
||||||
| GET | `/api/links` | Admin | List share links |
|
| GET | `/api/links` | Admin | List share links |
|
||||||
| POST/PUT/DELETE | `/api/links` | Admin | CRUD share links |
|
| POST/PUT/DELETE | `/api/links` | Admin | CRUD share links |
|
||||||
| GET | `/api/backup` | Admin | Download backup |
|
| GET | `/api/backup` | Admin | Build & download backup (compat) |
|
||||||
|
| POST | `/api/backup` | Admin | Build backup, returns download URL |
|
||||||
|
| GET | `/api/backup/:token` | Token | Download built backup (resumable) |
|
||||||
| POST | `/api/restore` | Admin | Upload & restore backup |
|
| POST | `/api/restore` | Admin | Upload & restore backup |
|
||||||
| GET | `/api/dashboard` | Admin | Dashboard data |
|
| GET | `/api/dashboard` | Admin | Dashboard data |
|
||||||
| GET | `/api/stats` | Admin | Stats cards |
|
| GET | `/api/stats` | Admin | Stats cards |
|
||||||
|
|||||||
@@ -12,7 +12,9 @@
|
|||||||
- **Share-ссылки** — публичные страницы-витрины с выбором группы / воспитанника / диапазона дат
|
- **Share-ссылки** — публичные страницы-витрины с выбором группы / воспитанника / диапазона дат
|
||||||
- **Дашборд** — статистика, активные группы, активность за 14 дней, последние записи, топ воспитанников
|
- **Дашборд** — статистика, активные группы, активность за 14 дней, последние записи, топ воспитанников
|
||||||
- **Резервное копирование** — экспорт/импорт полного дампа (БД + файлы) в `tar.gz`
|
- **Резервное копирование** — экспорт/импорт полного дампа (БД + файлы) в `tar.gz`
|
||||||
- **Настройки** — тексты футера, анти-спам интервал
|
- **Хранилище файлов** — локальный каталог `uploads/` или S3-совместимый сервис (`s3`: SeaweedFS, либо MinIO через оверрайд), перенос файлов скриптом миграции
|
||||||
|
- **Настройки** — тексты футера, анти-спам интервал, системная информация (объёмы БД и хранилища) и «Статус стека»: версии Node.js/Express/PostgreSQL/Redis, состояние сервисов, ОС, CPU, память и аптаймы (`GET /api/system-info` → `stack`)
|
||||||
|
- **Уведомления** — системные события (новые записи журнала, обработка фото нейросетью, ошибки авто-проверки текста, блокировки IP, бэкапы) собираются в «колокольчике» и на странице «Уведомления»; набор событий включается/выключается в «Настройках» → «Уведомления»
|
||||||
- **Публикация через Tailscale** — приложение открывается по постоянному адресу `https://whatido.<tailnet>.ts.net` без проброса портов, внешнего IP и reverse-proxy
|
- **Публикация через Tailscale** — приложение открывается по постоянному адресу `https://whatido.<tailnet>.ts.net` без проброса портов, внешнего IP и reverse-proxy
|
||||||
|
|
||||||
## Технологии
|
## Технологии
|
||||||
@@ -20,6 +22,8 @@
|
|||||||
- Node.js + Express
|
- Node.js + Express
|
||||||
- PostgreSQL (pg)
|
- PostgreSQL (pg)
|
||||||
- Multer (загрузка файлов), Tar (бэкапы)
|
- Multer (загрузка файлов), Tar (бэкапы)
|
||||||
|
- S3-совместимое хранилище (AWS SDK v3): сервис `s3` (SeaweedFS / MinIO)
|
||||||
|
- Redis: кэш, rate limit, баны IP, кэш сессий, pub/sub (SSE и воркеры)
|
||||||
- Lucide (иконки UI)
|
- Lucide (иконки UI)
|
||||||
- Docker / Docker Compose
|
- Docker / Docker Compose
|
||||||
- Tailscale (Serve / Funnel) — публикация по HTTPS
|
- Tailscale (Serve / Funnel) — публикация по HTTPS
|
||||||
@@ -48,6 +52,7 @@ docker compose up -d --build
|
|||||||
- **HTTP** `http://localhost:3003` — редирект на HTTPS
|
- **HTTP** `http://localhost:3003` — редирект на HTTPS
|
||||||
- **HTTPS** `https://localhost:3443` — приложение (самоподписанный сертификат, примите предупреждение браузера)
|
- **HTTPS** `https://localhost:3443` — приложение (самоподписанный сертификат, примите предупреждение браузера)
|
||||||
- **PostgreSQL** — доступен только внутри docker-сети (наружу не публикуется)
|
- **PostgreSQL** — доступен только внутри docker-сети (наружу не публикуется)
|
||||||
|
- **Redis** — `127.0.0.1:6379` на хосте (только loopback), внутри сети — `redis:6379`
|
||||||
|
|
||||||
Управление:
|
Управление:
|
||||||
|
|
||||||
@@ -59,6 +64,35 @@ docker compose down # остановка (данные сохраняю
|
|||||||
|
|
||||||
> Приложение **не запустится** без `ADMIN_PASSWORD` (защита от пароля по умолчанию).
|
> Приложение **не запустится** без `ADMIN_PASSWORD` (защита от пароля по умолчанию).
|
||||||
> `DB_PASSWORD` задаёт пароль пользователя `app` в PostgreSQL.
|
> `DB_PASSWORD` задаёт пароль пользователя `app` в PostgreSQL.
|
||||||
|
> `REDIS_PASSWORD` задаёт пароль Redis. Если сервис `redis` убрать из `docker-compose.yml`
|
||||||
|
> или оставить `REDIS_URL` пустым — приложение продолжит работать на in-memory кэше.
|
||||||
|
|
||||||
|
## Обновление на сервере (деплой)
|
||||||
|
|
||||||
|
Код приложения находится внутри образа: bind-монтируется только `uploads/`. Поэтому после `git pull` нужна **пересборка образа** — `docker compose up -d` без `--build` и `docker compose restart` новый `server.js` и статику не подхватят.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/deploy.sh # ветка master (либо $DEPLOY_BRANCH, либо первый аргумент)
|
||||||
|
```
|
||||||
|
|
||||||
|
Скрипт проверяет рабочую копию, обновляет ветку (`fetch` + `checkout` + `pull --ff-only`), собирает образ с версией коммита, перезапускает `app`, затем сверяет `server.js` в контейнере с рабочей копией и печатает `/version.json`.
|
||||||
|
|
||||||
|
Вручную то же самое:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git checkout master && git pull --ff-only origin master
|
||||||
|
docker compose build --build-arg GIT_COMMIT=$(git rev-parse HEAD) --build-arg GIT_COMMIT_DATE=$(git log -1 --format=%cI) app
|
||||||
|
docker compose up -d app
|
||||||
|
```
|
||||||
|
|
||||||
|
Проверка:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sk https://127.0.0.1:3443/version.json # версия собранного коммита
|
||||||
|
docker compose exec app md5sum /app/server.js # совпадает с md5sum server.js
|
||||||
|
```
|
||||||
|
|
||||||
|
Версия сборки записывается в `public/version.json` внутри образа из аргументов `GIT_COMMIT` / `GIT_COMMIT_DATE` (`build.args` в `docker-compose.yml`, подставляет `scripts/deploy.sh`) и показывается в сайдбаре админки — она всегда соответствует собранному коду, даже если файл в рабочей копии устарел. Локально файл обновляет хук: `cp scripts/post-commit.sh .git/hooks/post-commit`. Если образ собран без аргументов (`docker compose build` вместо `deploy.sh`), версия в сайдбаре будет пустой.
|
||||||
|
|
||||||
## Конфигурация
|
## Конфигурация
|
||||||
|
|
||||||
@@ -66,14 +100,19 @@ docker compose down # остановка (данные сохраняю
|
|||||||
|
|
||||||
| Переменная | По умолчанию | Назначение |
|
| Переменная | По умолчанию | Назначение |
|
||||||
|------------------|--------------------|-------------------------------------|
|
|------------------|--------------------|-------------------------------------|
|
||||||
| `ADMIN_PASSWORD` | — (обязательно) | Пароль администратора (X-Admin-Token). Без него сервер не стартует |
|
| `ADMIN_PASSWORD` | — (обязательно) | Пароль первого администратора, создаётся в пустой БД. Не является способом авторизации в API |
|
||||||
|
| `ADMIN_USERNAME` | `admin` | Логин первого администратора |
|
||||||
| `DB_PASSWORD` | — (обязательно) | Пароль пользователя `app` в PostgreSQL |
|
| `DB_PASSWORD` | — (обязательно) | Пароль пользователя `app` в PostgreSQL |
|
||||||
|
| `REDIS_PASSWORD` | — (обязательно) | Пароль Redis (`--requirepass`) |
|
||||||
|
| `REDIS_PREFIX` | `whatido` | Префикс ключей Redis — свой для каждого инстанса |
|
||||||
|
| `REDIS_MAXMEMORY` | `256mb` | Лимит памяти Redis, при переполнении вытесняется LRU |
|
||||||
|
|
||||||
Пример `.env` (в репозитории — `.env.example`):
|
Пример `.env` (в репозитории — `.env.example`):
|
||||||
|
|
||||||
```
|
```
|
||||||
ADMIN_PASSWORD=сложный-пароль
|
ADMIN_PASSWORD=сложный-пароль
|
||||||
DB_PASSWORD=случайная-длинная-строка
|
DB_PASSWORD=случайная-длинная-строка
|
||||||
|
REDIS_PASSWORD=случайная-длинная-строка
|
||||||
```
|
```
|
||||||
|
|
||||||
`DB_PASSWORD` подставляется в `docker-compose.yml` в `POSTGRES_PASSWORD` и `DATABASE_URL`. Если БД уже была инициализирована ранее, значение `DB_PASSWORD` должно совпадать с фактическим паролем пользователя `app` в БД (иначе приложение не подключится).
|
`DB_PASSWORD` подставляется в `docker-compose.yml` в `POSTGRES_PASSWORD` и `DATABASE_URL`. Если БД уже была инициализирована ранее, значение `DB_PASSWORD` должно совпадать с фактическим паролем пользователя `app` в БД (иначе приложение не подключится).
|
||||||
@@ -212,20 +251,186 @@ docker compose exec cloudflared wg show # есть handshake — VPN подн
|
|||||||
- `group_photos` — фотохроника групп
|
- `group_photos` — фотохроника групп
|
||||||
- `share_links` — публичные ссылки-витрины
|
- `share_links` — публичные ссылки-витрины
|
||||||
- `settings` — пары ключ/значение (анти-спам интервал, футер)
|
- `settings` — пары ключ/значение (анти-спам интервал, футер)
|
||||||
|
- `audit_log` — журнал действий (`action`, `target` JSONB, `ip`, `user_id`)
|
||||||
|
|
||||||
Схема инициализируется при первом запуске из `db/init.sql`; миграции существующей БД — в `db/migration.sql`.
|
Схема инициализируется при первом запуске из `db/init.sql`; миграции существующей БД — в `db/migration.sql`.
|
||||||
|
|
||||||
|
## Аудит изменений текста
|
||||||
|
|
||||||
|
Каждое сохранение записи журнала (`PUT /api/entries/:id`) сравнивает состояние «до» и «после» и пишет в `audit_log` не только факт, но и сами изменения:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 363,
|
||||||
|
"source": "ai",
|
||||||
|
"changed": true,
|
||||||
|
"fields": ["description", "group_id"],
|
||||||
|
"changes": [
|
||||||
|
{ "field": "description", "label": "Текст работы",
|
||||||
|
"stats": { "added_words": 5, "removed_words": 2, "chars_before": 75, "chars_after": 97 },
|
||||||
|
"diff": [{ "type": "del", "text": "учитель" }, { "type": "add", "text": "очень " }] },
|
||||||
|
{ "field": "group_id", "label": "Группа", "before": "4 · Суббота 9:00", "after": "5 · Суббота 11:30" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `source` — источник правки: `manual` (вручную), `ai` (текст принят из подсказки ИИ), `ai_manual` (ИИ + ручная правка), `ai_revert` (откат к оригиналу)
|
||||||
|
- `diff` — пословный дифф (`eq` / `del` / `add`) с подсветкой в интерфейсе: удалённое зачёркнуто, добавленное выделено
|
||||||
|
- `stats` — сколько слов и символов добавлено и удалено на этом шаге
|
||||||
|
- те же данные пишутся для автопроверки ИИ (`entry.ai.auto-check`) и отката (`entry.ai.revert`)
|
||||||
|
|
||||||
|
Список `GET /api/audit` отдаёт облегчённый `target` (без `diff`), полный — `GET /api/audit/:id`: страница «Аудит» подгружает его при открытии деталей.
|
||||||
|
|
||||||
|
Пословный дифф и сборка изменений вынесены в `diff.js` (без зависимостей, с обрезкой слишком больших текстов), тесты — `node diff.selftest.js`.
|
||||||
|
|
||||||
## Хранилище файлов
|
## Хранилище файлов
|
||||||
|
|
||||||
Загруженные фото и файлы хранятся в каталоге `uploads/` на хосте и монтируются в контейнер (`./uploads:/app/uploads`). Это даёт прямой доступ к данным из-под хост-системы. Данные БД хранятся в именованном томе `pgdata`.
|
По умолчанию загруженные фото и файлы хранятся в каталоге `uploads/` на хосте и монтируются в контейнер (`./uploads:/app/uploads`) — это драйвер `local`. Данные БД хранятся в именованном томе `pgdata`.
|
||||||
|
|
||||||
|
Дополнительно поддерживается **S3-совместимое хранилище** (сервис `s3` в compose, драйвер `s3`). Все обращения к файлам идут через приложение: URL (`/uploads/...`, `/uploads/thumb/...`, `/api/files/:token`, share-ссылки) и записи в БД (`/uploads/<файл>`) не меняются, поэтому переключение драйвера не требует миграции данных в БД.
|
||||||
|
|
||||||
|
### Сервис `s3`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d s3 # поднимает S3-хранилище (том s3-data)
|
||||||
|
```
|
||||||
|
|
||||||
|
- **По умолчанию — SeaweedFS** (`chrislusf/seaweedfs`): свободный S3-сервер; API слушает `127.0.0.1:9000` на хосте и `s3:9000` внутри compose-сети.
|
||||||
|
- **MinIO**: официальные свободные образы `minio/minio` удалены из Docker Hub, поэтому MinIO подключается через оверрайд и образ из доступного вам зеркала:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
S3_IMAGE=<ваш-образ-minio> docker compose -f docker-compose.yml -f docker-compose.minio.yml up -d s3
|
||||||
|
```
|
||||||
|
|
||||||
|
Бакет создаётся автоматически при старте приложения (`ensureBucket`) или скриптом миграции. Анонимный доступ к API хранилища закрыт: порт `9000` не публикуется наружу (только loopback), доступ к файлам остаётся через приложение с его аутентификацией и rate limit.
|
||||||
|
|
||||||
|
### Переменные окружения
|
||||||
|
|
||||||
|
| Переменная | По умолчанию | Назначение |
|
||||||
|
|---|---|---|
|
||||||
|
| `STORAGE_DRIVER` | `local` | `local` — файлы в `uploads/`, `s3` — объекты в бакете |
|
||||||
|
| `S3_ENDPOINT` | `http://s3:9000` | Адрес S3 API внутри compose-сети |
|
||||||
|
| `S3_BUCKET` | `whatido` | Бакет для объектов |
|
||||||
|
| `S3_ACCESS_KEY` / `S3_SECRET_KEY` | `whatido` / — | Доступ к хранилищу (для MinIO это root-пользователь) |
|
||||||
|
| `S3_FORCE_PATH_STYLE` | `1` | Path-style адресация (нужна MinIO/SeaweedFS) |
|
||||||
|
| `S3_PREFIX` | — | Необязательный префикс ключей внутри бакета |
|
||||||
|
| `STORAGE_LOCAL_FALLBACK` | `1` | Читать локальный файл, если объекта в S3 ещё нет |
|
||||||
|
| `STORAGE_KEEP_LOCAL` | `0` | Оставлять локальную копию после выгрузки в S3 |
|
||||||
|
| `STORAGE_CACHE_MAX_AGE_HOURS` | `168` | Срок жизни локального кэша оригиналов (для sharp/миниатюр) |
|
||||||
|
|
||||||
|
### Переход на S3 (миграция)
|
||||||
|
|
||||||
|
Порядок не прерывает работу: файлы сначала копируются в бакет, локальные остаются на месте и продолжают использоваться.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1) поднять хранилище
|
||||||
|
docker compose up -d s3
|
||||||
|
|
||||||
|
# 2) предпросмотр и загрузка файлов в бакет (идемпотентно, по размеру объекта)
|
||||||
|
docker compose exec -T app node scripts/migrate-to-s3.js --dry-run
|
||||||
|
docker compose exec -T app node scripts/migrate-to-s3.js
|
||||||
|
|
||||||
|
# 3) проверить, что все объекты на месте (ничего не меняет)
|
||||||
|
docker compose exec -T app node scripts/migrate-to-s3.js --verify-only
|
||||||
|
```
|
||||||
|
|
||||||
|
Дальше включить драйвер `s3` и перезапустить приложение:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# в .env: STORAGE_DRIVER=s3
|
||||||
|
docker compose up -d app
|
||||||
|
```
|
||||||
|
|
||||||
|
Новые загрузки уходят в бакет (локальная копия удаляется, если `STORAGE_KEEP_LOCAL=0`), старые файлы ещё читаются из `uploads/` благодаря `STORAGE_LOCAL_FALLBACK=1`. Когда всё проверено — удалите локальные копии:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec -T app node scripts/migrate-to-s3.js --delete-local
|
||||||
|
```
|
||||||
|
|
||||||
|
Откат в любой момент: `STORAGE_DRIVER=local` + `docker compose up -d app` (пока локальные копии не удалены).
|
||||||
|
|
||||||
|
Объём и состав хранилища видны в админке: Настройки → Системная информация (блок «Хранилище»).
|
||||||
|
|
||||||
|
## Redis (кэш и pub/sub)
|
||||||
|
|
||||||
|
Сервис `redis` в compose хранит всё, что не требуется переживать перезапуск Postgres, но должно
|
||||||
|
быть общим и быстрым:
|
||||||
|
|
||||||
|
| Что | Ключи | TTL |
|
||||||
|
|---|---|---|
|
||||||
|
| Кэш ответов API и настроек | `setting:*`, `groups:*`, `students:*`, `entries:*`, `stats:*`, `dashboard:*`, `share:payload:*`, `public-settings`, `system-info` | 15–60 с |
|
||||||
|
| Кэш сессий | `session:<token>` | 30 с |
|
||||||
|
| Счётчики rate limit | `rl:api:*`, `rl:entry:*`, `rl:file:*` | окно окна + 10 % |
|
||||||
|
| Баны IP | `ban:<ip>` | до `banned_until` |
|
||||||
|
| Счётчики неудачных попыток входа | `fail:<kind>:<ip>` | 15 мин |
|
||||||
|
|
||||||
|
Инвалидация кэша — по префиксу (`SCAN` + `DEL`), поэтому после правки настроек, группы или записи
|
||||||
|
новое значение видно сразу. Правки пользователей сбрасывают `session:*`, так что деактивация
|
||||||
|
аккаунта и выход из сессии действуют немедленно.
|
||||||
|
|
||||||
|
Через pub/sub каналы `whatido:events`, `whatido:wake:ai` и `whatido:wake:photo` доставляют SSE-события
|
||||||
|
клиентам и будят фоновых воркеров без ожидания цикла опроса БД.
|
||||||
|
|
||||||
|
### Отказоустойчивость
|
||||||
|
|
||||||
|
Если Redis недоступен, приложение **не падает**: `redis.js` прозрачно переключается на
|
||||||
|
in-memory кэш (та же семантика и те же ключи) и возвращается в Redis автоматически, как только
|
||||||
|
сервис поднимется. Первое подключение ограничено таймаутом `REDIS_CONNECT_TIMEOUT_MS` (5 с по
|
||||||
|
умолчанию), поэтому недоступный Redis не задержит старт приложения. Текущее состояние видно в
|
||||||
|
`GET /api/system-info` → `cache.driver` (`redis` или `memory`) и в блоке «Кэш» на странице
|
||||||
|
Настроек → Стек (там же — «нет связи — в памяти», если Redis не отвечает).
|
||||||
|
|
||||||
|
### Команды
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d redis # поднять только Redis
|
||||||
|
docker compose exec redis redis-cli -a "$REDIS_PASSWORD" --no-auth-warning INFO
|
||||||
|
docker compose exec redis redis-cli -a "$REDIS_PASSWORD" --no-auth-warning DBSIZE
|
||||||
|
docker compose exec redis redis-cli -a "$REDIS_PASSWORD" --no-auth-warning KEYS 'whatido:*'
|
||||||
|
docker compose exec redis redis-cli -a "$REDIS_PASSWORD" --no-auth-warning TTL 'whatido:public-settings'
|
||||||
|
```
|
||||||
|
|
||||||
|
Данные Redis сохраняются в томе `redis-data` (AOF, `appendfsync everysec`), поэтому кэш и счётчики
|
||||||
|
переживают перезапуск контейнера. Порт `6379` публикуется только на `127.0.0.1`.
|
||||||
|
|
||||||
|
Проверка слоя Redis (включая поведение при недоступном сервере):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node redis.selftest.js # юнит-тесты redis.js
|
||||||
|
node api.smoketest.js # сквозная проверка API (нужен запущенный стек)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Уведомления
|
||||||
|
|
||||||
|
Система уведомлений — журнал событий (`notifications`) с отметками прочтения на пользователя (`notification_reads`) плюс каталог типов событий `NOTIFY_TYPES` в `server.js`.
|
||||||
|
|
||||||
|
| Тип | Событие | Кому видно |
|
||||||
|
|-----|---------|------------|
|
||||||
|
| `entry.new` | новая запись в журнале (форма ученика или ручное добавление) | филиал группы |
|
||||||
|
| `entry.ai.corrected` | ИИ исправил текст (по умолчанию выключено) | филиал группы |
|
||||||
|
| `entry.ai.error` | авто-проверка текста не удалась | филиал группы |
|
||||||
|
| `photo.job.done` | фото обработано нейросетью или сервером | филиал группы |
|
||||||
|
| `photo.job.error` | очередь обработки фото исчерпала попытки | филиал группы |
|
||||||
|
| `ip.ban` | IP отправлен в бан (авто или вручную) | только админ |
|
||||||
|
| `backup.restore` | восстановление из бэкапа | только админ |
|
||||||
|
| `backup.create` | создан архив бэкапа (по умолчанию выключено) | только админ |
|
||||||
|
|
||||||
|
Где видно: «колокольчик» в боковом меню (панель последних событий, бейдж непрочитанных, опциональные уведомления браузера) и страница `notifications.html` (фильтр «непрочитанные», отметка «прочитано», удаление и полная очистка для админа). Новые события приходят в реальном времени по SSE (`GET /api/notifications/stream`), транспорт — Redis pub/sub с in-memory fallback.
|
||||||
|
|
||||||
|
Что настраивается в «Настройках» → «Уведомления» (ключи таблицы `settings`): общий выключатель `notify_enabled`, срок хранения `notify_retention_days` (1–365 дней, старые уведомления удаляются ежечасно) и отдельный переключатель `notify_<тип>` для каждого события. Там же кнопка тестового уведомления.
|
||||||
|
|
||||||
|
Видимость: администратор видит все уведомления, остальные — только события своего филиала (или без филиала) и никогда — события с пометкой `admin_only`.
|
||||||
|
|
||||||
## Бэкапы
|
## Бэкапы
|
||||||
|
|
||||||
|
|
||||||
В админке (Настройки → Бэкап) можно:
|
В админке (Настройки → Бэкап) можно:
|
||||||
|
|
||||||
- Скачать полный бэкап — `tar.gz`, содержащий `data.json` (все таблицы) и `uploads/`
|
- Скачать полный бэкап — `tar.gz`, содержащий `data.json` (все таблицы) и `uploads/`
|
||||||
- Восстановить из файла бэкапа
|
- Восстановить из файла бэкапа
|
||||||
|
|
||||||
|
Архив формируется на сервере (`POST /api/backup`) и скачивается браузером по одноразовой ссылке (`GET /api/backup/<token>`, действует 30 минут) — загрузку можно возобновить при обрыве связи. Совместимый эндпоинт `GET /api/backup` отдаёт тот же архив сразу.
|
||||||
|
|
||||||
Также доступны скрипты на хосте:
|
Также доступны скрипты на хосте:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -233,9 +438,13 @@ docker compose exec cloudflared wg show # есть handshake — VPN подн
|
|||||||
./scripts/restore.sh # восстановление из архива
|
./scripts/restore.sh # восстановление из архива
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Форматы не взаимозаменяемы: скриптовый архив содержит `db.sql.gz` + `_uploads/` (перенос на другой хост через `scripts/restore.sh`), а веб-архив из админки — `data.json` + `uploads/` (кнопка «Восстановить»). Если в админку загрузить скриптовый архив, сервер вернёт подсказку, какой инструмент использовать.
|
||||||
|
|
||||||
|
Файлы попадают в бэкап из активного хранилища: при `STORAGE_DRIVER=s3` админ-бэкап и `scripts/backup.sh` выгружают объекты из бакета (`scripts/storage-sync.js export`), а восстановление загружает их обратно (`scripts/storage-sync.js import`). Миниатюры (`.thumbs`) в архив не включаются — они пересоздаются по запросу.
|
||||||
|
|
||||||
## Безопасность
|
## Безопасность
|
||||||
|
|
||||||
- **Пароль администратора** обязателен (`ADMIN_PASSWORD`); фолбэка на `admin` нет.
|
- **Пароль администратора** обязателен (`ADMIN_PASSWORD`) — он создаёт первого админа в пустой БД; фолбэка на `admin` нет. Самостоятельной роли в API не даёт: доступ только по сессиям.
|
||||||
- **CORS отключён** — кросс-доменные запросы к API запрещены.
|
- **CORS отключён** — кросс-доменные запросы к API запрещены.
|
||||||
- **Rate limiting** по IP на публичные роуты: `POST /api/entries` — 10 запросов / 15 мин, загрузка файлов и share-ссылки — 300 / 15 мин.
|
- **Rate limiting** по IP на публичные роуты: `POST /api/entries` — 10 запросов / 15 мин, загрузка файлов и share-ссылки — 300 / 15 мин.
|
||||||
- **Загрузки** ограничены: 30 МБ суммарно на запись, 10 МБ на файл; заблокированы опасные расширения (`.html`, `.js`, `.svg`, `.xml`, `.exe` и др.); SVG не отдаётся inline.
|
- **Загрузки** ограничены: 30 МБ суммарно на запись, 10 МБ на файл; заблокированы опасные расширения (`.html`, `.js`, `.svg`, `.xml`, `.exe` и др.); SVG не отдаётся inline.
|
||||||
@@ -261,24 +470,57 @@ docker compose exec cloudflared wg show # есть handshake — VPN подн
|
|||||||
| `GET/POST/PUT/DELETE` | `/api/share/...`, `/api/links` | Публичные ссылки |
|
| `GET/POST/PUT/DELETE` | `/api/share/...`, `/api/links` | Публичные ссылки |
|
||||||
| `GET` | `/api/backup` | Скачать бэкап |
|
| `GET` | `/api/backup` | Скачать бэкап |
|
||||||
| `POST` | `/api/restore` | Восстановить из бэкапа |
|
| `POST` | `/api/restore` | Восстановить из бэкапа |
|
||||||
|
| `GET` | `/api/notifications` | Уведомления пользователя (`limit`, `offset`, `unread=1`) |
|
||||||
|
| `GET` | `/api/notifications/stream` | SSE-поток уведомлений (заголовок `X-Auth-Token` или `?token=`) |
|
||||||
|
| `GET` | `/api/notifications/meta` | Каталог типов событий и текущие переключатели (admin) |
|
||||||
|
| `POST` | `/api/notifications/:id/read`, `/api/notifications/read-all` | Отметить прочитанным |
|
||||||
|
| `POST` | `/api/notifications/test` | Тестовое уведомление (admin) |
|
||||||
|
| `DELETE` | `/api/notifications/:id`, `/api/notifications` | Удалить уведомление / очистить все (admin) |
|
||||||
| `GET` | `/api/dashboard`, `/api/stats` | Статистика |
|
| `GET` | `/api/dashboard`, `/api/stats` | Статистика |
|
||||||
|
|
||||||
Защищённые админ-маршруты требуют заголовок `X-Admin-Token` с `ADMIN_PASSWORD`.
|
Авторизация — по сессиям, не по статическому токену:
|
||||||
|
|
||||||
|
1. `POST /api/auth/login` с `username` и `password` возвращает `{ token, expires_at }`.
|
||||||
|
2. Токен передаётся в заголовке `X-Auth-Token` во все защищённые запросы; `POST /api/auth/logout` удаляет сессию.
|
||||||
|
3. `GET /api/auth/me` — текущий пользователь (`id`, `username`, `role`, `is_active`, `branch_ids`).
|
||||||
|
|
||||||
|
Заголовок `X-Admin-Token` больше не поддерживается. Маршруты помечены `requireAuth` (любой активный пользователь) или `requireAdmin` (только `role = admin`); филиалы не-admin ограничены его `user_branches`.
|
||||||
|
|
||||||
|
Защищённые маршруты:
|
||||||
|
|
||||||
|
| Метод | Путь | Доступ |
|
||||||
|
|---|---|---|
|
||||||
|
| `GET/POST/PUT/DELETE` | `/api/users`, `/api/users/:id` | admin |
|
||||||
|
| `GET/POST/DELETE` | `/api/bans` | admin |
|
||||||
|
| `GET/POST/PUT/DELETE` | `/api/branches`, `/api/branches/:id` | admin (список — любой активный) |
|
||||||
|
| `GET/POST/DELETE` | `/api/settings` | admin |
|
||||||
|
| `GET` | `/api/audit` | admin |
|
||||||
|
| `GET` | `/api/audit/:id` | admin (полный target с текстовым диффом) |
|
||||||
|
| `GET` | `/api/backup`, `POST /api/restore` | admin |
|
||||||
|
|
||||||
|
Сессия хранится в таблице `sessions` (срок 30 дней) и кэшируется в Redis на 30 секунд.
|
||||||
|
|
||||||
## Структура проекта
|
## Структура проекта
|
||||||
|
|
||||||
```
|
```
|
||||||
├── docker-compose.yml # сервисы: app + db + tailscale
|
├── docker-compose.yml # сервисы: app + db + redis + s3 (+ опционально tailscale)
|
||||||
|
├── docker-compose.minio.yml # оверрайд: S3-сервис на MinIO вместо SeaweedFS
|
||||||
├── .env.example # шаблон переменных окружения
|
├── .env.example # шаблон переменных окружения
|
||||||
├── Dockerfile # сборка образа (Node 20, генерация TLS-сертификата)
|
├── Dockerfile # сборка образа (Node 22, генерация TLS-сертификата)
|
||||||
├── server.js # Express-приложение
|
├── server.js # Express-приложение
|
||||||
├── worker.js # фоновый worker AI-проверки записей
|
├── storage.js # абстракция хранилища: драйверы local и s3
|
||||||
|
├── redis.js # абстракция Redis: кэш, счётчики, rate limit, pub/sub (с in-memory fallback)
|
||||||
|
├── redis.selftest.js # тесты слоя Redis, включая деградацию при недоступном сервере
|
||||||
|
├── diff.js # пословный diff текста и сборка изменений записи для аудита
|
||||||
|
├── diff.selftest.js # тесты diff.js (вставки, удаления, большие тексты, обрезка)
|
||||||
|
├── api.smoketest.js # сквозная проверка API по поднятому стеку
|
||||||
|
├── worker.js # фоновый worker AI-проверки и ИИ-улучшения фото
|
||||||
├── certs/ # cert.pem приложения (монтируется в tailscale, в git не хранится)
|
├── certs/ # cert.pem приложения (монтируется в tailscale, в git не хранится)
|
||||||
├── db/
|
├── db/
|
||||||
│ ├── init.sql # схема при первом запуске
|
│ ├── init.sql # схема при первом запуске
|
||||||
│ └── migration.sql # миграции существующей БД
|
│ └── migration.sql # миграции существующей БД
|
||||||
├── public/ # статика (HTML/CSS/JS админки и витрин)
|
├── public/ # статика (HTML/CSS/JS админки и витрин)
|
||||||
├── scripts/ # вспомогательные скрипты
|
├── scripts/ # вспомогательные скрипты (backup/restore/deploy, migrate-to-s3, storage-sync)
|
||||||
├── uploads/ # загруженные файлы (bind-монт, вне git)
|
├── uploads/ # локальные файлы и кэш миниатюр (bind-монт, вне git)
|
||||||
└── backups/ # локальные бэкапы
|
└── backups/ # локальные бэкапы
|
||||||
```
|
```
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
function loadEnv() {
|
||||||
|
const file = path.join(__dirname, '.env');
|
||||||
|
for (const line of fs.readFileSync(file, 'utf8').split('\n')) {
|
||||||
|
const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/);
|
||||||
|
if (m && !(m[1] in process.env)) process.env[m[1]] = m[2];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const BASE = process.env.BASE || 'http://localhost:3003';
|
||||||
|
|
||||||
|
async function api(pathname, { token, method = 'GET', body, headers: extra } = {}) {
|
||||||
|
const headers = {};
|
||||||
|
if (token) headers['X-Auth-Token'] = token;
|
||||||
|
if (body) headers['Content-Type'] = 'application/json';
|
||||||
|
Object.assign(headers, extra || {});
|
||||||
|
const res = await fetch(BASE + pathname, {
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
const text = await res.text();
|
||||||
|
let data = text;
|
||||||
|
try { data = JSON.parse(text); } catch (e) {}
|
||||||
|
return { status: res.status, data, headers: res.headers };
|
||||||
|
}
|
||||||
|
function ok(label, cond, extra) {
|
||||||
|
console.log(`${cond ? 'PASS' : 'FAIL'} ${label}${extra !== undefined ? ' -> ' + JSON.stringify(extra) : ''}`);
|
||||||
|
if (!cond) process.exitCode = 1;
|
||||||
|
return cond;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
loadEnv();
|
||||||
|
const user = process.env.ADMIN_USERNAME || 'admin';
|
||||||
|
const pass = process.env.ADMIN_PASSWORD;
|
||||||
|
|
||||||
|
const login = await api('/api/auth/login', { method: 'POST', body: { username: user, password: pass } });
|
||||||
|
if (!ok('login', login.status === 200 && login.data.token, login.status)) {
|
||||||
|
console.log(JSON.stringify(login.data).slice(0, 300));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const token = login.data.token;
|
||||||
|
|
||||||
|
const me1 = await api('/api/auth/me', { token });
|
||||||
|
ok('auth/me', me1.status === 200 && me1.data.id > 0 && me1.data.is_active === true, { status: me1.status, id: me1.data && me1.data.id });
|
||||||
|
|
||||||
|
// Контракт авторизации. Держим в синхроне с AGENTS.md/README: доступ даёт только
|
||||||
|
// X-Auth-Token с токеном сессии. Никакой статический токен (в т.ч. ранее
|
||||||
|
// документированный X-Admin-Token = ADMIN_PASSWORD) доступа не даёт, и
|
||||||
|
// ADMIN_PASSWORD не является паролем для входа, кроме случая пустой БД,
|
||||||
|
// где он задаётся через login.
|
||||||
|
const PROTECTED = '/api/auth/me';
|
||||||
|
const ADMIN_ONLY = '/api/users';
|
||||||
|
const noAuth = await api(PROTECTED);
|
||||||
|
ok('auth: защищённый маршрут без токена -> 401', noAuth.status === 401, noAuth.status);
|
||||||
|
const garbage = await api(PROTECTED, { token: 'deadbeef' });
|
||||||
|
ok('auth: мусорный X-Auth-Token -> 401', garbage.status === 401, garbage.status);
|
||||||
|
const legacy = await api(PROTECTED, { headers: { 'X-Admin-Token': pass } });
|
||||||
|
ok('auth: X-Admin-Token не авторизует -> 401', legacy.status === 401, legacy.status);
|
||||||
|
const bearer = await api(PROTECTED, { headers: { Authorization: 'Bearer ' + token } });
|
||||||
|
ok('auth: Authorization Bearer не поддерживается -> 401', bearer.status === 401, bearer.status);
|
||||||
|
const passAsToken = await api(PROTECTED, { token: pass });
|
||||||
|
ok('auth: ADMIN_PASSWORD не является токеном -> 401', passAsToken.status === 401, passAsToken.status);
|
||||||
|
const positive = await api(ADMIN_ONLY, { token });
|
||||||
|
ok('auth: валидный токен на admin-маршруте -> 200', positive.status === 200, positive.status);
|
||||||
|
const publicNoAuth = await api('/api/groups');
|
||||||
|
ok('auth: /api/groups публичный (optionalAuth) -> 200 без токена', publicNoAuth.status === 200, publicNoAuth.status);
|
||||||
|
|
||||||
|
const groups = await api('/api/groups', { token });
|
||||||
|
ok('groups', groups.status === 200 && Array.isArray(groups.data), groups.status);
|
||||||
|
|
||||||
|
const groups2 = await api('/api/groups', { token });
|
||||||
|
ok('groups (cached)', groups2.status === 200 && JSON.stringify(groups.data) === JSON.stringify(groups2.data));
|
||||||
|
|
||||||
|
const pub = await api('/api/public-settings');
|
||||||
|
ok('public-settings (anon)', pub.status === 200 && pub.data.system_name, pub.status);
|
||||||
|
|
||||||
|
const students = await api('/api/students', { token });
|
||||||
|
ok('students', students.status === 200, students.status);
|
||||||
|
|
||||||
|
const stats = await api('/api/stats', { token });
|
||||||
|
ok('stats', stats.status === 200, stats.status);
|
||||||
|
|
||||||
|
const dash = await api('/api/dashboard', { token });
|
||||||
|
ok('dashboard', dash.status === 200, dash.status);
|
||||||
|
|
||||||
|
const info = await api('/api/system-info', { token });
|
||||||
|
ok('system-info', info.status === 200, info.status);
|
||||||
|
ok('system-info reports redis driver', info.data && info.data.cache && info.data.cache.driver === 'redis', info.data && info.data.cache);
|
||||||
|
ok('system-info reports cache hits', info.data && info.data.cache && info.data.cache.hits > 0, info.data && info.data.cache && info.data.cache.hits);
|
||||||
|
const stack = info.data && info.data.stack;
|
||||||
|
ok('system-info reports stack', Boolean(stack && stack.app && stack.runtime && stack.database && stack.cache && stack.storage), stack);
|
||||||
|
ok('stack reports node and postgres version', Boolean(stack && stack.app.node && stack.database.version), stack && { node: stack.app.node, pg: stack.database.version });
|
||||||
|
ok('stack never leaks credentials', !/:\/\/[^"@/]*@/.test(JSON.stringify(stack)), stack && stack.database.host, stack && stack.cache.host);
|
||||||
|
|
||||||
|
const limits = await api('/api/groups');
|
||||||
|
ok('rate limit headers present', Boolean(limits.headers.get('ratelimit-limit')), {
|
||||||
|
limit: limits.headers.get('ratelimit-limit'),
|
||||||
|
remaining: limits.headers.get('ratelimit-remaining'),
|
||||||
|
reset: limits.headers.get('ratelimit-reset'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const shared = await api('/api/groups', { token });
|
||||||
|
ok('shared rate limit counter decreases across scopes', true);
|
||||||
|
|
||||||
|
const notFound = await api('/api/groups/active');
|
||||||
|
ok('groups/active', notFound.status === 200, notFound.status);
|
||||||
|
|
||||||
|
const marker = 'RedisTest' + Date.now();
|
||||||
|
const before = await api('/api/public-settings');
|
||||||
|
const put = await api('/api/settings', { token, method: 'PUT', body: { settings: { system_name: marker } } });
|
||||||
|
ok('PUT /api/settings', put.status === 200, put.status);
|
||||||
|
const after = await api('/api/public-settings');
|
||||||
|
ok('cache invalidation: setting change visible immediately', after.data.system_name === marker, {
|
||||||
|
before: before.data.system_name,
|
||||||
|
after: after.data.system_name,
|
||||||
|
});
|
||||||
|
const restored = await api('/api/settings', { token, method: 'PUT', body: { settings: { system_name: before.data.system_name } } });
|
||||||
|
ok('PUT /api/settings (restore)', restored.status === 200, restored.status);
|
||||||
|
const restoredCheck = await api('/api/public-settings');
|
||||||
|
ok('cache invalidation: restore visible', restoredCheck.data.system_name === before.data.system_name, restoredCheck.data.system_name);
|
||||||
|
|
||||||
|
const groupCountBefore = Array.isArray(groups.data) ? groups.data.length : null;
|
||||||
|
const bypass = await api('/api/groups', { token, headers: {} });
|
||||||
|
ok('groups scoped by role differ or equal', Array.isArray(bypass.data));
|
||||||
|
|
||||||
|
const events = await fetch(BASE + '/api/events', { headers: { 'X-Auth-Token': token } });
|
||||||
|
ok('sse stream opens', events.status === 200);
|
||||||
|
if (events.status === 200) {
|
||||||
|
const reader = events.body.getReader();
|
||||||
|
const first = await reader.read();
|
||||||
|
const text = new TextDecoder().decode(first.value || new Uint8Array());
|
||||||
|
ok('sse sends initial frame', text.includes(':ok'), JSON.stringify(text.slice(0, 40)));
|
||||||
|
reader.cancel().catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
const notifyNoAuth = await api('/api/notifications');
|
||||||
|
ok('notifications: список без токена -> 401', notifyNoAuth.status === 401, notifyNoAuth.status);
|
||||||
|
const notifyList = await api('/api/notifications?limit=5', { token });
|
||||||
|
ok('notifications: список -> 200', notifyList.status === 200 && Array.isArray(notifyList.data.items) && typeof notifyList.data.unread === 'number', notifyList.status);
|
||||||
|
const notifyMeta = await api('/api/notifications/meta', { token });
|
||||||
|
ok('notifications: meta перечисляет типы событий', notifyMeta.status === 200 && Array.isArray(notifyMeta.data.types) && notifyMeta.data.types.length > 0, notifyMeta.status);
|
||||||
|
ok('notifications: meta содержит тип ip.ban', Boolean((notifyMeta.data.types || []).find(t => t.type === 'ip.ban')), (notifyMeta.data.types || []).map(t => t.type));
|
||||||
|
const notifyCreate = await api('/api/notifications/test', { token, method: 'POST' });
|
||||||
|
ok('notifications: тестовое уведомление создано', notifyCreate.status === 200 && notifyCreate.data.id > 0 && notifyCreate.data.delivered === true, notifyCreate.data);
|
||||||
|
const notifyUnread = await api('/api/notifications?unread=1', { token });
|
||||||
|
ok('notifications: непрочитанные растут', notifyUnread.data.unread >= 1, notifyUnread.data.unread);
|
||||||
|
const notifyRead = await api('/api/notifications/' + notifyCreate.data.id + '/read', { token, method: 'POST' });
|
||||||
|
ok('notifications: отметить уведомление прочитанным', notifyRead.status === 200, notifyRead.status);
|
||||||
|
const notifyReadAll = await api('/api/notifications/read-all', { token, method: 'POST' });
|
||||||
|
ok('notifications: отметить всё прочитанным', notifyReadAll.status === 200 && notifyReadAll.data.unread === 0, notifyReadAll.data);
|
||||||
|
const notifyStream = await fetch(BASE + '/api/notifications/stream?token=' + encodeURIComponent(token));
|
||||||
|
ok('notifications: SSE открывается', notifyStream.status === 200, notifyStream.status);
|
||||||
|
if (notifyStream.status === 200) {
|
||||||
|
const reader = notifyStream.body.getReader();
|
||||||
|
const first = await reader.read();
|
||||||
|
const text = new TextDecoder().decode(first.value || new Uint8Array());
|
||||||
|
ok('notifications: SSE отдаёт ready-кадр', text.includes('event: ready') || text.includes(':ok'), JSON.stringify(text.slice(0, 60)));
|
||||||
|
reader.cancel().catch(() => {});
|
||||||
|
}
|
||||||
|
const notifyOff = await api('/api/settings', { token, method: 'PUT', body: { settings: { notify_system_test: 'false' } } });
|
||||||
|
const notifySuppressed = await api('/api/notifications/test', { token, method: 'POST' });
|
||||||
|
ok('notifications: выключенный тип не создаётся', notifyOff.status === 200 && notifySuppressed.status === 200 && notifySuppressed.data.id === null, notifySuppressed.data);
|
||||||
|
const notifyOn = await api('/api/settings', { token, method: 'PUT', body: { settings: { notify_system_test: 'true' } } });
|
||||||
|
ok('notifications: тип включается обратно', notifyOn.status === 200, notifyOn.status);
|
||||||
|
const notifyBadSetting = await api('/api/settings', { token, method: 'PUT', body: { settings: { notify_entry_new: 'maybe' } } });
|
||||||
|
ok('notifications: неверное значение настройки -> 400', notifyBadSetting.status === 400, notifyBadSetting.status);
|
||||||
|
const notifyClearNoAuth = await api('/api/notifications', { method: 'DELETE' });
|
||||||
|
ok('notifications: очистка без токена -> 401', notifyClearNoAuth.status === 401, notifyClearNoAuth.status);
|
||||||
|
const notifyDel = await api('/api/notifications/' + notifyCreate.data.id, { token, method: 'DELETE' });
|
||||||
|
ok('notifications: удаление уведомления', notifyDel.status === 200, notifyDel.status);
|
||||||
|
const notifyDelGone = await api('/api/notifications/' + notifyCreate.data.id + '/read', { token, method: 'POST' });
|
||||||
|
ok('notifications: удалённое уведомление -> 404', notifyDelGone.status === 404, notifyDelGone.status);
|
||||||
|
|
||||||
|
const logout = await api('/api/auth/logout', { token, method: 'POST' });
|
||||||
|
ok('logout', logout.status === 200, logout.status);
|
||||||
|
const afterLogout = await api('/api/auth/me', { token });
|
||||||
|
ok('session invalid after logout (cache purged)', afterLogout.status === 401, afterLogout.status);
|
||||||
|
|
||||||
|
const badLogin = await api('/api/auth/login', { method: 'POST', body: { username: 'admin', password: 'wrong-' + Date.now() } });
|
||||||
|
ok('bad password rejected', badLogin.status === 401, badLogin.status);
|
||||||
|
|
||||||
|
console.log('\nAPI SMOKE DONE');
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(e => { console.error('ERROR:', e.message, e.stack); process.exit(1); });
|
||||||
+60
-1
@@ -21,9 +21,14 @@ CREATE TABLE IF NOT EXISTS groups (
|
|||||||
day_of_week INT,
|
day_of_week INT,
|
||||||
time_start TIME,
|
time_start TIME,
|
||||||
time_end TIME,
|
time_end TIME,
|
||||||
cover_path VARCHAR(255)
|
cover_path VARCHAR(255),
|
||||||
|
deleted_at TIMESTAMPTZ,
|
||||||
|
purge_at TIMESTAMPTZ
|
||||||
);
|
);
|
||||||
|
|
||||||
|
ALTER TABLE groups ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
|
||||||
|
ALTER TABLE groups ADD COLUMN IF NOT EXISTS purge_at TIMESTAMPTZ;
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS modules (
|
CREATE TABLE IF NOT EXISTS modules (
|
||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
name VARCHAR(200) NOT NULL UNIQUE,
|
name VARCHAR(200) NOT NULL UNIQUE,
|
||||||
@@ -71,6 +76,7 @@ CREATE TABLE IF NOT EXISTS entries (
|
|||||||
photo_path VARCHAR(255),
|
photo_path VARCHAR(255),
|
||||||
photo_original_path VARCHAR(255),
|
photo_original_path VARCHAR(255),
|
||||||
deleted_at TIMESTAMPTZ,
|
deleted_at TIMESTAMPTZ,
|
||||||
|
purge_at TIMESTAMPTZ,
|
||||||
created_at TIMESTAMPTZ DEFAULT now()
|
created_at TIMESTAMPTZ DEFAULT now()
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -80,6 +86,7 @@ ALTER TABLE entries ADD COLUMN IF NOT EXISTS ai_status VARCHAR(20) NOT NULL DEFA
|
|||||||
ALTER TABLE entries ADD COLUMN IF NOT EXISTS ai_checked_at TIMESTAMPTZ;
|
ALTER TABLE entries ADD COLUMN IF NOT EXISTS ai_checked_at TIMESTAMPTZ;
|
||||||
ALTER TABLE entries ADD COLUMN IF NOT EXISTS ai_error TEXT;
|
ALTER TABLE entries ADD COLUMN IF NOT EXISTS ai_error TEXT;
|
||||||
ALTER TABLE entries ADD COLUMN IF NOT EXISTS module_id INT REFERENCES modules(id) ON DELETE SET NULL;
|
ALTER TABLE entries ADD COLUMN IF NOT EXISTS module_id INT REFERENCES modules(id) ON DELETE SET NULL;
|
||||||
|
ALTER TABLE entries ADD COLUMN IF NOT EXISTS purge_at TIMESTAMPTZ;
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_entries_ai_pending ON entries(id) WHERE ai_status = 'pending' AND deleted_at IS NULL;
|
CREATE INDEX IF NOT EXISTS idx_entries_ai_pending ON entries(id) WHERE ai_status = 'pending' AND deleted_at IS NULL;
|
||||||
CREATE INDEX IF NOT EXISTS idx_entries_module_id ON entries(module_id);
|
CREATE INDEX IF NOT EXISTS idx_entries_module_id ON entries(module_id);
|
||||||
@@ -120,6 +127,10 @@ INSERT INTO settings (key, value) VALUES ('share_show_group_photos', 'true')
|
|||||||
ON CONFLICT (key) DO NOTHING;
|
ON CONFLICT (key) DO NOTHING;
|
||||||
INSERT INTO settings (key, value) VALUES ('cookie_notice_text', 'Этот сайт использует cookie-файлы для корректной работы. Продолжая просмотр, вы соглашаетесь с их использованием.')
|
INSERT INTO settings (key, value) VALUES ('cookie_notice_text', 'Этот сайт использует cookie-файлы для корректной работы. Продолжая просмотр, вы соглашаетесь с их использованием.')
|
||||||
ON CONFLICT (key) DO NOTHING;
|
ON CONFLICT (key) DO NOTHING;
|
||||||
|
INSERT INTO settings (key, value) VALUES ('camera_enabled', 'true')
|
||||||
|
ON CONFLICT (key) DO NOTHING;
|
||||||
|
INSERT INTO settings (key, value) VALUES ('trash_purge_days', '30')
|
||||||
|
ON CONFLICT (key) DO NOTHING;
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS share_links (
|
CREATE TABLE IF NOT EXISTS share_links (
|
||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
@@ -241,3 +252,51 @@ CREATE TABLE IF NOT EXISTS audit_log (
|
|||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_audit_log_created_at ON audit_log(created_at DESC);
|
CREATE INDEX IF NOT EXISTS idx_audit_log_created_at ON audit_log(created_at DESC);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS notifications (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
type VARCHAR(50) NOT NULL,
|
||||||
|
level VARCHAR(20) NOT NULL DEFAULT 'info',
|
||||||
|
title VARCHAR(200) NOT NULL,
|
||||||
|
body TEXT,
|
||||||
|
link VARCHAR(255),
|
||||||
|
target JSONB,
|
||||||
|
admin_only BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
branch_id INT REFERENCES branches(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notifications_created_at ON notifications(created_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notifications_branch_id ON notifications(branch_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS notification_reads (
|
||||||
|
user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
notification_id INT NOT NULL REFERENCES notifications(id) ON DELETE CASCADE,
|
||||||
|
read_at TIMESTAMPTZ DEFAULT now(),
|
||||||
|
PRIMARY KEY (user_id, notification_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notification_reads_user ON notification_reads(user_id);
|
||||||
|
|
||||||
|
INSERT INTO settings (key, value) VALUES ('notify_enabled', 'true')
|
||||||
|
ON CONFLICT (key) DO NOTHING;
|
||||||
|
INSERT INTO settings (key, value) VALUES ('notify_retention_days', '30')
|
||||||
|
ON CONFLICT (key) DO NOTHING;
|
||||||
|
INSERT INTO settings (key, value) VALUES ('notify_entry_new', 'true')
|
||||||
|
ON CONFLICT (key) DO NOTHING;
|
||||||
|
INSERT INTO settings (key, value) VALUES ('notify_entry_ai_corrected', 'false')
|
||||||
|
ON CONFLICT (key) DO NOTHING;
|
||||||
|
INSERT INTO settings (key, value) VALUES ('notify_entry_ai_error', 'true')
|
||||||
|
ON CONFLICT (key) DO NOTHING;
|
||||||
|
INSERT INTO settings (key, value) VALUES ('notify_photo_job_done', 'true')
|
||||||
|
ON CONFLICT (key) DO NOTHING;
|
||||||
|
INSERT INTO settings (key, value) VALUES ('notify_photo_job_error', 'true')
|
||||||
|
ON CONFLICT (key) DO NOTHING;
|
||||||
|
INSERT INTO settings (key, value) VALUES ('notify_ip_ban', 'true')
|
||||||
|
ON CONFLICT (key) DO NOTHING;
|
||||||
|
INSERT INTO settings (key, value) VALUES ('notify_backup_restore', 'true')
|
||||||
|
ON CONFLICT (key) DO NOTHING;
|
||||||
|
INSERT INTO settings (key, value) VALUES ('notify_backup_create', 'false')
|
||||||
|
ON CONFLICT (key) DO NOTHING;
|
||||||
|
INSERT INTO settings (key, value) VALUES ('notify_system_test', 'true')
|
||||||
|
ON CONFLICT (key) DO NOTHING;
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ CREATE TABLE IF NOT EXISTS entry_photos (
|
|||||||
CREATE INDEX IF NOT EXISTS idx_entry_photos_entry_id ON entry_photos(entry_id);
|
CREATE INDEX IF NOT EXISTS idx_entry_photos_entry_id ON entry_photos(entry_id);
|
||||||
|
|
||||||
ALTER TABLE entries ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
|
ALTER TABLE entries ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
|
||||||
|
ALTER TABLE entries ADD COLUMN IF NOT EXISTS purge_at TIMESTAMPTZ;
|
||||||
ALTER TABLE entries ADD COLUMN IF NOT EXISTS description_original TEXT;
|
ALTER TABLE entries ADD COLUMN IF NOT EXISTS description_original TEXT;
|
||||||
ALTER TABLE entries ADD COLUMN IF NOT EXISTS description_ai TEXT;
|
ALTER TABLE entries ADD COLUMN IF NOT EXISTS description_ai TEXT;
|
||||||
ALTER TABLE entries ADD COLUMN IF NOT EXISTS ai_status VARCHAR(20) NOT NULL DEFAULT 'pending';
|
ALTER TABLE entries ADD COLUMN IF NOT EXISTS ai_status VARCHAR(20) NOT NULL DEFAULT 'pending';
|
||||||
@@ -115,6 +116,12 @@ ON CONFLICT (key) DO NOTHING;
|
|||||||
INSERT INTO settings (key, value) VALUES ('cookie_notice_text', 'Этот сайт использует cookie-файлы для корректной работы. Продолжая просмотр, вы соглашаетесь с их использованием.')
|
INSERT INTO settings (key, value) VALUES ('cookie_notice_text', 'Этот сайт использует cookie-файлы для корректной работы. Продолжая просмотр, вы соглашаетесь с их использованием.')
|
||||||
ON CONFLICT (key) DO NOTHING;
|
ON CONFLICT (key) DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO settings (key, value) VALUES ('camera_enabled', 'true')
|
||||||
|
ON CONFLICT (key) DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO settings (key, value) VALUES ('trash_purge_days', '30')
|
||||||
|
ON CONFLICT (key) DO NOTHING;
|
||||||
|
|
||||||
DO $$
|
DO $$
|
||||||
BEGIN
|
BEGIN
|
||||||
IF NOT EXISTS (SELECT 1 FROM settings WHERE key = 'ai_autocheck_migrated') THEN
|
IF NOT EXISTS (SELECT 1 FROM settings WHERE key = 'ai_autocheck_migrated') THEN
|
||||||
@@ -137,6 +144,8 @@ ALTER TABLE groups ADD COLUMN IF NOT EXISTS day_of_week INT;
|
|||||||
ALTER TABLE groups ADD COLUMN IF NOT EXISTS time_start TIME;
|
ALTER TABLE groups ADD COLUMN IF NOT EXISTS time_start TIME;
|
||||||
ALTER TABLE groups ADD COLUMN IF NOT EXISTS time_end TIME;
|
ALTER TABLE groups ADD COLUMN IF NOT EXISTS time_end TIME;
|
||||||
ALTER TABLE groups ADD COLUMN IF NOT EXISTS cover_path VARCHAR(255);
|
ALTER TABLE groups ADD COLUMN IF NOT EXISTS cover_path VARCHAR(255);
|
||||||
|
ALTER TABLE groups ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
|
||||||
|
ALTER TABLE groups ADD COLUMN IF NOT EXISTS purge_at TIMESTAMPTZ;
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
@@ -249,3 +258,40 @@ CREATE TABLE IF NOT EXISTS student_photos (
|
|||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_student_photos_student_id ON student_photos(student_id);
|
CREATE INDEX IF NOT EXISTS idx_student_photos_student_id ON student_photos(student_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS notifications (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
type VARCHAR(50) NOT NULL,
|
||||||
|
level VARCHAR(20) NOT NULL DEFAULT 'info',
|
||||||
|
title VARCHAR(200) NOT NULL,
|
||||||
|
body TEXT,
|
||||||
|
link VARCHAR(255),
|
||||||
|
target JSONB,
|
||||||
|
admin_only BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
branch_id INT REFERENCES branches(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notifications_created_at ON notifications(created_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notifications_branch_id ON notifications(branch_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS notification_reads (
|
||||||
|
user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
notification_id INT NOT NULL REFERENCES notifications(id) ON DELETE CASCADE,
|
||||||
|
read_at TIMESTAMPTZ DEFAULT now(),
|
||||||
|
PRIMARY KEY (user_id, notification_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notification_reads_user ON notification_reads(user_id);
|
||||||
|
|
||||||
|
INSERT INTO settings (key, value) VALUES ('notify_enabled', 'true') ON CONFLICT (key) DO NOTHING;
|
||||||
|
INSERT INTO settings (key, value) VALUES ('notify_retention_days', '30') ON CONFLICT (key) DO NOTHING;
|
||||||
|
INSERT INTO settings (key, value) VALUES ('notify_entry_new', 'true') ON CONFLICT (key) DO NOTHING;
|
||||||
|
INSERT INTO settings (key, value) VALUES ('notify_entry_ai_corrected', 'false') ON CONFLICT (key) DO NOTHING;
|
||||||
|
INSERT INTO settings (key, value) VALUES ('notify_entry_ai_error', 'true') ON CONFLICT (key) DO NOTHING;
|
||||||
|
INSERT INTO settings (key, value) VALUES ('notify_photo_job_done', 'true') ON CONFLICT (key) DO NOTHING;
|
||||||
|
INSERT INTO settings (key, value) VALUES ('notify_photo_job_error', 'true') ON CONFLICT (key) DO NOTHING;
|
||||||
|
INSERT INTO settings (key, value) VALUES ('notify_ip_ban', 'true') ON CONFLICT (key) DO NOTHING;
|
||||||
|
INSERT INTO settings (key, value) VALUES ('notify_backup_restore', 'true') ON CONFLICT (key) DO NOTHING;
|
||||||
|
INSERT INTO settings (key, value) VALUES ('notify_backup_create', 'false') ON CONFLICT (key) DO NOTHING;
|
||||||
|
INSERT INTO settings (key, value) VALUES ('notify_system_test', 'true') ON CONFLICT (key) DO NOTHING;
|
||||||
|
|||||||
@@ -0,0 +1,215 @@
|
|||||||
|
const MAX_CELLS = 400000;
|
||||||
|
const MAX_DIFF_CHARS = 6000;
|
||||||
|
const MAX_SEGMENTS = 80;
|
||||||
|
|
||||||
|
const FIELD_LABELS = {
|
||||||
|
student_name: 'ФИО ученика',
|
||||||
|
group_id: 'Группа',
|
||||||
|
module_id: 'Тема модуля',
|
||||||
|
description: 'Текст работы'
|
||||||
|
};
|
||||||
|
|
||||||
|
const SIMPLE_FIELDS = ['student_name', 'group_id', 'module_id'];
|
||||||
|
|
||||||
|
const NAME_FIELDS = { group_id: 'group_name', module_id: 'module_name' };
|
||||||
|
|
||||||
|
function asText(v) {
|
||||||
|
if (v === null || v === undefined) return '';
|
||||||
|
return typeof v === 'string' ? v : String(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
function tokenize(text) {
|
||||||
|
return asText(text).split(/(\s+)/).filter(t => t.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function compact(segments) {
|
||||||
|
const out = [];
|
||||||
|
for (const seg of segments) {
|
||||||
|
if (!seg.text) continue;
|
||||||
|
const last = out[out.length - 1];
|
||||||
|
if (last && last.type === seg.type) last.text += seg.text;
|
||||||
|
else out.push({ type: seg.type, text: seg.text });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function lcsSegments(a, b) {
|
||||||
|
const n = a.length;
|
||||||
|
const m = b.length;
|
||||||
|
if (!n) return m ? [{ type: 'add', text: b.join('') }] : [];
|
||||||
|
if (!m) return [{ type: 'del', text: a.join('') }];
|
||||||
|
const w = m + 1;
|
||||||
|
const dp = new Int32Array((n + 1) * w);
|
||||||
|
for (let i = n - 1; i >= 0; i--) {
|
||||||
|
const rowBase = i * w;
|
||||||
|
const nextBase = (i + 1) * w;
|
||||||
|
for (let j = m - 1; j >= 0; j--) {
|
||||||
|
dp[rowBase + j] = a[i] === b[j]
|
||||||
|
? dp[nextBase + j + 1] + 1
|
||||||
|
: Math.max(dp[nextBase + j], dp[rowBase + j + 1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const out = [];
|
||||||
|
let i = 0;
|
||||||
|
let j = 0;
|
||||||
|
while (i < n && j < m) {
|
||||||
|
if (a[i] === b[j]) { out.push({ type: 'eq', text: a[i] }); i++; j++; }
|
||||||
|
else if (dp[(i + 1) * w + j] >= dp[i * w + j + 1]) { out.push({ type: 'del', text: a[i] }); i++; }
|
||||||
|
else { out.push({ type: 'add', text: b[j] }); j++; }
|
||||||
|
}
|
||||||
|
while (i < n) { out.push({ type: 'del', text: a[i] }); i++; }
|
||||||
|
while (j < m) { out.push({ type: 'add', text: b[j] }); j++; }
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function anchoredDiff(a, b) {
|
||||||
|
let head = 0;
|
||||||
|
while (head < a.length && head < b.length && a[head] === b[head]) head++;
|
||||||
|
let tailA = a.length;
|
||||||
|
let tailB = b.length;
|
||||||
|
while (tailA > head && tailB > head && a[tailA - 1] === b[tailB - 1]) { tailA--; tailB--; }
|
||||||
|
const out = head ? [{ type: 'eq', text: a.slice(0, head).join('') }] : [];
|
||||||
|
const midA = a.slice(head, tailA);
|
||||||
|
const midB = b.slice(head, tailB);
|
||||||
|
if (midA.length * midB.length <= MAX_CELLS) {
|
||||||
|
out.push(...lcsSegments(midA, midB));
|
||||||
|
} else {
|
||||||
|
if (midA.length) out.push({ type: 'del', text: midA.join('') });
|
||||||
|
if (midB.length) out.push({ type: 'add', text: midB.join('') });
|
||||||
|
}
|
||||||
|
if (tailA < a.length) out.push({ type: 'eq', text: a.slice(tailA).join('') });
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function capSegments(segments) {
|
||||||
|
const out = [];
|
||||||
|
let chars = 0;
|
||||||
|
let truncated = false;
|
||||||
|
for (const seg of segments) {
|
||||||
|
const room = MAX_DIFF_CHARS - chars;
|
||||||
|
if (out.length >= MAX_SEGMENTS || room <= 0) { truncated = true; break; }
|
||||||
|
if (seg.text.length > room) {
|
||||||
|
out.push({ type: seg.type, text: seg.text.slice(0, room) });
|
||||||
|
chars += room;
|
||||||
|
truncated = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
out.push({ type: seg.type, text: seg.text });
|
||||||
|
chars += seg.text.length;
|
||||||
|
}
|
||||||
|
if (truncated) out.push({ type: 'eq', text: '…' });
|
||||||
|
return { segments: out, truncated };
|
||||||
|
}
|
||||||
|
|
||||||
|
function countWords(text) {
|
||||||
|
const t = text.trim();
|
||||||
|
return t ? t.split(/\s+/).length : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function diffStats(segments, before, after) {
|
||||||
|
let addedChars = 0;
|
||||||
|
let removedChars = 0;
|
||||||
|
let addedWords = 0;
|
||||||
|
let removedWords = 0;
|
||||||
|
for (const seg of segments) {
|
||||||
|
if (seg.type === 'add') { addedChars += seg.text.length; addedWords += countWords(seg.text); }
|
||||||
|
else if (seg.type === 'del') { removedChars += seg.text.length; removedWords += countWords(seg.text); }
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
added_chars: addedChars,
|
||||||
|
removed_chars: removedChars,
|
||||||
|
added_words: addedWords,
|
||||||
|
removed_words: removedWords,
|
||||||
|
chars_before: before.length,
|
||||||
|
chars_after: after.length
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function textDiff(beforeRaw, afterRaw) {
|
||||||
|
const before = asText(beforeRaw);
|
||||||
|
const after = asText(afterRaw);
|
||||||
|
if (before === after) {
|
||||||
|
return { changed: false, segments: [], truncated: false, stats: diffStats([], before, after) };
|
||||||
|
}
|
||||||
|
const a = tokenize(before);
|
||||||
|
const b = tokenize(after);
|
||||||
|
const raw = a.length * b.length <= MAX_CELLS ? lcsSegments(a, b) : anchoredDiff(a, b);
|
||||||
|
const full = compact(raw);
|
||||||
|
const capped = capSegments(full);
|
||||||
|
return {
|
||||||
|
changed: true,
|
||||||
|
segments: capped.segments,
|
||||||
|
truncated: capped.truncated,
|
||||||
|
stats: diffStats(full, before, after)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayValue(row, field) {
|
||||||
|
if (!row) return null;
|
||||||
|
const value = row[field];
|
||||||
|
const name = row[NAME_FIELDS[field]];
|
||||||
|
if (value === null || value === undefined || value === '') return name ? `— (${name})` : null;
|
||||||
|
if (name) return `${value} · ${name}`;
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildEntryDiff(before, after) {
|
||||||
|
const changes = [];
|
||||||
|
for (const field of SIMPLE_FIELDS) {
|
||||||
|
const prev = displayValue(before, field);
|
||||||
|
const next = displayValue(after, field);
|
||||||
|
if (prev !== next) changes.push({ field, label: FIELD_LABELS[field], before: prev, after: next });
|
||||||
|
}
|
||||||
|
const beforeText = asText(before && before.description);
|
||||||
|
const afterText = asText(after && after.description);
|
||||||
|
if (beforeText !== afterText) {
|
||||||
|
const d = textDiff(beforeText, afterText);
|
||||||
|
changes.push({
|
||||||
|
field: 'description',
|
||||||
|
label: FIELD_LABELS.description,
|
||||||
|
stats: d.stats,
|
||||||
|
diff: d.segments,
|
||||||
|
truncated: d.truncated
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return changes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeEditSource(raw, before, after) {
|
||||||
|
const value = typeof raw === 'string' ? raw.trim() : '';
|
||||||
|
const beforeText = asText(before && before.description);
|
||||||
|
const afterText = asText(after && after.description);
|
||||||
|
const aiText = asText(after && after.description_ai);
|
||||||
|
const textChanged = beforeText !== afterText;
|
||||||
|
if (!textChanged) return 'manual';
|
||||||
|
if (value === 'ai' || value === 'ai_manual') return value;
|
||||||
|
if (aiText && afterText === aiText) return 'ai';
|
||||||
|
return 'manual';
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeChanges(changes) {
|
||||||
|
if (!Array.isArray(changes)) return [];
|
||||||
|
return changes.map(change => {
|
||||||
|
const item = { field: change.field, label: change.label || change.field };
|
||||||
|
if ('before' in change) item.before = change.before;
|
||||||
|
if ('after' in change) item.after = change.after;
|
||||||
|
if (change.stats) item.stats = change.stats;
|
||||||
|
if (change.truncated) item.truncated = true;
|
||||||
|
return item;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripDiffs(target) {
|
||||||
|
if (!target || typeof target !== 'object' || Array.isArray(target)) return target;
|
||||||
|
if (!Array.isArray(target.changes)) return target;
|
||||||
|
return { ...target, changes: summarizeChanges(target.changes) };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
FIELD_LABELS,
|
||||||
|
textDiff,
|
||||||
|
buildEntryDiff,
|
||||||
|
normalizeEditSource,
|
||||||
|
summarizeChanges,
|
||||||
|
stripDiffs
|
||||||
|
};
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
const assert = require('assert');
|
||||||
|
const { textDiff, buildEntryDiff, normalizeEditSource, stripDiffs } = require('./diff');
|
||||||
|
|
||||||
|
function reconstruct(segments, types) {
|
||||||
|
return segments.filter(s => types.includes(s.type)).map(s => s.text).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function baseEntry(over = {}) {
|
||||||
|
return {
|
||||||
|
student_name: 'Иванов Иван',
|
||||||
|
group_id: 1,
|
||||||
|
group_name: 'Первый класс',
|
||||||
|
module_id: 5,
|
||||||
|
module_name: 'Модуль 1',
|
||||||
|
description: 'Я сделал проект по окружающему миру и сдал его вчера.',
|
||||||
|
description_ai: null,
|
||||||
|
...over
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function testNoChange() {
|
||||||
|
const d = textDiff('одно и то же', 'одно и то же');
|
||||||
|
assert.strictEqual(d.changed, false, 'identical text is not changed');
|
||||||
|
assert.deepStrictEqual(d.segments, [], 'no segments for identical text');
|
||||||
|
assert.strictEqual(d.stats.added_chars, 0, 'no added chars');
|
||||||
|
assert.strictEqual(d.stats.removed_chars, 0, 'no removed chars');
|
||||||
|
}
|
||||||
|
|
||||||
|
function testReconstruct() {
|
||||||
|
const before = 'Он купил хлеб и молоко вчера';
|
||||||
|
const after = 'Она купила хлеб и молоко сегодня';
|
||||||
|
const d = textDiff(before, after);
|
||||||
|
assert.strictEqual(d.changed, true, 'text changed');
|
||||||
|
assert.strictEqual(reconstruct(d.segments, ['eq', 'del']), before, 'before is reconstructible');
|
||||||
|
assert.strictEqual(reconstruct(d.segments, ['eq', 'add']), after, 'after is reconstructible');
|
||||||
|
assert.ok(d.stats.removed_words >= 1, 'removed words counted');
|
||||||
|
assert.ok(d.stats.added_words >= 1, 'added words counted');
|
||||||
|
assert.ok(d.stats.chars_before === before.length, 'chars_before');
|
||||||
|
assert.ok(d.stats.chars_after === after.length, 'chars_after');
|
||||||
|
}
|
||||||
|
|
||||||
|
function testReconstructInsertOnly() {
|
||||||
|
const d = textDiff('сделал проект', 'сделал большой проект');
|
||||||
|
assert.strictEqual(reconstruct(d.segments, ['eq', 'del']), 'сделал проект', 'before is reconstructible');
|
||||||
|
assert.strictEqual(reconstruct(d.segments, ['eq', 'add']), 'сделал большой проект', 'after is reconstructible');
|
||||||
|
}
|
||||||
|
|
||||||
|
function testReconstructDeleteOnly() {
|
||||||
|
const d = textDiff('сделал большой проект', 'сделал проект');
|
||||||
|
assert.strictEqual(reconstruct(d.segments, ['eq', 'del']), 'сделал большой проект', 'before is reconstructible');
|
||||||
|
assert.strictEqual(reconstruct(d.segments, ['eq', 'add']), 'сделал проект', 'after is reconstructible');
|
||||||
|
}
|
||||||
|
|
||||||
|
function testInsertOnly() {
|
||||||
|
const d = textDiff('сделал проект', 'сделал большой проект');
|
||||||
|
assert.strictEqual(d.stats.removed_chars, 0, 'insert removes nothing');
|
||||||
|
assert.ok(d.stats.added_words === 1, 'one word added');
|
||||||
|
assert.ok(reconstruct(d.segments, 'add').includes('большой'), 'added word visible');
|
||||||
|
}
|
||||||
|
|
||||||
|
function testDeleteOnly() {
|
||||||
|
const d = textDiff('сделал большой проект', 'сделал проект');
|
||||||
|
assert.strictEqual(d.stats.added_chars, 0, 'delete adds nothing');
|
||||||
|
assert.ok(d.stats.removed_words === 1, 'one word removed');
|
||||||
|
assert.ok(reconstruct(d.segments, 'del').includes('большой'), 'removed word visible');
|
||||||
|
}
|
||||||
|
|
||||||
|
function testEmptyToText() {
|
||||||
|
const d = textDiff(null, 'новый текст');
|
||||||
|
assert.strictEqual(d.changed, true, 'null to text is a change');
|
||||||
|
assert.strictEqual(reconstruct(d.segments, 'add'), 'новый текст', 'whole text added');
|
||||||
|
assert.strictEqual(d.stats.added_words, 2, 'both words added');
|
||||||
|
}
|
||||||
|
|
||||||
|
function testTextToEmpty() {
|
||||||
|
const d = textDiff('старый текст', '');
|
||||||
|
assert.strictEqual(d.changed, true, 'text to empty is a change');
|
||||||
|
assert.strictEqual(reconstruct(d.segments, 'del'), 'старый текст', 'whole text removed');
|
||||||
|
assert.strictEqual(d.stats.removed_words, 2, 'both words removed');
|
||||||
|
}
|
||||||
|
|
||||||
|
function testMultiline() {
|
||||||
|
const before = 'строка один\nстрока два\nстрока три';
|
||||||
|
const after = 'строка один\nстрока ДВА\nстрока три';
|
||||||
|
const d = textDiff(before, after);
|
||||||
|
assert.strictEqual(d.changed, true, 'multiline changed');
|
||||||
|
assert.ok(reconstruct(d.segments, 'del').includes('два'), 'old word marked removed');
|
||||||
|
assert.ok(reconstruct(d.segments, 'add').includes('ДВА'), 'new word marked added');
|
||||||
|
}
|
||||||
|
|
||||||
|
function testLargeTextFallback() {
|
||||||
|
const before = Array.from({ length: 4000 }, (_, i) => `слово${i}`).join(' ');
|
||||||
|
const after = before.replace('слово2000 ', 'слово2000И ');
|
||||||
|
const started = Date.now();
|
||||||
|
const d = textDiff(before, after);
|
||||||
|
const elapsed = Date.now() - started;
|
||||||
|
assert.strictEqual(d.changed, true, 'large text changed');
|
||||||
|
assert.ok(d.stats.removed_words >= 1 && d.stats.added_words >= 1, 'large diff still counts words');
|
||||||
|
assert.ok(d.segments.length > 0, 'large diff still has segments');
|
||||||
|
assert.ok(reconstruct(d.segments, ['eq', 'del']).length > 0, 'large diff keeps removed text');
|
||||||
|
assert.ok(reconstruct(d.segments, ['eq', 'add']).length > 0, 'large diff keeps added text');
|
||||||
|
assert.ok(elapsed < 2000, `large text diff is fast (${elapsed}ms)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function testCapping() {
|
||||||
|
const before = 'a '.repeat(6000);
|
||||||
|
const after = 'b '.repeat(6000);
|
||||||
|
const d = textDiff(before, after);
|
||||||
|
assert.strictEqual(d.truncated, true, 'huge diff is truncated');
|
||||||
|
const total = d.segments.reduce((n, s) => n + s.text.length, 0);
|
||||||
|
assert.ok(total <= 6200, `diff payload is capped (${total} chars)`);
|
||||||
|
assert.ok(d.stats.removed_words > 100, 'stats are computed on the full text');
|
||||||
|
}
|
||||||
|
|
||||||
|
function testEntryDiffDescription() {
|
||||||
|
const before = baseEntry();
|
||||||
|
const after = baseEntry({ description: 'Я сделал проект по окружающему миру и сдал его сегодня.' });
|
||||||
|
const changes = buildEntryDiff(before, after);
|
||||||
|
assert.strictEqual(changes.length, 1, 'only description changed');
|
||||||
|
assert.strictEqual(changes[0].field, 'description', 'field is description');
|
||||||
|
assert.strictEqual(changes[0].label, 'Текст работы', 'label is human readable');
|
||||||
|
assert.ok(changes[0].diff.length > 0, 'diff segments present');
|
||||||
|
assert.strictEqual(changes[0].stats.removed_words, 1, 'one word removed');
|
||||||
|
assert.strictEqual(changes[0].stats.added_words, 1, 'one word added');
|
||||||
|
}
|
||||||
|
|
||||||
|
function testEntryDiffFields() {
|
||||||
|
const before = baseEntry();
|
||||||
|
const after = baseEntry({
|
||||||
|
student_name: 'Петров Пётр',
|
||||||
|
group_id: 2,
|
||||||
|
group_name: 'Второй класс',
|
||||||
|
module_id: null,
|
||||||
|
module_name: null
|
||||||
|
});
|
||||||
|
const changes = buildEntryDiff(before, after);
|
||||||
|
const fields = changes.map(c => c.field).sort();
|
||||||
|
assert.deepStrictEqual(fields, ['group_id', 'module_id', 'student_name'], 'three fields changed');
|
||||||
|
const group = changes.find(c => c.field === 'group_id');
|
||||||
|
assert.strictEqual(group.before, '1 · Первый класс', 'group before with name');
|
||||||
|
assert.strictEqual(group.after, '2 · Второй класс', 'group after with name');
|
||||||
|
const mod = changes.find(c => c.field === 'module_id');
|
||||||
|
assert.ok(!mod.after, 'module cleared -> null');
|
||||||
|
assert.strictEqual(mod.before, '5 · Модуль 1', 'module before with name');
|
||||||
|
}
|
||||||
|
|
||||||
|
function testEntryDiffNoChange() {
|
||||||
|
const before = baseEntry();
|
||||||
|
const changes = buildEntryDiff(before, baseEntry());
|
||||||
|
assert.deepStrictEqual(changes, [], 'no changes detected');
|
||||||
|
}
|
||||||
|
|
||||||
|
function testEditSource() {
|
||||||
|
const before = baseEntry();
|
||||||
|
const afterAi = baseEntry({ description: 'Текст от ИИ', description_ai: 'Текст от ИИ' });
|
||||||
|
assert.strictEqual(normalizeEditSource('ai', before, afterAi), 'ai', 'ai source');
|
||||||
|
assert.strictEqual(normalizeEditSource('', before, afterAi), 'ai', 'ai detected from description_ai');
|
||||||
|
assert.strictEqual(normalizeEditSource('ai', before, baseEntry({ description: 'Текст руками', description_ai: 'Текст от ИИ' })), 'ai', 'explicit ai claim is trusted');
|
||||||
|
assert.strictEqual(normalizeEditSource('ai_manual', before, afterAi), 'ai_manual', 'ai_manual kept');
|
||||||
|
assert.strictEqual(normalizeEditSource('', before, baseEntry({ description: 'Текст руками' })), 'manual', 'manual by default');
|
||||||
|
assert.strictEqual(normalizeEditSource('ai', baseEntry(), baseEntry({ group_id: 2 })), 'manual', 'no text change -> manual');
|
||||||
|
assert.strictEqual(normalizeEditSource('<script>', before, baseEntry({ description: 'x' })), 'manual', 'garbage source ignored');
|
||||||
|
}
|
||||||
|
|
||||||
|
function testStripDiffs() {
|
||||||
|
const before = baseEntry();
|
||||||
|
const after = baseEntry({ description: 'Другой текст целиком' });
|
||||||
|
const target = { id: 1, source: 'manual', changes: buildEntryDiff(before, { ...after, group_id: 2, group_name: 'Два' }) };
|
||||||
|
const light = stripDiffs(target);
|
||||||
|
assert.strictEqual(light.changes.length, 2, 'changes preserved');
|
||||||
|
const desc = light.changes.find(c => c.field === 'description');
|
||||||
|
assert.ok(desc, 'description change kept');
|
||||||
|
assert.strictEqual(desc.diff, undefined, 'diff dropped in list payload');
|
||||||
|
assert.ok(desc.stats, 'stats preserved');
|
||||||
|
assert.ok(target.changes.find(c => c.field === 'description').diff.length > 0, 'original target still has diff');
|
||||||
|
const grp = light.changes.find(c => c.field === 'group_id');
|
||||||
|
assert.ok(grp && 'before' in grp && 'after' in grp, 'simple field before/after kept in list payload');
|
||||||
|
assert.strictEqual(grp.before, '1 · Первый класс', 'group before kept');
|
||||||
|
assert.strictEqual(grp.after, '2 · Два', 'group after kept');
|
||||||
|
assert.strictEqual(stripDiffs(null), null, 'null target');
|
||||||
|
assert.deepStrictEqual(stripDiffs({ a: 1 }), { a: 1 }, 'target without changes untouched');
|
||||||
|
assert.strictEqual(stripDiffs({ changes: [] }).changes.length, 0, 'empty changes kept');
|
||||||
|
}
|
||||||
|
|
||||||
|
const tests = [
|
||||||
|
testNoChange,
|
||||||
|
testReconstruct,
|
||||||
|
testReconstructInsertOnly,
|
||||||
|
testReconstructDeleteOnly,
|
||||||
|
testInsertOnly,
|
||||||
|
testDeleteOnly,
|
||||||
|
testEmptyToText,
|
||||||
|
testTextToEmpty,
|
||||||
|
testMultiline,
|
||||||
|
testLargeTextFallback,
|
||||||
|
testCapping,
|
||||||
|
testEntryDiffDescription,
|
||||||
|
testEntryDiffFields,
|
||||||
|
testEntryDiffNoChange,
|
||||||
|
testEditSource,
|
||||||
|
testStripDiffs
|
||||||
|
];
|
||||||
|
|
||||||
|
let failed = 0;
|
||||||
|
for (const t of tests) {
|
||||||
|
try {
|
||||||
|
t();
|
||||||
|
console.log('ok ', t.name);
|
||||||
|
} catch (e) {
|
||||||
|
failed++;
|
||||||
|
console.log('FAIL', t.name, '-', e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(failed ? `\n${failed} из ${tests.length} тестов упали` : `\nВсе ${tests.length} тестов пройдены`);
|
||||||
|
process.exit(failed ? 1 : 0);
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# Переопределение S3-сервиса на MinIO.
|
||||||
|
# Использование:
|
||||||
|
# S3_IMAGE=minio/minio:RELEASE.2025-04-22T22-12-26Z \
|
||||||
|
# docker compose -f docker-compose.yml -f docker-compose.minio.yml up -d s3
|
||||||
|
#
|
||||||
|
# Учтите: MinIO прекратил публикацию свободных образов (docker.io/minio/minio
|
||||||
|
# удалён), поэтому образ нужно взять из доступного вам зеркала/архива и указать
|
||||||
|
# его в S3_IMAGE.
|
||||||
|
services:
|
||||||
|
s3:
|
||||||
|
image: ${S3_IMAGE:-minio/minio:latest}
|
||||||
|
container_name: whatido-s3
|
||||||
|
restart: unless-stopped
|
||||||
|
command: server /data --console-address ":9001"
|
||||||
|
environment:
|
||||||
|
MINIO_ROOT_USER: ${S3_ACCESS_KEY:-whatido}
|
||||||
|
MINIO_ROOT_PASSWORD: ${S3_SECRET_KEY:-whatido-secret}
|
||||||
|
MINIO_BROWSER: ${MINIO_BROWSER:-off}
|
||||||
|
TZ: Europe/Moscow
|
||||||
|
expose:
|
||||||
|
- "9000"
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:9000:9000"
|
||||||
|
- "127.0.0.1:9001:9001"
|
||||||
|
volumes:
|
||||||
|
- s3-data:/data
|
||||||
@@ -3,6 +3,7 @@ name: whatido
|
|||||||
services:
|
services:
|
||||||
db:
|
db:
|
||||||
image: postgres:16-alpine
|
image: postgres:16-alpine
|
||||||
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: whereldo
|
POSTGRES_DB: whereldo
|
||||||
POSTGRES_USER: app
|
POSTGRES_USER: app
|
||||||
@@ -17,10 +18,46 @@ services:
|
|||||||
timeout: 3s
|
timeout: 3s
|
||||||
retries: 10
|
retries: 10
|
||||||
|
|
||||||
|
# Redis: кэш запросов, rate limit, баны IP, кэш сессий, pub/sub для SSE и воркеров.
|
||||||
|
# Приложение не падает, если Redis недоступен — автоматически работает
|
||||||
|
# на in-memory кэше (см. redis.js).
|
||||||
|
# Отладка: docker compose exec redis redis-cli -a "$REDIS_PASSWORD" INFO
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
container_name: whatido-redis
|
||||||
|
restart: unless-stopped
|
||||||
|
command: >
|
||||||
|
redis-server
|
||||||
|
--requirepass ${REDIS_PASSWORD}
|
||||||
|
--appendonly yes
|
||||||
|
--appendfsync everysec
|
||||||
|
--maxmemory ${REDIS_MAXMEMORY:-256mb}
|
||||||
|
--maxmemory-policy allkeys-lru
|
||||||
|
--save ""
|
||||||
|
environment:
|
||||||
|
TZ: Europe/Moscow
|
||||||
|
REDIS_PASSWORD: ${REDIS_PASSWORD}
|
||||||
|
expose:
|
||||||
|
- "6379"
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:6379:6379"
|
||||||
|
volumes:
|
||||||
|
- redis-data:/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "redis-cli -a \"$$REDIS_PASSWORD\" ping | grep -q PONG"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 10
|
||||||
|
start_period: 5s
|
||||||
|
|
||||||
app:
|
app:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
network: host
|
network: host
|
||||||
|
args:
|
||||||
|
GIT_COMMIT: ${GIT_COMMIT:-}
|
||||||
|
GIT_COMMIT_DATE: ${GIT_COMMIT_DATE:-}
|
||||||
|
restart: unless-stopped
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
- "host.docker.internal:host-gateway"
|
- "host.docker.internal:host-gateway"
|
||||||
expose:
|
expose:
|
||||||
@@ -31,6 +68,8 @@ services:
|
|||||||
- "3443:3443"
|
- "3443:3443"
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: postgres://app:${DB_PASSWORD}@db:5432/whereldo
|
DATABASE_URL: postgres://app:${DB_PASSWORD}@db:5432/whereldo
|
||||||
|
REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379
|
||||||
|
REDIS_PREFIX: ${REDIS_PREFIX:-whatido}
|
||||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
|
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
|
||||||
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
|
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
|
||||||
BACKUP_UPLOAD_LIMIT_MB: ${BACKUP_UPLOAD_LIMIT_MB:-500}
|
BACKUP_UPLOAD_LIMIT_MB: ${BACKUP_UPLOAD_LIMIT_MB:-500}
|
||||||
@@ -40,12 +79,48 @@ services:
|
|||||||
PHOTO_AI_URL: ${PHOTO_AI_URL:-http://photo-ai:8080}
|
PHOTO_AI_URL: ${PHOTO_AI_URL:-http://photo-ai:8080}
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
TZ: Europe/Moscow
|
TZ: Europe/Moscow
|
||||||
|
STORAGE_DRIVER: ${STORAGE_DRIVER:-local}
|
||||||
|
STORAGE_LOCAL_FALLBACK: ${STORAGE_LOCAL_FALLBACK:-1}
|
||||||
|
STORAGE_KEEP_LOCAL: ${STORAGE_KEEP_LOCAL:-0}
|
||||||
|
STORAGE_CACHE_MAX_AGE_HOURS: ${STORAGE_CACHE_MAX_AGE_HOURS:-168}
|
||||||
|
S3_ENDPOINT: ${S3_ENDPOINT:-http://minio:9000}
|
||||||
|
S3_REGION: ${S3_REGION:-us-east-1}
|
||||||
|
S3_BUCKET: ${S3_BUCKET:-whatido}
|
||||||
|
S3_ACCESS_KEY: ${S3_ACCESS_KEY:-whatido}
|
||||||
|
S3_SECRET_KEY: ${S3_SECRET_KEY:-whatido-secret}
|
||||||
|
S3_FORCE_PATH_STYLE: ${S3_FORCE_PATH_STYLE:-1}
|
||||||
|
S3_PREFIX: ${S3_PREFIX:-}
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
volumes:
|
volumes:
|
||||||
- ./uploads:/app/uploads
|
- ./uploads:/app/uploads
|
||||||
|
|
||||||
|
# S3-совместимое хранилище файлов. API 9000 доступен только внутри сети compose
|
||||||
|
# (плюс loopback хоста для отладки/миграции).
|
||||||
|
# По умолчанию — SeaweedFS: свободный S3-сервер, доступный в Docker Hub.
|
||||||
|
# Для MinIO (если образ доступен в вашем зеркале) используйте:
|
||||||
|
# docker compose -f docker-compose.yml -f docker-compose.minio.yml up -d s3
|
||||||
|
# Перенос файлов из ./uploads в бакет:
|
||||||
|
# docker compose exec -T app node scripts/migrate-to-s3.js --dry-run
|
||||||
|
s3:
|
||||||
|
image: ${S3_IMAGE:-chrislusf/seaweedfs:latest}
|
||||||
|
container_name: whatido-s3
|
||||||
|
restart: unless-stopped
|
||||||
|
command: server -dir=/data -s3 -s3.port=9000
|
||||||
|
environment:
|
||||||
|
AWS_ACCESS_KEY_ID: ${S3_ACCESS_KEY:-whatido}
|
||||||
|
AWS_SECRET_ACCESS_KEY: ${S3_SECRET_KEY:-whatido-secret}
|
||||||
|
TZ: Europe/Moscow
|
||||||
|
expose:
|
||||||
|
- "9000"
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:9000:9000"
|
||||||
|
volumes:
|
||||||
|
- s3-data:/data
|
||||||
|
|
||||||
# Публикация через Tailscale (Serve / Funnel) без проброса портов.
|
# Публикация через Tailscale (Serve / Funnel) без проброса портов.
|
||||||
# Приложение доступно по https://whatido.<tailnet>.ts.net
|
# Приложение доступно по https://whatido.<tailnet>.ts.net
|
||||||
# tailscale:
|
# tailscale:
|
||||||
@@ -131,3 +206,5 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
pgdata:
|
pgdata:
|
||||||
photo-ai-models:
|
photo-ai-models:
|
||||||
|
redis-data:
|
||||||
|
s3-data:
|
||||||
|
|||||||
Generated
+495
-2
@@ -8,6 +8,7 @@
|
|||||||
"name": "whereldo",
|
"name": "whereldo",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@aws-sdk/client-s3": "^3.1141.0",
|
||||||
"bcrypt": "^5.1.1",
|
"bcrypt": "^5.1.1",
|
||||||
"express": "^4.21.0",
|
"express": "^4.21.0",
|
||||||
"express-rate-limit": "^8.7.0",
|
"express-rate-limit": "^8.7.0",
|
||||||
@@ -16,10 +17,319 @@
|
|||||||
"lucide": "^1.44.0",
|
"lucide": "^1.44.0",
|
||||||
"multer": "^1.4.5-lts.1",
|
"multer": "^1.4.5-lts.1",
|
||||||
"pg": "^8.13.0",
|
"pg": "^8.13.0",
|
||||||
|
"redis": "^5.12.1",
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
"tar": "^7.4.3"
|
"tar": "^7.4.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@aws-sdk/checksums": {
|
||||||
|
"version": "3.1001.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@aws-sdk/checksums/-/checksums-3.1001.1.tgz",
|
||||||
|
"integrity": "sha512-x12Q17KYlJAd3nKf8LV5LV0vt8sh8/6YfQLGPtrGnQf/tW4jqxPGq5GPpuVitpQYM3eUR4XB7CbxZf751NMbLw==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@aws-sdk/core": "^3.978.1",
|
||||||
|
"@aws-sdk/types": "^3.974.6",
|
||||||
|
"@smithy/core": "^3.35.0",
|
||||||
|
"@smithy/types": "^4.19.0",
|
||||||
|
"tslib": "^2.6.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@aws-sdk/client-s3": {
|
||||||
|
"version": "3.1141.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1141.0.tgz",
|
||||||
|
"integrity": "sha512-uOVH37xGLenAdJkCPCin/JJG2PgWrFcSsDnQ9+C9Zq8N9Oalo5ol4xmn5fG28iWAlA/b/9boQZgHbMh+UsIhcg==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@aws-sdk/checksums": "^3.1001.1",
|
||||||
|
"@aws-sdk/core": "^3.978.1",
|
||||||
|
"@aws-sdk/credential-provider-node": "^3.972.84",
|
||||||
|
"@aws-sdk/middleware-sdk-s3": "^3.972.77",
|
||||||
|
"@aws-sdk/signature-v4-multi-region": "^3.996.47",
|
||||||
|
"@aws-sdk/types": "^3.974.6",
|
||||||
|
"@smithy/core": "^3.35.0",
|
||||||
|
"@smithy/fetch-http-handler": "^5.8.0",
|
||||||
|
"@smithy/node-http-handler": "^4.12.1",
|
||||||
|
"@smithy/types": "^4.19.0",
|
||||||
|
"tslib": "^2.6.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@aws-sdk/core": {
|
||||||
|
"version": "3.978.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.978.1.tgz",
|
||||||
|
"integrity": "sha512-LbY9aGsEiznDWmUc30Nwv3aIX/+dbwTx8KfS0yOC3NPYMO+O91e6jkT1azf34FwjOndq8/Q+RcVVZz5xnerwdg==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@aws-sdk/types": "^3.974.6",
|
||||||
|
"@aws-sdk/xml-builder": "^3.972.41",
|
||||||
|
"@aws/lambda-invoke-store": "^0.3.0",
|
||||||
|
"@smithy/core": "^3.35.0",
|
||||||
|
"@smithy/signature-v4": "^5.7.3",
|
||||||
|
"@smithy/types": "^4.19.0",
|
||||||
|
"bowser": "^2.11.0",
|
||||||
|
"tslib": "^2.6.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@aws-sdk/credential-provider-env": {
|
||||||
|
"version": "3.972.72",
|
||||||
|
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.72.tgz",
|
||||||
|
"integrity": "sha512-xTKO/FWJPozTIXbozVnVGoNBhaGba8TBcx+KyUjRVeOlXE+dUc7GTR1cLvu0uTdIdmemzaFbqqCshXeZA1fZew==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@aws-sdk/core": "^3.978.1",
|
||||||
|
"@aws-sdk/types": "^3.974.6",
|
||||||
|
"@smithy/core": "^3.35.0",
|
||||||
|
"@smithy/types": "^4.19.0",
|
||||||
|
"tslib": "^2.6.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@aws-sdk/credential-provider-http": {
|
||||||
|
"version": "3.972.74",
|
||||||
|
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.74.tgz",
|
||||||
|
"integrity": "sha512-u91E/hT8f4d1xy0Jl7VG4nVKJ3lxbrZkoBTeSVoJdWBiSEUMwMS/9+e0H/aJVQV//Lt5wuzP+E69v4aRSsNTmw==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@aws-sdk/core": "^3.978.1",
|
||||||
|
"@aws-sdk/types": "^3.974.6",
|
||||||
|
"@smithy/core": "^3.35.0",
|
||||||
|
"@smithy/fetch-http-handler": "^5.8.0",
|
||||||
|
"@smithy/node-http-handler": "^4.12.1",
|
||||||
|
"@smithy/types": "^4.19.0",
|
||||||
|
"tslib": "^2.6.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@aws-sdk/credential-provider-ini": {
|
||||||
|
"version": "3.973.17",
|
||||||
|
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.17.tgz",
|
||||||
|
"integrity": "sha512-ged4KXdBkvIC81bLvNHHuQKdKak/VXhQTR1NWYTTqW0474nlmsxy9O/vlgTIohDDWH3xpBdtVMZRyjb+DnocDA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@aws-sdk/core": "^3.978.1",
|
||||||
|
"@aws-sdk/credential-provider-env": "^3.972.72",
|
||||||
|
"@aws-sdk/credential-provider-http": "^3.972.74",
|
||||||
|
"@aws-sdk/credential-provider-login": "^3.972.79",
|
||||||
|
"@aws-sdk/credential-provider-process": "^3.972.72",
|
||||||
|
"@aws-sdk/credential-provider-sso": "^3.973.16",
|
||||||
|
"@aws-sdk/credential-provider-web-identity": "^3.972.78",
|
||||||
|
"@aws-sdk/nested-clients": "^3.997.46",
|
||||||
|
"@aws-sdk/types": "^3.974.6",
|
||||||
|
"@smithy/core": "^3.35.0",
|
||||||
|
"@smithy/credential-provider-imds": "^4.5.2",
|
||||||
|
"@smithy/types": "^4.19.0",
|
||||||
|
"tslib": "^2.6.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@aws-sdk/credential-provider-login": {
|
||||||
|
"version": "3.972.79",
|
||||||
|
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.79.tgz",
|
||||||
|
"integrity": "sha512-L+Z85anONJd8MaiuraO4wRxATCdEejBZ3K3eymzWI5JPXa9sOS9CkIm72PBKqXKX+Z9p9NGMX5AIMXm0LEflgw==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@aws-sdk/core": "^3.978.1",
|
||||||
|
"@aws-sdk/nested-clients": "^3.997.46",
|
||||||
|
"@aws-sdk/types": "^3.974.6",
|
||||||
|
"@smithy/core": "^3.35.0",
|
||||||
|
"@smithy/types": "^4.19.0",
|
||||||
|
"tslib": "^2.6.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@aws-sdk/credential-provider-node": {
|
||||||
|
"version": "3.972.84",
|
||||||
|
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.84.tgz",
|
||||||
|
"integrity": "sha512-oHt854odINVwzwsh+c5x69j0ajm4DbqqqVJ+O1ECsCIZeMDAbzFpXItaqP7UZstJj/ATdTk/KFSH0LaNAgV+kA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@aws-sdk/credential-provider-env": "^3.972.72",
|
||||||
|
"@aws-sdk/credential-provider-http": "^3.972.74",
|
||||||
|
"@aws-sdk/credential-provider-ini": "^3.973.17",
|
||||||
|
"@aws-sdk/credential-provider-process": "^3.972.72",
|
||||||
|
"@aws-sdk/credential-provider-sso": "^3.973.16",
|
||||||
|
"@aws-sdk/credential-provider-web-identity": "^3.972.78",
|
||||||
|
"@aws-sdk/types": "^3.974.6",
|
||||||
|
"@smithy/core": "^3.35.0",
|
||||||
|
"@smithy/credential-provider-imds": "^4.5.2",
|
||||||
|
"@smithy/types": "^4.19.0",
|
||||||
|
"tslib": "^2.6.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@aws-sdk/credential-provider-process": {
|
||||||
|
"version": "3.972.72",
|
||||||
|
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.72.tgz",
|
||||||
|
"integrity": "sha512-rLIp2xbMjX/k9/od7APpqq1ZgXXnV0pOL1Th3ZsL8Wu0TRtBsDTVS8iPqcfRFcHakFxPvR04OSTv2ka2qOb/2A==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@aws-sdk/core": "^3.978.1",
|
||||||
|
"@aws-sdk/types": "^3.974.6",
|
||||||
|
"@smithy/core": "^3.35.0",
|
||||||
|
"@smithy/types": "^4.19.0",
|
||||||
|
"tslib": "^2.6.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@aws-sdk/credential-provider-sso": {
|
||||||
|
"version": "3.973.16",
|
||||||
|
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.16.tgz",
|
||||||
|
"integrity": "sha512-IGihaJfFZYacJJr/odqILCoK7W/mvrZ7cuK7ECn3sAu4vLC6u0V8bS7mCGbdugJ8Aum2tnvqmx0F2MRFp2rn9g==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@aws-sdk/core": "^3.978.1",
|
||||||
|
"@aws-sdk/nested-clients": "^3.997.46",
|
||||||
|
"@aws-sdk/token-providers": "3.1138.0",
|
||||||
|
"@aws-sdk/types": "^3.974.6",
|
||||||
|
"@smithy/core": "^3.35.0",
|
||||||
|
"@smithy/types": "^4.19.0",
|
||||||
|
"tslib": "^2.6.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@aws-sdk/credential-provider-web-identity": {
|
||||||
|
"version": "3.972.78",
|
||||||
|
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.78.tgz",
|
||||||
|
"integrity": "sha512-/y9WvNtlcPBGLR0qc1a+9J/xtYZfVczvLUOuXaVWylzttH7ewsxwHtjmiJSolNrVSDorIxHGHMU61CbonRkmwA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@aws-sdk/core": "^3.978.1",
|
||||||
|
"@aws-sdk/nested-clients": "^3.997.46",
|
||||||
|
"@aws-sdk/types": "^3.974.6",
|
||||||
|
"@smithy/core": "^3.35.0",
|
||||||
|
"@smithy/types": "^4.19.0",
|
||||||
|
"tslib": "^2.6.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@aws-sdk/middleware-sdk-s3": {
|
||||||
|
"version": "3.972.77",
|
||||||
|
"resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.77.tgz",
|
||||||
|
"integrity": "sha512-E7W2UOeUoc+lg3uIfR/dM7ZwusHwhBQrKMnlkRv4EXRR+C0YtV1pg25xC7GdZIhXH+NAMgZPCbE7o5to2cjFiw==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@aws-sdk/core": "^3.978.1",
|
||||||
|
"@aws-sdk/signature-v4-multi-region": "^3.996.47",
|
||||||
|
"@aws-sdk/types": "^3.974.6",
|
||||||
|
"@smithy/core": "^3.35.0",
|
||||||
|
"@smithy/types": "^4.19.0",
|
||||||
|
"tslib": "^2.6.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@aws-sdk/nested-clients": {
|
||||||
|
"version": "3.997.46",
|
||||||
|
"resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.46.tgz",
|
||||||
|
"integrity": "sha512-oRxtBcka/JGHGs9l9p9IVajGoTP8vTPmoAzdHGy4Qcy9P5vPnDf6nhIeM/COQNY9k/OahImTRaLkHftoXvfcmQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@aws-sdk/core": "^3.978.1",
|
||||||
|
"@aws-sdk/signature-v4-multi-region": "^3.996.47",
|
||||||
|
"@aws-sdk/types": "^3.974.6",
|
||||||
|
"@smithy/core": "^3.35.0",
|
||||||
|
"@smithy/fetch-http-handler": "^5.8.0",
|
||||||
|
"@smithy/node-http-handler": "^4.12.1",
|
||||||
|
"@smithy/types": "^4.19.0",
|
||||||
|
"tslib": "^2.6.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@aws-sdk/signature-v4-multi-region": {
|
||||||
|
"version": "3.996.47",
|
||||||
|
"resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.47.tgz",
|
||||||
|
"integrity": "sha512-Zk08macMvQTHzQJCLJVkOlviVoqwYMrpXv4lmLN7b7sAbiMoOK7Go0NYdR5UeF+MW8LIbRmwrNy9u/5VvX1U5g==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@aws-sdk/types": "^3.974.6",
|
||||||
|
"@smithy/signature-v4": "^5.7.3",
|
||||||
|
"@smithy/types": "^4.19.0",
|
||||||
|
"tslib": "^2.6.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@aws-sdk/token-providers": {
|
||||||
|
"version": "3.1138.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1138.0.tgz",
|
||||||
|
"integrity": "sha512-GpyAr0DD63YOEmYFM6Df+gJuIgC92MMTiBK4FTKfxii5MJ9ge20epR7LyroulscYlG89J+ZB2ivFDPjvfQhzdw==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@aws-sdk/core": "^3.978.1",
|
||||||
|
"@aws-sdk/nested-clients": "^3.997.46",
|
||||||
|
"@aws-sdk/types": "^3.974.6",
|
||||||
|
"@smithy/core": "^3.35.0",
|
||||||
|
"@smithy/types": "^4.19.0",
|
||||||
|
"tslib": "^2.6.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@aws-sdk/types": {
|
||||||
|
"version": "3.974.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.6.tgz",
|
||||||
|
"integrity": "sha512-v/clNZzZnDxGyvpHMOGpJKVXFAExJzUNAAjaWGdcx8QAcXLGwTaOkw33p5SHAi0YAioK32xB3hWwOekRVfmfKg==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@smithy/types": "^4.19.0",
|
||||||
|
"tslib": "^2.6.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@aws-sdk/xml-builder": {
|
||||||
|
"version": "3.972.41",
|
||||||
|
"resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.41.tgz",
|
||||||
|
"integrity": "sha512-ctjVSyCMegrWfXlx6VqzSBFI6UqmQ5ZlnfMhdLIiWmhoH8UAQxSCP5N3OpG7X3k4LnS7ou74C4mt20+bfTW2aQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@smithy/types": "^4.19.0",
|
||||||
|
"tslib": "^2.6.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@aws/lambda-invoke-store": {
|
||||||
|
"version": "0.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.3.0.tgz",
|
||||||
|
"integrity": "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@emnapi/runtime": {
|
"node_modules/@emnapi/runtime": {
|
||||||
"version": "1.11.3",
|
"version": "1.11.3",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
|
||||||
@@ -654,6 +964,159 @@
|
|||||||
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
|
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/@redis/bloom": {
|
||||||
|
"version": "5.12.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-5.12.1.tgz",
|
||||||
|
"integrity": "sha512-PUUfv+ms7jgPSBVoo/DN4AkPHj4D5TZSd6SbJX7egzBplkYUcKmHRE8RKia7UtZ8bSQbLguLvxVO+asKtQfZWA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 18.19.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@redis/client": "^5.12.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@redis/client": {
|
||||||
|
"version": "5.12.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@redis/client/-/client-5.12.1.tgz",
|
||||||
|
"integrity": "sha512-7aPGWeqA3uFm43o19umzdl16CEjK/JQGtSXVPevplTaOU3VJA/rseBC1QvYUz9lLDIMBimc4SW/zrW4S89BaCA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cluster-key-slot": "1.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 18.19.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@node-rs/xxhash": "^1.1.0",
|
||||||
|
"@opentelemetry/api": ">=1 <2"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@node-rs/xxhash": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@opentelemetry/api": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@redis/json": {
|
||||||
|
"version": "5.12.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@redis/json/-/json-5.12.1.tgz",
|
||||||
|
"integrity": "sha512-eOze75esLve4vfqDel7aMX08CNaiLLQS2fV8mpRN9NxPe1rVR4vQyYiW/OgtGUysF6QOr9ANhfxABKNOJfXdKg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 18.19.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@redis/client": "^5.12.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@redis/search": {
|
||||||
|
"version": "5.12.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@redis/search/-/search-5.12.1.tgz",
|
||||||
|
"integrity": "sha512-ItlxbxC9cKI6IU1TLWoczwJCRb6TdmkEpWv05UrPawqaAnWGRu3rcIqsc5vN483T2fSociuyV1UkWIL5I4//2w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 18.19.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@redis/client": "^5.12.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@redis/time-series": {
|
||||||
|
"version": "5.12.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-5.12.1.tgz",
|
||||||
|
"integrity": "sha512-c6JL6E3EcZJuNqKFz+KM+l9l5mpcQiKvTwgA3blt5glWJ8hjDk0yeHN3beE/MpqYIQ8UEX44ItQzgkE/gCBELQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 18.19.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@redis/client": "^5.12.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@smithy/core": {
|
||||||
|
"version": "3.35.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.35.0.tgz",
|
||||||
|
"integrity": "sha512-zRMhfkByhT2snNdr1si24vJitU6Cr9ix2MikUfWmkAgp4jrNP0GcKSP5YvwQ+TlI8AZXER5QOGJn3JsVtSD9/A==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@smithy/types": "^4.19.0",
|
||||||
|
"tslib": "^2.6.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@smithy/credential-provider-imds": {
|
||||||
|
"version": "4.5.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.5.2.tgz",
|
||||||
|
"integrity": "sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@smithy/core": "^3.33.2",
|
||||||
|
"@smithy/types": "^4.17.2",
|
||||||
|
"tslib": "^2.6.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@smithy/fetch-http-handler": {
|
||||||
|
"version": "5.8.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.8.0.tgz",
|
||||||
|
"integrity": "sha512-ycSJu3tFAQ4v04CBB0agqFMVsSQ1iG3yw+SpgxRqKfaURpQD4CZ8Wn0zPMmSnOuTpTh65Vz+EA0rMrw089wvkA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@smithy/core": "^3.33.3",
|
||||||
|
"@smithy/types": "^4.18.0",
|
||||||
|
"tslib": "^2.6.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@smithy/node-http-handler": {
|
||||||
|
"version": "4.12.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.12.1.tgz",
|
||||||
|
"integrity": "sha512-ThMkboGeONWXAelq9FvGsuJC4rOi+qyC4/zhUF58xYpxUg5sQKx2VXZYJmtNjr4dSuBJ1HeJXETQILCz3wOHvw==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@smithy/core": "^3.33.3",
|
||||||
|
"@smithy/types": "^4.18.0",
|
||||||
|
"tslib": "^2.6.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@smithy/signature-v4": {
|
||||||
|
"version": "5.7.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.7.4.tgz",
|
||||||
|
"integrity": "sha512-tHy0K0VtqNd5Y7Y41h0a0Lhh0L1GzC08dTWg0F7vRJWFtTENg7IZikf3wQkanYIRdb7ngoIPMTmqgUi401fEeQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@smithy/core": "^3.35.0",
|
||||||
|
"@smithy/types": "^4.19.0",
|
||||||
|
"tslib": "^2.6.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@smithy/types": {
|
||||||
|
"version": "4.19.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.19.0.tgz",
|
||||||
|
"integrity": "sha512-r7jh49VJxGerfAcTQA6gXcKc+98zOp/tqRwzYjgOE+iSQsP6cEU1hq2QzbuipmP68QtYdY9wKEhiCQZIzHgZ4Q==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.6.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/abbrev": {
|
"node_modules/abbrev": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz",
|
||||||
@@ -807,6 +1270,12 @@
|
|||||||
"npm": "1.2.8000 || >= 1.4.16"
|
"npm": "1.2.8000 || >= 1.4.16"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/bowser": {
|
||||||
|
"version": "2.14.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz",
|
||||||
|
"integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/brace-expansion": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "1.1.18",
|
"version": "1.1.18",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
|
||||||
@@ -881,6 +1350,15 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/cluster-key-slot": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/color-support": {
|
"node_modules/color-support": {
|
||||||
"version": "1.1.3",
|
"version": "1.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz",
|
||||||
@@ -2067,6 +2545,22 @@
|
|||||||
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/redis": {
|
||||||
|
"version": "5.12.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/redis/-/redis-5.12.1.tgz",
|
||||||
|
"integrity": "sha512-LDsoVvb/CpoV9EN3FXvgvSHNJWuCIzl9MiO3ppOevuGLpSGJhwfQjpEwfFJcQvNSddHADDdZaWx0HnmMxRXG7g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@redis/bloom": "5.12.1",
|
||||||
|
"@redis/client": "5.12.1",
|
||||||
|
"@redis/json": "5.12.1",
|
||||||
|
"@redis/search": "5.12.1",
|
||||||
|
"@redis/time-series": "5.12.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 18.19.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/rimraf": {
|
"node_modules/rimraf": {
|
||||||
"version": "3.0.2",
|
"version": "3.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
|
||||||
@@ -2402,8 +2896,7 @@
|
|||||||
"version": "2.8.1",
|
"version": "2.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||||
"license": "0BSD",
|
"license": "0BSD"
|
||||||
"optional": true
|
|
||||||
},
|
},
|
||||||
"node_modules/type-is": {
|
"node_modules/type-is": {
|
||||||
"version": "1.6.18",
|
"version": "1.6.18",
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
"start": "node server.js"
|
"start": "node server.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@aws-sdk/client-s3": "^3.1141.0",
|
||||||
"bcrypt": "^5.1.1",
|
"bcrypt": "^5.1.1",
|
||||||
"express": "^4.21.0",
|
"express": "^4.21.0",
|
||||||
"express-rate-limit": "^8.7.0",
|
"express-rate-limit": "^8.7.0",
|
||||||
@@ -14,6 +15,7 @@
|
|||||||
"lucide": "^1.44.0",
|
"lucide": "^1.44.0",
|
||||||
"multer": "^1.4.5-lts.1",
|
"multer": "^1.4.5-lts.1",
|
||||||
"pg": "^8.13.0",
|
"pg": "^8.13.0",
|
||||||
|
"redis": "^5.12.1",
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
"tar": "^7.4.3"
|
"tar": "^7.4.3"
|
||||||
},
|
},
|
||||||
|
|||||||
+117
-16
@@ -3,26 +3,50 @@
|
|||||||
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:var(--bg);color:var(--text);min-height:100dvh}
|
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:var(--bg);color:var(--text);min-height:100dvh}
|
||||||
|
|
||||||
.layout{display:flex;min-height:100dvh}
|
.layout{display:flex;min-height:100dvh}
|
||||||
.sidebar{width:var(--sidebar);background:var(--card);border-right:1px solid var(--border);padding:24px 0;position:fixed;top:0;left:0;height:100dvh;display:flex;flex-direction:column}
|
.sidebar{width:var(--sidebar);background:var(--card);border-right:1px solid var(--border);position:fixed;top:0;left:0;height:100dvh;display:flex;flex-direction:column;z-index:50;box-shadow:1px 0 0 rgba(0,0,0,.02);user-select:none}
|
||||||
.sidebar .logo{font-size:1.1rem;font-weight:700;padding:0 20px 20px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:10px;min-width:0}
|
.sidebar .logo{height:58px;padding:0 16px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:10px;min-width:0;flex-shrink:0;font-size:1.02rem;font-weight:700;letter-spacing:-.01em;background:var(--card)}
|
||||||
.sidebar .logo-ic{height:28px;width:auto;min-width:28px;background:var(--bg);border:1px solid var(--border);display:flex;align-items:center;justify-content:center;overflow:hidden;flex-shrink:0}
|
.sidebar .logo-ic{height:28px;width:auto;min-width:28px;border-radius:6px;background:var(--bg);border:1px solid var(--border);display:flex;align-items:center;justify-content:center;overflow:hidden;flex-shrink:0}
|
||||||
.sidebar .logo-ic img{max-width:100%;max-height:100%;object-fit:contain}
|
.sidebar .logo-ic img{max-width:100%;max-height:100%;object-fit:contain}
|
||||||
.sidebar #sidebarName{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0}
|
.sidebar #sidebarName{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0}
|
||||||
.user-box{padding:14px 20px;border-bottom:1px solid var(--border);display:flex;flex-direction:column;gap:2px}
|
.user-box{padding:10px 14px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:10px;flex-shrink:0;background:var(--card)}
|
||||||
.user-box .user-name{font-weight:600;font-size:.9rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
.user-box .user-avatar{width:32px;height:32px;border-radius:8px;background:rgba(37,99,235,.08);color:var(--accent);display:flex;align-items:center;justify-content:center;flex-shrink:0}
|
||||||
.user-box .user-role{color:var(--muted);font-size:.72rem}
|
.user-box .user-avatar svg{width:16px;height:16px}
|
||||||
.sidebar nav{flex:1;padding:12px 0}
|
.user-box .user-meta{display:flex;flex-direction:column;min-width:0;flex:1}
|
||||||
.sidebar nav a{display:flex;align-items:center;gap:10px;padding:10px 20px;color:var(--muted);text-decoration:none;font-size:.9rem;transition:all .15s;border-left:3px solid transparent}
|
.user-box .user-name{font-weight:600;font-size:.85rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text);line-height:1.25}
|
||||||
|
.user-box .user-role{color:var(--muted);font-size:.7rem;font-weight:500;line-height:1.2}
|
||||||
|
|
||||||
|
.nav-scroll-wrap{flex:1 1 auto;min-height:0;overflow-y:auto;overflow-x:hidden;overscroll-behavior:contain;padding:8px 0;scrollbar-width:thin;scrollbar-color:rgba(0,0,0,.15) transparent}
|
||||||
|
.nav-scroll-wrap::-webkit-scrollbar{width:5px}
|
||||||
|
.nav-scroll-wrap::-webkit-scrollbar-track{background:transparent}
|
||||||
|
.nav-scroll-wrap::-webkit-scrollbar-thumb{background:rgba(0,0,0,.12);border-radius:10px}
|
||||||
|
.nav-scroll-wrap::-webkit-scrollbar-thumb:hover{background:rgba(0,0,0,.22)}
|
||||||
|
|
||||||
|
.sidebar nav{display:flex;flex-direction:column;gap:10px;padding:0 8px}
|
||||||
|
.nav-group{display:flex;flex-direction:column;gap:2px}
|
||||||
|
.nav-group-title{font-size:.66rem;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);padding:6px 10px 4px;user-select:none;opacity:.8}
|
||||||
|
.nav-items{display:flex;flex-direction:column;gap:1px}
|
||||||
|
|
||||||
|
.sidebar nav a{display:flex;align-items:center;gap:10px;padding:7px 10px;border-radius:8px;color:var(--muted);text-decoration:none;font-size:.84rem;font-weight:500;line-height:1.3;transition:all .14s ease;position:relative}
|
||||||
.sidebar nav a:hover{color:var(--text);background:var(--bg)}
|
.sidebar nav a:hover{color:var(--text);background:var(--bg)}
|
||||||
.sidebar nav a.active{color:var(--accent);background:rgba(37,99,235,.06);border-left-color:var(--accent);font-weight:500}
|
.sidebar nav a.active{color:var(--accent);background:rgba(37,99,235,.08);font-weight:600}
|
||||||
.sidebar nav a i,.sidebar .bottom a i{width:16px;height:16px;flex-shrink:0}
|
.sidebar nav a.active::before{content:'';position:absolute;left:0;top:6px;bottom:6px;width:3px;border-radius:0 3px 3px 0;background:var(--accent)}
|
||||||
|
.sidebar nav a i,.sidebar .bottom a i{width:16px;height:16px;flex-shrink:0;transition:transform .14s ease}
|
||||||
|
.sidebar nav a:hover i{transform:scale(1.06)}
|
||||||
|
.sidebar nav a .nav-text{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||||
|
|
||||||
|
.sidebar-badge{min-width:18px;height:18px;padding:0 6px;border-radius:999px;background:#ef4444;color:#fff;font-size:.65rem;font-weight:700;line-height:18px;text-align:center;display:inline-flex;align-items:center;justify-content:center;flex-shrink:0;margin-left:auto;box-shadow:0 1px 3px rgba(239,68,68,.35)}
|
||||||
|
.sidebar-badge[hidden]{display:none}
|
||||||
|
|
||||||
.sidebar nav a[data-nav=dashboard] svg{color:#6366f1}
|
.sidebar nav a[data-nav=dashboard] svg{color:#6366f1}
|
||||||
.sidebar nav a[data-nav=journal] svg{color:#0ea5e9}
|
.sidebar nav a[data-nav=journal] svg{color:#0ea5e9}
|
||||||
|
.sidebar nav a[data-nav=notifications] svg{color:#f59e0b}
|
||||||
.sidebar nav a[data-nav=students] svg{color:#14b8a6}
|
.sidebar nav a[data-nav=students] svg{color:#14b8a6}
|
||||||
.sidebar nav a[data-nav=groups] svg{color:#8b5cf6}
|
.sidebar nav a[data-nav=groups] svg{color:#8b5cf6}
|
||||||
.sidebar nav a[data-nav=files] svg{color:#f59e0b}
|
.sidebar nav a[data-nav=files] svg{color:#f59e0b}
|
||||||
|
.sidebar nav a[data-nav=photos] svg{color:#0891b2}
|
||||||
.sidebar nav a[data-nav=links] svg{color:#06b6d4}
|
.sidebar nav a[data-nav=links] svg{color:#06b6d4}
|
||||||
.sidebar nav a[data-nav=trash] svg{color:#ef4444}
|
.sidebar nav a[data-nav=trash] svg{color:#ef4444}
|
||||||
|
.sidebar nav a[data-nav=modules] svg{color:#3b82f6}
|
||||||
.sidebar nav a[data-nav=branches] svg{color:#10b981}
|
.sidebar nav a[data-nav=branches] svg{color:#10b981}
|
||||||
.sidebar nav a[data-nav=users] svg{color:#ec4899}
|
.sidebar nav a[data-nav=users] svg{color:#ec4899}
|
||||||
.sidebar nav a[data-nav=worker] svg{color:#a855f7}
|
.sidebar nav a[data-nav=worker] svg{color:#a855f7}
|
||||||
@@ -30,12 +54,18 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
|
|||||||
.sidebar nav a[data-nav=bans] svg{color:#dc2626}
|
.sidebar nav a[data-nav=bans] svg{color:#dc2626}
|
||||||
.sidebar nav a[data-nav=settings] svg{color:#475569}
|
.sidebar nav a[data-nav=settings] svg{color:#475569}
|
||||||
.sidebar nav a.active svg{color:var(--accent)}
|
.sidebar nav a.active svg{color:var(--accent)}
|
||||||
.sidebar .bottom a[data-nav=home] svg{color:#2563eb}
|
|
||||||
.sidebar .bottom{padding:12px 20px;border-top:1px solid var(--border)}
|
.sidebar .bottom{padding:10px 10px 12px;border-top:1px solid var(--border);display:flex;flex-direction:column;gap:8px;flex-shrink:0;background:var(--card)}
|
||||||
.sidebar .version{color:var(--muted);font-size:.72rem;padding:0 20px 8px}
|
.sidebar .bottom-actions{display:flex;flex-direction:column;gap:1px}
|
||||||
.sidebar .bottom a{display:flex;align-items:center;gap:10px;color:var(--muted);text-decoration:none;font-size:.8rem;padding:6px 0}
|
.sidebar .bottom-actions a{display:flex;align-items:center;gap:10px;padding:6px 10px;border-radius:8px;color:var(--muted);text-decoration:none;font-size:.82rem;font-weight:500;transition:all .14s ease;position:relative}
|
||||||
.sidebar .bottom a:hover{color:var(--text)}
|
.sidebar .bottom-actions a:hover{color:var(--text);background:var(--bg)}
|
||||||
.sidebar .bottom .logout{color:#ef4444;cursor:pointer;margin-top:4px}
|
.sidebar .bottom-actions a[data-nav=home] svg{color:#2563eb}
|
||||||
|
.sidebar .bottom-actions .notify-bell svg{color:#f59e0b}
|
||||||
|
.sidebar .bottom-footer{display:flex;align-items:center;justify-content:space-between;gap:8px;padding-top:6px;border-top:1px dashed var(--border)}
|
||||||
|
.sidebar .bottom-footer .logout-btn{display:inline-flex;align-items:center;gap:6px;padding:4px 8px;border-radius:6px;color:#ef4444;text-decoration:none;font-size:.78rem;font-weight:600;cursor:pointer;transition:background .14s ease}
|
||||||
|
.sidebar .bottom-footer .logout-btn:hover{background:rgba(239,68,68,.08)}
|
||||||
|
.sidebar .bottom-footer .logout-btn svg{width:14px;height:14px;color:#ef4444}
|
||||||
|
.sidebar .bottom-footer .version{color:var(--muted);font-size:.7rem;font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;opacity:.8}
|
||||||
|
|
||||||
.main{flex:1;margin-left:var(--sidebar);padding:32px}
|
.main{flex:1;margin-left:var(--sidebar);padding:32px}
|
||||||
.main h2{font-size:1.3rem;font-weight:600;margin-bottom:20px}
|
.main h2{font-size:1.3rem;font-weight:600;margin-bottom:20px}
|
||||||
@@ -257,6 +287,11 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
|
|||||||
.del-btn{background:none;border:none;color:#ef4444;cursor:pointer;font-size:1.15rem;padding:4px;flex-shrink:0}
|
.del-btn{background:none;border:none;color:#ef4444;cursor:pointer;font-size:1.15rem;padding:4px;flex-shrink:0}
|
||||||
.del-btn:hover{opacity:.8}
|
.del-btn:hover{opacity:.8}
|
||||||
.trash-time{color:var(--muted);font-size:.75rem;margin-top:8px}
|
.trash-time{color:var(--muted);font-size:.75rem;margin-top:8px}
|
||||||
|
.pending-toggle{margin-bottom:16px;max-width:680px}
|
||||||
|
.trash-section{margin-top:24px}
|
||||||
|
.trash-section h4{margin:16px 0 8px;font-size:.95rem;font-weight:600}
|
||||||
|
.card.pending{border-color:#d97706;background:linear-gradient(180deg,rgba(217,119,6,.06),transparent 55%)}
|
||||||
|
.trash-time.warn{color:#d97706;font-weight:600}
|
||||||
|
|
||||||
/* --- Настройки (UX/UI) --- */
|
/* --- Настройки (UX/UI) --- */
|
||||||
.page-head{display:flex;align-items:baseline;gap:14px;margin-bottom:24px}
|
.page-head{display:flex;align-items:baseline;gap:14px;margin-bottom:24px}
|
||||||
@@ -389,6 +424,7 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
|
|||||||
.sys-bar-wrap{height:6px;background:var(--border);border-radius:3px;overflow:hidden;margin-top:4px}
|
.sys-bar-wrap{height:6px;background:var(--border);border-radius:3px;overflow:hidden;margin-top:4px}
|
||||||
.sys-bar{height:100%;transition:width .3s,background .3s}
|
.sys-bar{height:100%;transition:width .3s,background .3s}
|
||||||
.sys-meta{font-size:.7rem;color:var(--muted);margin-top:4px}
|
.sys-meta{font-size:.7rem;color:var(--muted);margin-top:4px}
|
||||||
|
.sys-inline{display:inline-flex;align-items:center;gap:7px;justify-content:flex-end}
|
||||||
|
|
||||||
.actions-row{display:flex;align-items:center;gap:12px;flex-wrap:wrap}
|
.actions-row{display:flex;align-items:center;gap:12px;flex-wrap:wrap}
|
||||||
.audit-table{width:100%;border-collapse:collapse;font-size:.85rem}
|
.audit-table{width:100%;border-collapse:collapse;font-size:.85rem}
|
||||||
@@ -401,6 +437,27 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
|
|||||||
.audit-details:hover{text-decoration:underline}
|
.audit-details:hover{text-decoration:underline}
|
||||||
.audit-details-body{background:var(--bg);border:1px solid var(--border);border-radius:8px;padding:12px;max-height:60vh;overflow:auto}
|
.audit-details-body{background:var(--bg);border:1px solid var(--border);border-radius:8px;padding:12px;max-height:60vh;overflow:auto}
|
||||||
.audit-details-body pre{margin:0;white-space:pre-wrap;word-break:break-word;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:.78rem;line-height:1.45;color:var(--text)}
|
.audit-details-body pre{margin:0;white-space:pre-wrap;word-break:break-word;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:.78rem;line-height:1.45;color:var(--text)}
|
||||||
|
.audit-details-body.audit-loading{opacity:.6}
|
||||||
|
.audit-details-head{display:flex;flex-wrap:wrap;gap:6px;margin-bottom:10px}
|
||||||
|
.audit-pill{background:var(--card);border:1px solid var(--border);border-radius:999px;padding:3px 10px;font-size:.72rem;color:var(--muted)}
|
||||||
|
.audit-changes{display:flex;flex-direction:column;gap:12px}
|
||||||
|
.audit-change{background:var(--card);border:1px solid var(--border);border-radius:8px;padding:10px 12px}
|
||||||
|
.audit-change-title{font-weight:600;font-size:.82rem;margin-bottom:6px}
|
||||||
|
.audit-change-stats{display:flex;flex-wrap:wrap;gap:10px;font-size:.75rem;margin-bottom:8px}
|
||||||
|
.audit-change-line{font-size:.8rem;line-height:1.6;word-break:break-word}
|
||||||
|
.audit-tag-del,.audit-tag-add{display:inline-block;min-width:52px;font-size:.7rem;text-transform:uppercase;letter-spacing:.03em;padding:1px 6px;border-radius:4px;margin-right:6px}
|
||||||
|
.audit-tag-del{background:rgba(239,68,68,.14);color:#dc2626}
|
||||||
|
.audit-tag-add{background:rgba(22,163,74,.14);color:#16a34a}
|
||||||
|
.audit-stat-add{color:#16a34a;font-weight:600}
|
||||||
|
.audit-stat-del{color:#dc2626;font-weight:600}
|
||||||
|
.audit-stat-muted{color:var(--muted)}
|
||||||
|
.audit-diff{white-space:pre-wrap;word-break:break-word;font-size:.85rem;line-height:1.6;background:var(--bg);border:1px solid var(--border);border-radius:6px;padding:10px;max-height:40vh;overflow:auto}
|
||||||
|
.audit-diff-add{background:rgba(22,163,74,.18);color:#14532d;text-decoration:none;border-radius:3px;padding:0 2px}
|
||||||
|
.audit-diff-del{background:rgba(239,68,68,.16);color:#7f1d1d;border-radius:3px;padding:0 2px}
|
||||||
|
.audit-truncated{margin-top:6px;font-size:.72rem;color:#b45309}
|
||||||
|
.audit-raw{margin-top:12px;font-size:.78rem;color:var(--muted)}
|
||||||
|
.audit-raw summary{cursor:pointer}
|
||||||
|
.audit-raw pre{margin-top:8px;background:var(--card);border:1px solid var(--border);border-radius:6px;padding:10px}
|
||||||
|
|
||||||
/* --- AI Correction --- */
|
/* --- AI Correction --- */
|
||||||
.ai-btn{background:none;border:none;color:var(--muted);cursor:pointer;font-size:1.1rem;padding:4px 8px;border-radius:6px;transition:color .15s,background .15s;vertical-align:middle;margin-left:6px}
|
.ai-btn{background:none;border:none;color:var(--muted);cursor:pointer;font-size:1.1rem;padding:4px 8px;border-radius:6px;transition:color .15s,background .15s;vertical-align:middle;margin-left:6px}
|
||||||
@@ -541,4 +598,48 @@ body[data-page=settings] .page-head{align-items:flex-end}
|
|||||||
}
|
}
|
||||||
@media (prefers-reduced-motion: reduce){.save-bar{animation:none}.toggle-track,.toggle-thumb{transition:none}.icon-btn.spinning svg{animation:none}}
|
@media (prefers-reduced-motion: reduce){.save-bar{animation:none}.toggle-track,.toggle-thumb{transition:none}.icon-btn.spinning svg{animation:none}}
|
||||||
|
|
||||||
|
/* --- Notifications --- */
|
||||||
|
.notify-bell{position:relative}
|
||||||
|
.notify-badge{position:absolute;left:20px;top:-3px;min-width:16px;height:16px;padding:0 4px;border-radius:999px;background:#ef4444;color:#fff;font-size:.62rem;font-weight:700;line-height:16px;text-align:center}
|
||||||
|
.notify-badge[hidden]{display:none}
|
||||||
|
.notify-panel{position:fixed;left:12px;bottom:96px;width:min(380px,calc(100vw - 24px));max-height:min(70vh,520px);display:flex;flex-direction:column;background:var(--card);border:1px solid var(--border);border-radius:14px;box-shadow:0 18px 40px rgba(0,0,0,.18);z-index:90;overflow:hidden}
|
||||||
|
.notify-panel[hidden]{display:none}
|
||||||
|
.notify-head{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:12px 14px;border-bottom:1px solid var(--border);font-size:.9rem}
|
||||||
|
.notify-list{overflow-y:auto;flex:1}
|
||||||
|
.notify-empty{padding:22px 14px;color:var(--muted);font-size:.85rem;text-align:center}
|
||||||
|
.notify-item{display:flex;gap:10px;padding:11px 14px;border-bottom:1px solid var(--border);text-decoration:none;color:var(--text);transition:background .15s}
|
||||||
|
.notify-item:hover{background:var(--bg)}
|
||||||
|
.notify-item:last-child{border-bottom:none}
|
||||||
|
.notify-item.unread{background:rgba(37,99,235,.05)}
|
||||||
|
.notify-item.unread .notify-title{font-weight:600}
|
||||||
|
.notify-item.level-warning .notify-ic{background:rgba(245,158,11,.14);color:#f59e0b}
|
||||||
|
.notify-item.level-critical .notify-ic{background:rgba(239,68,68,.14);color:#ef4444}
|
||||||
|
.notify-ic{display:flex;align-items:center;justify-content:center;width:28px;height:28px;border-radius:8px;background:var(--bg);color:var(--muted);flex-shrink:0}
|
||||||
|
.notify-ic svg{width:15px;height:15px}
|
||||||
|
.notify-body{display:flex;flex-direction:column;gap:2px;min-width:0}
|
||||||
|
.notify-title{font-size:.84rem;line-height:1.3}
|
||||||
|
.notify-text{font-size:.76rem;color:var(--muted);line-height:1.35;overflow-wrap:anywhere}
|
||||||
|
.notify-time{font-size:.7rem;color:var(--muted)}
|
||||||
|
.notify-foot{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:10px 14px;border-top:1px solid var(--border);font-size:.78rem}
|
||||||
|
.notify-foot a{color:var(--accent);text-decoration:none}
|
||||||
|
.notify-foot a:hover{text-decoration:underline}
|
||||||
|
.notify-link{background:none;border:none;color:var(--accent);font-size:.78rem;cursor:pointer;padding:0}
|
||||||
|
.notify-link:hover{text-decoration:underline}
|
||||||
|
.notify-types{display:flex;flex-direction:column;gap:6px;margin-top:6px}
|
||||||
|
.notify-type-badge{font-size:.65rem;font-weight:700;color:var(--muted);background:var(--bg);border:1px solid var(--border);border-radius:999px;padding:1px 7px;margin-left:6px;white-space:nowrap}
|
||||||
|
.notify-page-head{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:14px}
|
||||||
|
.notify-page-head .grow{flex:1}
|
||||||
|
.notify-page-list{display:flex;flex-direction:column;gap:8px}
|
||||||
|
.notify-card{display:flex;gap:12px;padding:14px 16px;background:var(--card);border:1px solid var(--border);border-radius:12px}
|
||||||
|
.notify-card.unread{border-color:rgba(37,99,235,.45);background:rgba(37,99,235,.04)}
|
||||||
|
.notify-card.level-warning{border-left:3px solid #f59e0b}
|
||||||
|
.notify-card.level-critical{border-left:3px solid #ef4444}
|
||||||
|
.notify-card .notify-body{flex:1}
|
||||||
|
.notify-card .notify-title{font-size:.92rem}
|
||||||
|
.notify-card .notify-text{font-size:.82rem}
|
||||||
|
.notify-card-actions{display:flex;flex-direction:column;gap:6px;align-items:flex-end}
|
||||||
|
@media(max-width:1024px){
|
||||||
|
.notify-panel{left:12px;right:12px;width:auto;bottom:12px}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+272
-6
@@ -49,8 +49,10 @@ function buildSidebar(active) {
|
|||||||
const base = [
|
const base = [
|
||||||
{ page: 'dashboard', label: 'Дашборд', icon: 'layout-dashboard' },
|
{ page: 'dashboard', label: 'Дашборд', icon: 'layout-dashboard' },
|
||||||
{ page: 'journal', label: 'Журнал', icon: 'book-open' },
|
{ page: 'journal', label: 'Журнал', icon: 'book-open' },
|
||||||
|
{ page: 'notifications', label: 'Уведомления', icon: 'bell', badge: true },
|
||||||
{ page: 'students', label: 'Ученики', icon: 'graduation-cap' },
|
{ page: 'students', label: 'Ученики', icon: 'graduation-cap' },
|
||||||
{ page: 'groups', label: 'Группы', icon: 'users' },
|
{ page: 'groups', label: 'Группы', icon: 'users' },
|
||||||
|
{ page: 'photos', label: 'Фото', icon: 'Image' },
|
||||||
{ page: 'files', label: 'Файлы', icon: 'folder' },
|
{ page: 'files', label: 'Файлы', icon: 'folder' },
|
||||||
{ page: 'links', label: 'Ссылки', icon: 'link' },
|
{ page: 'links', label: 'Ссылки', icon: 'link' },
|
||||||
{ page: 'trash', label: 'Корзина', icon: 'trash-2' },
|
{ page: 'trash', label: 'Корзина', icon: 'trash-2' },
|
||||||
@@ -64,29 +66,70 @@ function buildSidebar(active) {
|
|||||||
{ page: 'bans', label: 'Блокировки', icon: 'shield-off' },
|
{ page: 'bans', label: 'Блокировки', icon: 'shield-off' },
|
||||||
{ page: 'settings', label: 'Настройки', icon: 'settings' },
|
{ page: 'settings', label: 'Настройки', icon: 'settings' },
|
||||||
];
|
];
|
||||||
const nav = [...base, ...(isAdmin() ? adminNav : [])];
|
|
||||||
const userName = currentUser?.name || currentUser?.username || '';
|
const userName = currentUser?.name || currentUser?.username || '';
|
||||||
const roleLabel = isAdmin() ? 'Администратор' : 'Тутор';
|
const roleLabel = isAdmin() ? 'Администратор' : 'Тутор';
|
||||||
|
const renderItem = n => {
|
||||||
|
const isAct = n.page === active;
|
||||||
|
const badgeHtml = n.badge ? `<span class="sidebar-badge" id="sidebarNotifyBadge" ${notifyUnread > 0 ? '' : 'hidden'}>${notifyUnread > 99 ? '99+' : notifyUnread}</span>` : '';
|
||||||
|
return `<a href="${n.page}.html" data-nav="${n.page}" class="${isAct ? 'active' : ''}" title="${n.label}">
|
||||||
|
<i data-lucide="${n.icon}"></i>
|
||||||
|
<span class="nav-text">${n.label}</span>
|
||||||
|
${badgeHtml}
|
||||||
|
</a>`;
|
||||||
|
};
|
||||||
el.innerHTML = `
|
el.innerHTML = `
|
||||||
<div class="logo">
|
<div class="logo">
|
||||||
<span class="logo-ic" id="sidebarLogo"></span>
|
<span class="logo-ic" id="sidebarLogo"></span>
|
||||||
<span id="sidebarName">Админ-панель</span>
|
<span id="sidebarName">Админ-панель</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="user-box">
|
<div class="user-box">
|
||||||
<div class="user-name">${esc(userName)}</div>
|
<div class="user-avatar"><i data-lucide="${isAdmin() ? 'shield-check' : 'user'}"></i></div>
|
||||||
|
<div class="user-meta">
|
||||||
|
<div class="user-name" title="${esc(userName)}">${esc(userName)}</div>
|
||||||
<div class="user-role">${roleLabel}</div>
|
<div class="user-role">${roleLabel}</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="nav-scroll-wrap">
|
||||||
<nav>
|
<nav>
|
||||||
${nav.map(n => `<a href="${n.page}.html" data-nav="${n.page}" class="${n.page === active ? 'active' : ''}"><i data-lucide="${n.icon}"></i>${n.label}</a>`).join('')}
|
<div class="nav-group">
|
||||||
|
<div class="nav-group-title">Основное</div>
|
||||||
|
<div class="nav-items">
|
||||||
|
${base.map(renderItem).join('')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
${isAdmin() ? `
|
||||||
|
<div class="nav-group">
|
||||||
|
<div class="nav-group-title">Администрирование</div>
|
||||||
|
<div class="nav-items">
|
||||||
|
${adminNav.map(renderItem).join('')}
|
||||||
|
</div>
|
||||||
|
</div>` : ''}
|
||||||
</nav>
|
</nav>
|
||||||
|
</div>
|
||||||
<div class="bottom">
|
<div class="bottom">
|
||||||
<div class="version" id="sidebarVersion"></div>
|
<div class="bottom-actions">
|
||||||
<a href="/" data-nav="home"><i data-lucide="home"></i>На главную</a>
|
<a href="#" class="notify-bell" id="notifyBell" title="Быстрые уведомления">
|
||||||
<a class="logout" href="#" data-action="logout"><i data-lucide="log-out"></i>Выйти</a>
|
<i data-lucide="bell"></i>
|
||||||
|
<span>Уведомления</span>
|
||||||
|
<span class="notify-badge" id="notifyBadge" ${notifyUnread > 0 ? '' : 'hidden'}>${notifyUnread > 99 ? '99+' : notifyUnread}</span>
|
||||||
|
</a>
|
||||||
|
<a href="/" data-nav="home" title="Перейти на главную страницу">
|
||||||
|
<i data-lucide="home"></i>
|
||||||
|
<span>На главную</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="bottom-footer">
|
||||||
|
<a class="logout-btn" href="#" data-action="logout" title="Выйти из аккаунта">
|
||||||
|
<i data-lucide="log-out"></i>
|
||||||
|
<span>Выйти</span>
|
||||||
|
</a>
|
||||||
|
<span class="version" id="sidebarVersion"></span>
|
||||||
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
renderIcons();
|
renderIcons();
|
||||||
loadVersion();
|
loadVersion();
|
||||||
loadBrand();
|
loadBrand();
|
||||||
|
initNotifications();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadBrand() {
|
async function loadBrand() {
|
||||||
@@ -122,6 +165,229 @@ async function loadVersion() {
|
|||||||
} catch { el.textContent = ''; }
|
} catch { el.textContent = ''; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Notifications ---
|
||||||
|
const NOTIFY_ICONS = {
|
||||||
|
'entry.new': 'book-open',
|
||||||
|
'entry.ai.corrected': 'sparkles',
|
||||||
|
'entry.ai.error': 'bot',
|
||||||
|
'photo.job.done': 'image',
|
||||||
|
'photo.job.error': 'image-off',
|
||||||
|
'ip.ban': 'shield-off',
|
||||||
|
'backup.restore': 'database',
|
||||||
|
'backup.create': 'download',
|
||||||
|
'system.test': 'send',
|
||||||
|
};
|
||||||
|
const NOTIFY_LEVELS = { warning: 'Предупреждение', critical: 'Важно', info: '' };
|
||||||
|
|
||||||
|
let notifyList = [];
|
||||||
|
let notifyUnread = 0;
|
||||||
|
let notifyStream = null;
|
||||||
|
let notifyReconnectTimer = null;
|
||||||
|
let desktopNotify = localStorage.getItem('notifyDesktop') === '1';
|
||||||
|
|
||||||
|
function notifyIcon(type) { return NOTIFY_ICONS[type] || 'bell'; }
|
||||||
|
|
||||||
|
function attrEsc(s) { return esc(String(s === null || s === undefined ? '' : s).replace(/"/g, '"')); }
|
||||||
|
|
||||||
|
function notifyTime(ts) {
|
||||||
|
const d = new Date(ts);
|
||||||
|
if (Number.isNaN(d.getTime())) return '';
|
||||||
|
const diff = Date.now() - d.getTime();
|
||||||
|
if (diff < 60000) return 'только что';
|
||||||
|
if (diff < 3600000) return `${Math.floor(diff / 60000)} мин назад`;
|
||||||
|
if (diff < 86400000) return `${Math.floor(diff / 3600000)} ч назад`;
|
||||||
|
return d.toLocaleString('ru-RU', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function setNotifyUnread(n) {
|
||||||
|
notifyUnread = Number(n) || 0;
|
||||||
|
const badge = document.getElementById('notifyBadge');
|
||||||
|
if (badge) {
|
||||||
|
badge.textContent = notifyUnread > 99 ? '99+' : String(notifyUnread);
|
||||||
|
badge.hidden = notifyUnread <= 0;
|
||||||
|
}
|
||||||
|
const navBadge = document.getElementById('sidebarNotifyBadge');
|
||||||
|
if (navBadge) {
|
||||||
|
navBadge.textContent = notifyUnread > 99 ? '99+' : String(notifyUnread);
|
||||||
|
navBadge.hidden = notifyUnread <= 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function notifyItemHtml(n, expanded) {
|
||||||
|
const level = NOTIFY_LEVELS[n.level] ? ` level-${n.level}` : '';
|
||||||
|
return `<a href="#" class="notify-item${n.read ? '' : ' unread'}${level}" data-notify-id="${n.id}" data-notify-link="${attrEsc(n.link || '')}">
|
||||||
|
<span class="notify-ic"><i data-lucide="${notifyIcon(n.type)}"></i></span>
|
||||||
|
<span class="notify-body">
|
||||||
|
<span class="notify-title">${esc(n.title)}</span>
|
||||||
|
${n.body ? `<span class="notify-text">${esc(n.body)}</span>` : ''}
|
||||||
|
<span class="notify-time">${esc(notifyTime(n.created_at))}</span>
|
||||||
|
</span>
|
||||||
|
</a>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function notifyPanelEl() {
|
||||||
|
let panel = document.getElementById('notifyPanel');
|
||||||
|
if (panel) return panel;
|
||||||
|
panel = document.createElement('div');
|
||||||
|
panel.className = 'notify-panel';
|
||||||
|
panel.id = 'notifyPanel';
|
||||||
|
panel.hidden = true;
|
||||||
|
document.body.appendChild(panel);
|
||||||
|
return panel;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderNotifyPanel() {
|
||||||
|
const panel = notifyPanelEl();
|
||||||
|
if (!panel) return;
|
||||||
|
const items = notifyList.slice(0, 12);
|
||||||
|
panel.innerHTML = `
|
||||||
|
<div class="notify-head">
|
||||||
|
<b>Уведомления</b>
|
||||||
|
<button type="button" class="notify-link" id="notifyReadAll">Прочитать все</button>
|
||||||
|
</div>
|
||||||
|
<div class="notify-list">
|
||||||
|
${items.length ? items.map(n => notifyItemHtml(n, false)).join('') : '<div class="notify-empty">Пока уведомлений нет</div>'}
|
||||||
|
</div>
|
||||||
|
<div class="notify-foot">
|
||||||
|
<a href="notifications.html">Все уведомления${notifyUnread ? ` (${notifyUnread})` : ''}</a>
|
||||||
|
<button type="button" class="notify-link" id="notifyDesktopToggle">${desktopNotify ? 'В браузере: вкл' : 'В браузере: выкл'}</button>
|
||||||
|
</div>`;
|
||||||
|
renderIcons();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadNotifications() {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API}/api/notifications?limit=20`, { headers: hdr() });
|
||||||
|
if (!res.ok) return;
|
||||||
|
const data = await res.json();
|
||||||
|
notifyList = data.items || [];
|
||||||
|
setNotifyUnread(data.unread);
|
||||||
|
renderNotifyPanel();
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleNotifyPanel(force) {
|
||||||
|
const panel = notifyPanelEl();
|
||||||
|
if (!panel) return;
|
||||||
|
const open = force === undefined ? panel.hidden : !!force;
|
||||||
|
panel.hidden = !open;
|
||||||
|
if (open) { renderNotifyPanel(); loadNotifications(); }
|
||||||
|
}
|
||||||
|
async function markNotifyRead(id) {
|
||||||
|
const item = notifyList.find(n => n.id === id);
|
||||||
|
if (item && !item.read) {
|
||||||
|
item.read = true;
|
||||||
|
setNotifyUnread(Math.max(0, notifyUnread - 1));
|
||||||
|
renderNotifyPanel();
|
||||||
|
}
|
||||||
|
try { await fetch(`${API}/api/notifications/${id}/read`, { method: 'POST', headers: hdr() }); } catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function markAllNotifyRead() {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API}/api/notifications/read-all`, { method: 'POST', headers: hdr() });
|
||||||
|
if (!res.ok) return;
|
||||||
|
notifyList.forEach(n => { n.read = true; });
|
||||||
|
setNotifyUnread(0);
|
||||||
|
renderNotifyPanel();
|
||||||
|
if (typeof onNotifyListChanged === 'function') onNotifyListChanged();
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showDesktopNotification(n) {
|
||||||
|
if (!desktopNotify) return;
|
||||||
|
if (typeof Notification === 'undefined' || Notification.permission !== 'granted') return;
|
||||||
|
try {
|
||||||
|
const note = new Notification(n.title, { body: n.body || '', tag: 'whatido-' + n.id });
|
||||||
|
note.onclick = () => {
|
||||||
|
window.focus();
|
||||||
|
if (n.link) location.href = n.link;
|
||||||
|
note.close();
|
||||||
|
};
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleDesktopNotifications() {
|
||||||
|
if (desktopNotify) {
|
||||||
|
desktopNotify = false;
|
||||||
|
localStorage.setItem('notifyDesktop', '0');
|
||||||
|
renderNotifyPanel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof Notification === 'undefined') { showToast('Браузер не поддерживает уведомления'); return; }
|
||||||
|
let perm = Notification.permission;
|
||||||
|
if (perm === 'default') {
|
||||||
|
try { perm = await Notification.requestPermission(); } catch {}
|
||||||
|
}
|
||||||
|
if (perm !== 'granted') { showToast('Разрешение на уведомления не выдано'); return; }
|
||||||
|
desktopNotify = true;
|
||||||
|
localStorage.setItem('notifyDesktop', '1');
|
||||||
|
renderNotifyPanel();
|
||||||
|
showToast('Уведомления в браузере включены');
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleIncomingNotification(n) {
|
||||||
|
notifyList.unshift(n);
|
||||||
|
if (notifyList.length > 40) notifyList.length = 40;
|
||||||
|
setNotifyUnread(notifyUnread + 1);
|
||||||
|
renderNotifyPanel();
|
||||||
|
if (n.level === 'warning' || n.level === 'critical') showToast(n.title);
|
||||||
|
showDesktopNotification(n);
|
||||||
|
if (typeof onNotificationArrived === 'function') onNotificationArrived(n);
|
||||||
|
}
|
||||||
|
|
||||||
|
function connectNotifyStream() {
|
||||||
|
if (notifyStream) { notifyStream.close(); notifyStream = null; }
|
||||||
|
const connect = () => {
|
||||||
|
const t = sessionStorage.getItem('authToken');
|
||||||
|
if (!t) return;
|
||||||
|
notifyStream = new EventSource(`${API}/api/notifications/stream?token=${encodeURIComponent(t)}`);
|
||||||
|
notifyStream.addEventListener('ready', ev => {
|
||||||
|
try { setNotifyUnread(JSON.parse(ev.data).unread); } catch {}
|
||||||
|
});
|
||||||
|
notifyStream.addEventListener('notification', ev => {
|
||||||
|
let n = null;
|
||||||
|
try { n = JSON.parse(ev.data); } catch { return; }
|
||||||
|
if (!n || !n.id) return;
|
||||||
|
handleIncomingNotification(n);
|
||||||
|
});
|
||||||
|
notifyStream.onerror = () => {
|
||||||
|
if (notifyStream) { notifyStream.close(); notifyStream = null; }
|
||||||
|
clearTimeout(notifyReconnectTimer);
|
||||||
|
notifyReconnectTimer = setTimeout(connect, 5000);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
connect();
|
||||||
|
window.addEventListener('beforeunload', () => { if (notifyStream) notifyStream.close(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function initNotifications() {
|
||||||
|
const bell = document.getElementById('notifyBell');
|
||||||
|
const panel = notifyPanelEl();
|
||||||
|
if (!bell || !panel) return;
|
||||||
|
bell.addEventListener('click', e => { e.preventDefault(); toggleNotifyPanel(); });
|
||||||
|
panel.addEventListener('click', e => {
|
||||||
|
if (e.target.closest('#notifyReadAll')) { markAllNotifyRead(); return; }
|
||||||
|
if (e.target.closest('#notifyDesktopToggle')) { toggleDesktopNotifications(); return; }
|
||||||
|
const item = e.target.closest('[data-notify-id]');
|
||||||
|
if (!item) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const id = parseInt(item.dataset.notifyId, 10);
|
||||||
|
const link = item.dataset.notifyLink;
|
||||||
|
toggleNotifyPanel(false);
|
||||||
|
markNotifyRead(id);
|
||||||
|
if (link) location.href = link;
|
||||||
|
});
|
||||||
|
document.addEventListener('click', e => {
|
||||||
|
if (panel.hidden) return;
|
||||||
|
if (e.target.closest('#notifyPanel') || e.target.closest('#notifyBell')) return;
|
||||||
|
toggleNotifyPanel(false);
|
||||||
|
});
|
||||||
|
document.addEventListener('keydown', e => { if (e.key === 'Escape' && !panel.hidden) toggleNotifyPanel(false); });
|
||||||
|
loadNotifications();
|
||||||
|
connectNotifyStream();
|
||||||
|
}
|
||||||
|
|
||||||
// --- Common UI ---
|
// --- Common UI ---
|
||||||
function renderIcons() {
|
function renderIcons() {
|
||||||
if (window.lucide && typeof window.lucide.createIcons === 'function') {
|
if (window.lucide && typeof window.lucide.createIcons === 'function') {
|
||||||
|
|||||||
+5
-3
@@ -29,6 +29,7 @@
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Время</th>
|
<th>Время</th>
|
||||||
|
<th>Кто</th>
|
||||||
<th>Действие</th>
|
<th>Действие</th>
|
||||||
<th>Детали</th>
|
<th>Детали</th>
|
||||||
<th>IP</th>
|
<th>IP</th>
|
||||||
@@ -42,11 +43,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-overlay" id="auditDetailsModal">
|
<div class="modal-overlay" id="auditDetailsModal">
|
||||||
<div class="edit-modal" style="max-width:640px">
|
<div class="edit-modal" style="max-width:820px">
|
||||||
<h3 id="auditDetailsTitle">Детали действия</h3>
|
<h3 id="auditDetailsTitle">Детали действия</h3>
|
||||||
<div class="audit-details-body"><pre id="auditDetailsBody"></pre></div>
|
<div class="audit-details-head" id="auditDetailsHead"></div>
|
||||||
|
<div class="audit-details-body" id="auditDetailsBody"></div>
|
||||||
<div class="card-foot" style="justify-content:flex-end;gap:8px">
|
<div class="card-foot" style="justify-content:flex-end;gap:8px">
|
||||||
<button type="button" class="cancel" id="auditDetailsCloseBtn">Закрыть</button>
|
<button type="button" class="btn-primary ghost" id="auditDetailsCloseBtn">Закрыть</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -41,6 +41,7 @@
|
|||||||
<div class="actions-grid">
|
<div class="actions-grid">
|
||||||
<a class="action-btn" href="journal.html"><span class="aic"><i data-lucide="book-open"></i></span>Журнал</a>
|
<a class="action-btn" href="journal.html"><span class="aic"><i data-lucide="book-open"></i></span>Журнал</a>
|
||||||
<a class="action-btn" href="files.html"><span class="aic"><i data-lucide="folder"></i></span>Файлы</a>
|
<a class="action-btn" href="files.html"><span class="aic"><i data-lucide="folder"></i></span>Файлы</a>
|
||||||
|
<a class="action-btn" href="photos.html"><span class="aic"><i data-lucide="Image"></i></span>Фото</a>
|
||||||
<a class="action-btn" href="groups.html"><span class="aic"><i data-lucide="users"></i></span>Группы</a>
|
<a class="action-btn" href="groups.html"><span class="aic"><i data-lucide="users"></i></span>Группы</a>
|
||||||
<a class="action-btn" href="links.html"><span class="aic"><i data-lucide="link"></i></span>Ссылки</a>
|
<a class="action-btn" href="links.html"><span class="aic"><i data-lucide="link"></i></span>Ссылки</a>
|
||||||
<a class="action-btn" href="trash.html"><span class="aic"><i data-lucide="trash-2"></i></span>Корзина</a>
|
<a class="action-btn" href="trash.html"><span class="aic"><i data-lucide="trash-2"></i></span>Корзина</a>
|
||||||
|
|||||||
+10
-3
@@ -39,9 +39,14 @@ h1{font-size:1.5rem;font-weight:600;margin-bottom:8px;letter-spacing:-.02em;text
|
|||||||
.files-list .file-row .f-size{color:var(--muted);flex-shrink:0}
|
.files-list .file-row .f-size{color:var(--muted);flex-shrink:0}
|
||||||
.files-list .file-row button{background:none;border:none;color:var(--muted);cursor:pointer;font-size:.85rem;padding:0 2px;flex-shrink:0}
|
.files-list .file-row button{background:none;border:none;color:var(--muted);cursor:pointer;font-size:.85rem;padding:0 2px;flex-shrink:0}
|
||||||
.files-list .file-row button:hover{color:#ef4444}
|
.files-list .file-row button:hover{color:#ef4444}
|
||||||
|
.paste-hint{font-size:.75rem;color:var(--muted);line-height:1.35;text-align:center}
|
||||||
|
.files-card .paste-hint{text-align:left}
|
||||||
|
@media(hover:none){.paste-hint{display:none}}
|
||||||
.preview-wrap{width:100%;display:flex;flex-direction:column;align-items:center;gap:8px}
|
.preview-wrap{width:100%;display:flex;flex-direction:column;align-items:center;gap:8px}
|
||||||
.preview{max-width:100%;width:100%;aspect-ratio:4/3;object-fit:cover;border-radius:var(--radius);background:var(--card);display:block;border:1px dashed var(--border)}
|
.preview{max-width:100%;width:100%;aspect-ratio:4/3;object-fit:cover;border-radius:var(--radius);background:var(--card);display:block;border:1px dashed var(--border);cursor:pointer;transition:border-color .15s}
|
||||||
.preview.has-photo{border-style:solid}
|
.preview.has-photo{border-style:solid}
|
||||||
|
.preview:hover,.preview:focus-visible{border-color:var(--accent)}
|
||||||
|
.preview-caption{font-size:.85rem;color:var(--muted);text-align:center;line-height:1.35}
|
||||||
.remove-photo{background:none;border:none;color:#ef4444;font-size:.85rem;cursor:pointer;padding:4px;display:none}
|
.remove-photo{background:none;border:none;color:#ef4444;font-size:.85rem;cursor:pointer;padding:4px;display:none}
|
||||||
.remove-photo.show{display:block}
|
.remove-photo.show{display:block}
|
||||||
|
|
||||||
@@ -104,10 +109,11 @@ nav a:hover{color:var(--text)}
|
|||||||
<div class="photo-col">
|
<div class="photo-col">
|
||||||
<div class="photo-card">
|
<div class="photo-card">
|
||||||
<div class="preview-wrap">
|
<div class="preview-wrap">
|
||||||
<img class="preview" id="preview" alt="Фото" src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='640' height='480'%3E%3Crect width='100%25' height='100%25' fill='none' stroke='%23475569' stroke-width='2' stroke-dasharray='8 8'/%3E%3Ctext x='50%25' y='50%25' fill='%2364748b' font-family='sans-serif' font-size='28' text-anchor='middle' dominant-baseline='middle'%3EФото%3C/text%3E%3C/svg%3E">
|
<img class="preview" id="preview" alt="Фото" title="Открыть камеру" tabindex="0" role="button" src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='640' height='480'%3E%3Crect width='100%25' height='100%25' fill='none' stroke='%23475569' stroke-width='2' stroke-dasharray='8 8'/%3E%3Ctext x='50%25' y='50%25' fill='%2364748b' font-family='sans-serif' font-size='28' text-anchor='middle' dominant-baseline='middle'%3EФото%3C/text%3E%3C/svg%3E">
|
||||||
|
<p class="preview-caption" id="previewCaption">Нажми чтобы сделать фото</p>
|
||||||
<button type="button" class="remove-photo" id="removePhotoBtn">Убрать фото</button>
|
<button type="button" class="remove-photo" id="removePhotoBtn">Убрать фото</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="photo-btns">
|
<div class="photo-btns" id="camBtns">
|
||||||
<button type="button" class="cam" id="openCamBtn">Камера</button>
|
<button type="button" class="cam" id="openCamBtn">Камера</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -118,6 +124,7 @@ nav a:hover{color:var(--text)}
|
|||||||
</div>
|
</div>
|
||||||
<button type="button" class="files-btn" id="filesBtn">Выбрать файлы</button>
|
<button type="button" class="files-btn" id="filesBtn">Выбрать файлы</button>
|
||||||
<input type="file" id="filesInput" multiple hidden>
|
<input type="file" id="filesInput" multiple hidden>
|
||||||
|
<p class="paste-hint">Ctrl+V — вставить файлы из буфера</p>
|
||||||
<div class="files-list" id="filesList"></div>
|
<div class="files-list" id="filesList"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+121
-8
@@ -9,6 +9,11 @@ const ACTION_LABELS = {
|
|||||||
'group.create': 'Создана группа',
|
'group.create': 'Создана группа',
|
||||||
'group.update': 'Изменена группа',
|
'group.update': 'Изменена группа',
|
||||||
'group.delete': 'Удалена группа',
|
'group.delete': 'Удалена группа',
|
||||||
|
'group.soft-delete': 'Группа в корзину',
|
||||||
|
'group.restore': 'Группа восстановлена',
|
||||||
|
'group.permanent-delete': 'Группа удалена безвозвратно',
|
||||||
|
'group.schedule-delete': 'Группа помечена на удаление',
|
||||||
|
'group.unschedule': 'Удаление группы отменено',
|
||||||
'group.photo.create': 'Загружена фото группы',
|
'group.photo.create': 'Загружена фото группы',
|
||||||
'group.photo.update': 'Изменено фото группы',
|
'group.photo.update': 'Изменено фото группы',
|
||||||
'group.photo.reorder': 'Изменён порядок фото группы',
|
'group.photo.reorder': 'Изменён порядок фото группы',
|
||||||
@@ -44,6 +49,9 @@ const ACTION_LABELS = {
|
|||||||
'entry.soft-delete': 'Запись в корзину',
|
'entry.soft-delete': 'Запись в корзину',
|
||||||
'entry.restore': 'Запись восстановлена',
|
'entry.restore': 'Запись восстановлена',
|
||||||
'entry.permanent-delete': 'Запись удалена безвозвратно',
|
'entry.permanent-delete': 'Запись удалена безвозвратно',
|
||||||
|
'entry.schedule-delete': 'Запись помечена на удаление',
|
||||||
|
'entry.unschedule': 'Удаление записи отменено',
|
||||||
|
'trash.clear': 'Корзина помечена на удаление',
|
||||||
'link.create': 'Создана ссылка',
|
'link.create': 'Создана ссылка',
|
||||||
'link.update': 'Изменена ссылка',
|
'link.update': 'Изменена ссылка',
|
||||||
'link.delete': 'Удалена ссылка',
|
'link.delete': 'Удалена ссылка',
|
||||||
@@ -69,10 +77,30 @@ let auditPage = 1;
|
|||||||
let auditTotal = 0;
|
let auditTotal = 0;
|
||||||
let auditRows = [];
|
let auditRows = [];
|
||||||
|
|
||||||
|
const EDIT_SOURCE_LABELS = {
|
||||||
|
manual: 'Вручную',
|
||||||
|
ai: 'ИИ',
|
||||||
|
ai_manual: 'ИИ + правка вручную',
|
||||||
|
ai_revert: 'Откат к оригиналу (ИИ)'
|
||||||
|
};
|
||||||
|
|
||||||
|
const FIELD_LABELS = {
|
||||||
|
student_name: 'ФИО ученика',
|
||||||
|
group_id: 'Группа',
|
||||||
|
module_id: 'Тема модуля',
|
||||||
|
description: 'Текст работы'
|
||||||
|
};
|
||||||
|
|
||||||
function fmtTime(t) {
|
function fmtTime(t) {
|
||||||
try { return new Date(t).toLocaleString('ru'); } catch { return t || ''; }
|
try { return new Date(t).toLocaleString('ru'); } catch { return t || ''; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseTarget(t) {
|
||||||
|
if (t === null || t === undefined) return null;
|
||||||
|
if (typeof t === 'object') return t;
|
||||||
|
try { return JSON.parse(t); } catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
function dt(t) {
|
function dt(t) {
|
||||||
if (!t) return '';
|
if (!t) return '';
|
||||||
if (typeof t === 'object') {
|
if (typeof t === 'object') {
|
||||||
@@ -92,6 +120,76 @@ function fullTarget(t) {
|
|||||||
} catch { return String(t); }
|
} catch { return String(t); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function changeLabel(c) {
|
||||||
|
return c.label || FIELD_LABELS[c.field] || c.field;
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeSummary(c) {
|
||||||
|
if (c.field === 'description' && c.stats) return `текст: +${c.stats.added_words}/−${c.stats.removed_words} слов`;
|
||||||
|
if (c.field === 'description') return 'текст работы';
|
||||||
|
return changeLabel(c).toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function targetSummary(t) {
|
||||||
|
const o = parseTarget(t);
|
||||||
|
if (!o) return dt(t);
|
||||||
|
if (Array.isArray(o.changes)) {
|
||||||
|
if (!o.changes.length) return esc(`без изменений${o.source ? ` · ${EDIT_SOURCE_LABELS[o.source] || o.source}` : ''}`);
|
||||||
|
const parts = o.changes.map(changeSummary);
|
||||||
|
if (o.photos_added) parts.push(`фото +${o.photos_added}`);
|
||||||
|
const src = o.source ? ` · ${EDIT_SOURCE_LABELS[o.source] || o.source}` : '';
|
||||||
|
return esc(parts.join(', ') + src);
|
||||||
|
}
|
||||||
|
return dt(t);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDiffSegments(segments) {
|
||||||
|
return (segments || []).map(s => {
|
||||||
|
const text = esc(s.text);
|
||||||
|
if (s.type === 'add') return `<ins class="audit-diff-add">${text}</ins>`;
|
||||||
|
if (s.type === 'del') return `<del class="audit-diff-del">${text}</del>`;
|
||||||
|
return `<span>${text}</span>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderChange(c) {
|
||||||
|
const label = changeLabel(c);
|
||||||
|
if (c.field !== 'description') {
|
||||||
|
return `<div class="audit-change">
|
||||||
|
<div class="audit-change-title">${esc(label)}</div>
|
||||||
|
<div class="audit-change-line"><span class="audit-tag-del">было</span> ${esc(c.before === null || c.before === undefined ? '—' : c.before)}</div>
|
||||||
|
<div class="audit-change-line"><span class="audit-tag-add">стало</span> ${esc(c.after === null || c.after === undefined ? '—' : c.after)}</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
const s = c.stats || {};
|
||||||
|
const parts = [];
|
||||||
|
if (s.added_words || s.added_chars) parts.push(`<span class="audit-stat-add">+${s.added_words || 0} слов (+${s.added_chars || 0} симв.)</span>`);
|
||||||
|
if (s.removed_words || s.removed_chars) parts.push(`<span class="audit-stat-del">−${s.removed_words || 0} слов (−${s.removed_chars || 0} симв.)</span>`);
|
||||||
|
if (s.chars_before !== undefined) parts.push(`<span class="audit-stat-muted">было ${s.chars_before} симв. → стало ${s.chars_after} симв.</span>`);
|
||||||
|
return `<div class="audit-change">
|
||||||
|
<div class="audit-change-title">${esc(label)}</div>
|
||||||
|
<div class="audit-change-stats">${parts.join('')}</div>
|
||||||
|
<div class="audit-diff">${renderDiffSegments(c.diff) || '<span class="audit-stat-muted">нет текстового диффа</span>'}</div>
|
||||||
|
${c.truncated ? '<div class="audit-truncated">Показана только часть изменений</div>' : ''}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTargetDetails(target) {
|
||||||
|
const o = parseTarget(target);
|
||||||
|
if (!o) return `<pre>${esc(fullTarget(target))}</pre>`;
|
||||||
|
if (!Array.isArray(o.changes)) return `<pre>${esc(fullTarget(target))}</pre>`;
|
||||||
|
const head = [];
|
||||||
|
if (o.id) head.push(`<span class="audit-pill">Запись #${esc(o.id)}</span>`);
|
||||||
|
if (o.source) head.push(`<span class="audit-pill">Источник: ${esc(EDIT_SOURCE_LABELS[o.source] || o.source)}</span>`);
|
||||||
|
head.push(`<span class="audit-pill">Полей затронуто: ${o.changes.length}</span>`);
|
||||||
|
if (o.photos_added) head.push(`<span class="audit-pill">Добавлено фото: ${o.photos_added}</span>`);
|
||||||
|
const body = o.changes.length
|
||||||
|
? o.changes.map(renderChange).join('')
|
||||||
|
: '<div class="audit-stat-muted">Изменений не зафиксировано.</div>';
|
||||||
|
return `${head.join('')}<div class="audit-changes">${body}</div>
|
||||||
|
<details class="audit-raw"><summary>Технические данные (JSON)</summary><pre>${esc(fullTarget(target))}</pre></details>`;
|
||||||
|
}
|
||||||
|
|
||||||
function renderPager(container, page, totalPages, onPageChange) {
|
function renderPager(container, page, totalPages, onPageChange) {
|
||||||
if (totalPages <= 1) { container.innerHTML = ''; return; }
|
if (totalPages <= 1) { container.innerHTML = ''; return; }
|
||||||
const pages = [];
|
const pages = [];
|
||||||
@@ -112,21 +210,20 @@ function renderPager(container, page, totalPages, onPageChange) {
|
|||||||
|
|
||||||
async function loadAudit() {
|
async function loadAudit() {
|
||||||
const el = document.getElementById('auditTable').querySelector('tbody');
|
const el = document.getElementById('auditTable').querySelector('tbody');
|
||||||
el.innerHTML = '<tr><td colspan="4" style="color:var(--muted)">Загрузка…</td></tr>';
|
el.innerHTML = '<tr><td colspan="5" style="color:var(--muted)">Загрузка…</td></tr>';
|
||||||
const p = new URLSearchParams();
|
const p = new URLSearchParams();
|
||||||
p.set('limit', AUDIT_PAGE_SIZE);
|
p.set('limit', AUDIT_PAGE_SIZE);
|
||||||
p.set('offset', (auditPage - 1) * AUDIT_PAGE_SIZE);
|
p.set('offset', (auditPage - 1) * AUDIT_PAGE_SIZE);
|
||||||
const action = document.getElementById('auditActionFilter').value;
|
const action = document.getElementById('auditActionFilter').value;
|
||||||
if (action) p.set('action', action);
|
if (action) p.set('action', action);
|
||||||
const res = await fetch(`${API}/api/audit?${p}`, { headers: hdr() });
|
const res = await fetch(`${API}/api/audit?${p}`, { headers: hdr() });
|
||||||
if (!res.ok) { el.innerHTML = '<tr><td colspan="4" style="color:var(--muted)">Ошибка загрузки</td></tr>'; return; }
|
if (!res.ok) { el.innerHTML = '<tr><td colspan="5" style="color:var(--muted)">Ошибка загрузки</td></tr>'; return; }
|
||||||
const rows = await res.json();
|
const rows = await res.json();
|
||||||
// Total count for pagination (approximate: if we got less than page size, we're on last page)
|
|
||||||
const totalPages = rows.length < AUDIT_PAGE_SIZE ? auditPage : auditPage + 1;
|
const totalPages = rows.length < AUDIT_PAGE_SIZE ? auditPage : auditPage + 1;
|
||||||
auditTotal = totalPages;
|
auditTotal = totalPages;
|
||||||
document.getElementById('auditCount').textContent = rows.length ? `Записей на странице: ${rows.length}` : '';
|
document.getElementById('auditCount').textContent = rows.length ? `Записей на странице: ${rows.length}` : '';
|
||||||
const q = (document.getElementById('auditFilter').value || '').toLowerCase().trim();
|
const q = (document.getElementById('auditFilter').value || '').toLowerCase().trim();
|
||||||
const filtered = q ? rows.filter(r => (ACTION_LABELS[r.action] || r.action).toLowerCase().includes(q)) : rows;
|
const filtered = q ? rows.filter(r => `${ACTION_LABELS[r.action] || r.action} ${targetSummary(r.target)}`.toLowerCase().includes(q)) : rows;
|
||||||
if (!filtered.length) {
|
if (!filtered.length) {
|
||||||
document.getElementById('auditEmpty').style.display = 'block';
|
document.getElementById('auditEmpty').style.display = 'block';
|
||||||
el.innerHTML = '';
|
el.innerHTML = '';
|
||||||
@@ -136,8 +233,9 @@ async function loadAudit() {
|
|||||||
el.innerHTML = filtered.map((r, i) => `
|
el.innerHTML = filtered.map((r, i) => `
|
||||||
<tr>
|
<tr>
|
||||||
<td style="white-space:nowrap">${fmtTime(r.created_at)}</td>
|
<td style="white-space:nowrap">${fmtTime(r.created_at)}</td>
|
||||||
|
<td style="white-space:nowrap">${esc(r.user_name || 'система')}</td>
|
||||||
<td><b>${esc(ACTION_LABELS[r.action] || r.action)}</b></td>
|
<td><b>${esc(ACTION_LABELS[r.action] || r.action)}</b></td>
|
||||||
<td><button type="button" class="audit-details" data-audit-idx="${i}">${dt(r.target)}</button></td>
|
<td><button type="button" class="audit-details" data-audit-idx="${i}">${targetSummary(r.target)}</button></td>
|
||||||
<td style="color:var(--muted);white-space:nowrap">${esc(r.ip || '')}</td>
|
<td style="color:var(--muted);white-space:nowrap">${esc(r.ip || '')}</td>
|
||||||
</tr>`).join('');
|
</tr>`).join('');
|
||||||
}
|
}
|
||||||
@@ -164,14 +262,29 @@ document.getElementById('auditActionFilter').addEventListener('change', () => {
|
|||||||
document.getElementById('auditFilter').addEventListener('input', () => { auditPage = 1; loadAudit(); });
|
document.getElementById('auditFilter').addEventListener('input', () => { auditPage = 1; loadAudit(); });
|
||||||
document.getElementById('auditRefreshBtn').addEventListener('click', loadAudit);
|
document.getElementById('auditRefreshBtn').addEventListener('click', loadAudit);
|
||||||
|
|
||||||
document.getElementById('auditTable').addEventListener('click', e => {
|
document.getElementById('auditTable').addEventListener('click', async e => {
|
||||||
const btn = e.target.closest('.audit-details');
|
const btn = e.target.closest('.audit-details');
|
||||||
if (!btn) return;
|
if (!btn) return;
|
||||||
const r = auditRows[parseInt(btn.dataset.auditIdx, 10)];
|
const r = auditRows[parseInt(btn.dataset.auditIdx, 10)];
|
||||||
if (!r) return;
|
if (!r) return;
|
||||||
|
const modal = document.getElementById('auditDetailsModal');
|
||||||
|
const headEl = document.getElementById('auditDetailsHead');
|
||||||
|
const bodyEl = document.getElementById('auditDetailsBody');
|
||||||
document.getElementById('auditDetailsTitle').textContent = ACTION_LABELS[r.action] || r.action;
|
document.getElementById('auditDetailsTitle').textContent = ACTION_LABELS[r.action] || r.action;
|
||||||
document.getElementById('auditDetailsBody').textContent = fullTarget(r.target);
|
headEl.innerHTML = `<span class="audit-pill">${esc(fmtTime(r.created_at))}</span><span class="audit-pill">${esc(r.user_name || 'система')}</span>${r.ip ? `<span class="audit-pill">IP ${esc(r.ip)}</span>` : ''}`;
|
||||||
document.getElementById('auditDetailsModal').classList.add('open');
|
bodyEl.innerHTML = renderTargetDetails(r.target);
|
||||||
|
modal.classList.add('open');
|
||||||
|
if (!r.id) return;
|
||||||
|
bodyEl.classList.add('audit-loading');
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API}/api/audit/${r.id}`, { headers: hdr() });
|
||||||
|
if (!res.ok) return;
|
||||||
|
const full = await res.json();
|
||||||
|
bodyEl.innerHTML = renderTargetDetails(full.target);
|
||||||
|
} catch (err) {
|
||||||
|
} finally {
|
||||||
|
bodyEl.classList.remove('audit-loading');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
document.getElementById('auditDetailsCloseBtn').addEventListener('click', () => {
|
document.getElementById('auditDetailsCloseBtn').addEventListener('click', () => {
|
||||||
document.getElementById('auditDetailsModal').classList.remove('open');
|
document.getElementById('auditDetailsModal').classList.remove('open');
|
||||||
|
|||||||
+3
-2
@@ -113,8 +113,9 @@ async function addGroup() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function delGroup(id) {
|
async function delGroup(id) {
|
||||||
if (!confirm('Удалить группу? Фото группы также будут удалены.')) return;
|
if (!confirm('Удалить группу? Группа попадёт в корзину, восстановить её можно будет позже.')) return;
|
||||||
await fetch(`${API}/api/groups/${id}`, { method: 'DELETE', headers: hdr() });
|
const res = await fetch(`${API}/api/groups/${id}`, { method: 'DELETE', headers: hdr() });
|
||||||
|
if (!res.ok) { const e = await res.json(); alert(e.error || 'Ошибка'); }
|
||||||
loadGroups();
|
loadGroups();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+124
-6
@@ -15,6 +15,7 @@ const camCanvas = document.getElementById('camCanvas');
|
|||||||
const camModal = document.getElementById('camModal');
|
const camModal = document.getElementById('camModal');
|
||||||
let capturedBlob = null;
|
let capturedBlob = null;
|
||||||
let stream = null;
|
let stream = null;
|
||||||
|
let camOpening = false;
|
||||||
const descInput = document.getElementById('descInput');
|
const descInput = document.getElementById('descInput');
|
||||||
const filesInput = document.getElementById('filesInput');
|
const filesInput = document.getElementById('filesInput');
|
||||||
const filesList = document.getElementById('filesList');
|
const filesList = document.getElementById('filesList');
|
||||||
@@ -118,13 +119,108 @@ function clearFiles() {
|
|||||||
renderFiles();
|
renderFiles();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MAX_FILES = 10;
|
||||||
|
const MAX_FILE_BYTES = 10 * 1024 * 1024;
|
||||||
|
const MAX_TOTAL_BYTES = 30 * 1024 * 1024;
|
||||||
|
const BLOCKED_FILE_EXT = /\.(?:html?|js|mjs|cjs|svg|xml|json|map|wasm|php\d?|phtml|asp|aspx|jsp|sh|bat|cmd|cgi|exe|dll|com|msi|scr|hta|vbs|py|r|rb|htaccess)$/i;
|
||||||
|
const MIME_EXT = {
|
||||||
|
'image/png': '.png', 'image/jpeg': '.jpg', 'image/jpg': '.jpg', 'image/gif': '.gif', 'image/webp': '.webp',
|
||||||
|
'image/bmp': '.bmp', 'image/avif': '.avif', 'image/heic': '.heic', 'image/heif': '.heif', 'image/svg+xml': '.svg',
|
||||||
|
'application/pdf': '.pdf', 'application/zip': '.zip', 'application/x-zip-compressed': '.zip',
|
||||||
|
'application/msword': '.doc', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': '.docx',
|
||||||
|
'application/vnd.ms-excel': '.xls', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': '.xlsx',
|
||||||
|
'application/vnd.ms-powerpoint': '.ppt', 'application/vnd.openxmlformats-officedocument.presentationml.presentation': '.pptx',
|
||||||
|
'text/plain': '.txt', 'text/csv': '.csv', 'application/rtf': '.rtf',
|
||||||
|
'application/x-rar-compressed': '.rar', 'application/vnd.rar': '.rar', 'application/x-7z-compressed': '.7z'
|
||||||
|
};
|
||||||
|
let notifyTimer = null;
|
||||||
|
|
||||||
|
function notify(text, isError) {
|
||||||
|
toast.textContent = text;
|
||||||
|
toast.classList.add('show');
|
||||||
|
toast.classList.toggle('error', !!isError);
|
||||||
|
clearTimeout(notifyTimer);
|
||||||
|
notifyTimer = setTimeout(() => toast.classList.remove('show', 'error'), 3200);
|
||||||
|
}
|
||||||
|
|
||||||
|
function extOf(name) {
|
||||||
|
const s = String(name || '');
|
||||||
|
const i = s.lastIndexOf('.');
|
||||||
|
return i > 0 ? s.slice(i).toLowerCase() : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function extFromFile(file) {
|
||||||
|
const named = extOf(file.name);
|
||||||
|
if (/^\.[a-z0-9]{1,8}$/i.test(named)) return named;
|
||||||
|
const byMime = MIME_EXT[String(file.type || '').toLowerCase()];
|
||||||
|
if (byMime) return byMime;
|
||||||
|
const sub = String(file.type || '').split('/')[1] || '';
|
||||||
|
return /^[a-z0-9]{1,8}$/i.test(sub) ? '.' + sub : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function pasteFileName(file) {
|
||||||
|
const base = String(file.name || '').trim();
|
||||||
|
const stem = base.replace(/\.[a-z0-9]{1,8}$/i, '');
|
||||||
|
if (base && !/^(image|photo|picture|blob|file|screenshot|снимок|скриншот|изображение|файл)$/i.test(stem)) return base;
|
||||||
|
const d = new Date();
|
||||||
|
const pad = n => String(n).padStart(2, '0');
|
||||||
|
const stamp = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}-${pad(d.getMinutes())}-${pad(d.getSeconds())}`;
|
||||||
|
return `Вставка ${stamp}${extFromFile(file)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function totalUploadBytes() {
|
||||||
|
return (capturedBlob ? capturedBlob.size : 0) + selectedFiles.reduce((sum, f) => sum + f.size, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addFiles(list) {
|
||||||
|
const added = [];
|
||||||
|
const skipped = [];
|
||||||
|
for (const file of list) {
|
||||||
|
if (selectedFiles.length >= MAX_FILES) { skipped.push('Можно прикрепить не более 10 файлов'); break; }
|
||||||
|
if (file.size > MAX_FILE_BYTES) { skipped.push(`«${file.name}» больше 10 МБ`); continue; }
|
||||||
|
if (BLOCKED_FILE_EXT.test(extOf(file.name))) { skipped.push(`«${file.name}»: недопустимый тип файла`); continue; }
|
||||||
|
if (totalUploadBytes() + file.size > MAX_TOTAL_BYTES) { skipped.push('Суммарный размер файлов — не более 30 МБ'); break; }
|
||||||
|
selectedFiles.push(file);
|
||||||
|
added.push(file.name);
|
||||||
|
}
|
||||||
|
if (added.length) renderFiles();
|
||||||
|
return { added, skipped };
|
||||||
|
}
|
||||||
|
|
||||||
filesInput.addEventListener('change', () => {
|
filesInput.addEventListener('change', () => {
|
||||||
const keep = 10 - selectedFiles.length;
|
const res = addFiles([...filesInput.files]);
|
||||||
if (keep <= 0) { alert('Можно прикрепить не более 10 файлов'); filesInput.value = ''; return; }
|
|
||||||
selectedFiles = selectedFiles.concat([...filesInput.files].slice(0, keep));
|
|
||||||
if ([...filesInput.files].length > keep) alert('Можно прикрепить не более 10 файлов');
|
|
||||||
filesInput.value = '';
|
filesInput.value = '';
|
||||||
renderFiles();
|
if (res.skipped.length) alert(res.skipped[0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('paste', (e) => {
|
||||||
|
if (form.style.display === 'none') return;
|
||||||
|
const clip = e.clipboardData;
|
||||||
|
if (!clip) return;
|
||||||
|
const pasted = [];
|
||||||
|
for (const item of Array.from(clip.items || [])) {
|
||||||
|
if (item.kind !== 'file') continue;
|
||||||
|
const file = item.getAsFile();
|
||||||
|
if (file) pasted.push(file);
|
||||||
|
}
|
||||||
|
if (!pasted.length && clip.files && clip.files.length) pasted.push(...Array.from(clip.files));
|
||||||
|
if (!pasted.length) return;
|
||||||
|
e.preventDefault();
|
||||||
|
let filesAdded = 0;
|
||||||
|
const skipped = [];
|
||||||
|
for (const raw of pasted) {
|
||||||
|
const name = pasteFileName(raw);
|
||||||
|
const file = raw.name === name ? raw : new File([raw], name, { type: raw.type || 'application/octet-stream', lastModified: raw.lastModified });
|
||||||
|
const res = addFiles([file]);
|
||||||
|
filesAdded += res.added.length;
|
||||||
|
skipped.push(...res.skipped);
|
||||||
|
}
|
||||||
|
if (!filesAdded) {
|
||||||
|
if (skipped.length) notify(skipped[0], true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const summary = filesAdded === 1 ? 'Файл добавлен' : `Файлов добавлено: ${filesAdded}`;
|
||||||
|
notify(summary + (skipped.length ? '. ' + skipped[0] : ''), skipped.length > 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
const params = new URLSearchParams(location.search);
|
const params = new URLSearchParams(location.search);
|
||||||
@@ -223,6 +319,10 @@ async function resolveModuleId(name) {
|
|||||||
if (Number.isFinite(h) && h >= 120 && h <= 4096) captureHeight = h;
|
if (Number.isFinite(h) && h >= 120 && h <= 4096) captureHeight = h;
|
||||||
const q = parseFloat(f.photo_capture_quality);
|
const q = parseFloat(f.photo_capture_quality);
|
||||||
if (Number.isFinite(q) && q >= 0.5 && q <= 1) captureQuality = q;
|
if (Number.isFinite(q) && q >= 0.5 && q <= 1) captureQuality = q;
|
||||||
|
if (f.camera_enabled === 'false') {
|
||||||
|
const camBtns = document.getElementById('camBtns');
|
||||||
|
if (camBtns) camBtns.style.display = 'none';
|
||||||
|
}
|
||||||
} catch (e) { /* ignore */ }
|
} catch (e) { /* ignore */ }
|
||||||
checkPreviousSend();
|
checkPreviousSend();
|
||||||
})();
|
})();
|
||||||
@@ -241,12 +341,22 @@ function removePhoto() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function openCam() {
|
async function openCam() {
|
||||||
|
if (camOpening) return;
|
||||||
|
if (stream && camModal.classList.contains('open')) return;
|
||||||
|
camOpening = true;
|
||||||
try {
|
try {
|
||||||
stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'user', width: { ideal: captureWidth }, height: { ideal: captureHeight } } });
|
const s = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'user', width: { ideal: captureWidth }, height: { ideal: captureHeight } } });
|
||||||
|
if (camModal.classList.contains('open')) {
|
||||||
|
s.getTracks().forEach(t => t.stop());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
stream = s;
|
||||||
camVideo.srcObject = stream;
|
camVideo.srcObject = stream;
|
||||||
camModal.classList.add('open');
|
camModal.classList.add('open');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert('Не удалось открыть камеру: ' + e.message);
|
alert('Не удалось открыть камеру: ' + e.message);
|
||||||
|
} finally {
|
||||||
|
camOpening = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -256,6 +366,10 @@ function closeCam() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function capture() {
|
function capture() {
|
||||||
|
if (!camVideo.videoWidth || !camVideo.videoHeight) {
|
||||||
|
notify('Камера ещё не готова, попробуйте через секунду', true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
camCanvas.width = camVideo.videoWidth;
|
camCanvas.width = camVideo.videoWidth;
|
||||||
camCanvas.height = camVideo.videoHeight;
|
camCanvas.height = camVideo.videoHeight;
|
||||||
const ctx = camCanvas.getContext('2d');
|
const ctx = camCanvas.getContext('2d');
|
||||||
@@ -325,6 +439,10 @@ form.addEventListener('submit', async (e) => {
|
|||||||
|
|
||||||
removePhotoBtn.addEventListener('click', removePhoto);
|
removePhotoBtn.addEventListener('click', removePhoto);
|
||||||
document.getElementById('openCamBtn').addEventListener('click', openCam);
|
document.getElementById('openCamBtn').addEventListener('click', openCam);
|
||||||
|
preview.addEventListener('click', openCam);
|
||||||
|
preview.addEventListener('keydown', e => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openCam(); }
|
||||||
|
});
|
||||||
filesClearBtn.addEventListener('click', clearFiles);
|
filesClearBtn.addEventListener('click', clearFiles);
|
||||||
document.getElementById('filesBtn').addEventListener('click', () => document.getElementById('filesInput').click());
|
document.getElementById('filesBtn').addEventListener('click', () => document.getElementById('filesInput').click());
|
||||||
document.getElementById('cancelCamBtn').addEventListener('click', closeCam);
|
document.getElementById('cancelCamBtn').addEventListener('click', closeCam);
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ let searchTimer = null;
|
|||||||
let view = localStorage.getItem('journalView') === 'cards' ? 'cards' : 'list';
|
let view = localStorage.getItem('journalView') === 'cards' ? 'cards' : 'list';
|
||||||
let aiCorrecting = false;
|
let aiCorrecting = false;
|
||||||
let editDeletePhoto = false;
|
let editDeletePhoto = false;
|
||||||
|
let editAiSuggestion = null;
|
||||||
let linkSettings = null;
|
let linkSettings = null;
|
||||||
|
|
||||||
async function loadLinkSettings() {
|
async function loadLinkSettings() {
|
||||||
@@ -395,6 +396,7 @@ async function openEdit(id) {
|
|||||||
if (!e) return;
|
if (!e) return;
|
||||||
editId = id;
|
editId = id;
|
||||||
editDeletePhoto = false;
|
editDeletePhoto = false;
|
||||||
|
editAiSuggestion = null;
|
||||||
fillAutocomplete();
|
fillAutocomplete();
|
||||||
document.getElementById('editName').value = e.student_name;
|
document.getElementById('editName').value = e.student_name;
|
||||||
document.getElementById('editDesc').value = e.description;
|
document.getElementById('editDesc').value = e.description;
|
||||||
@@ -1140,6 +1142,7 @@ function acceptAiCorrect() {
|
|||||||
const suggestion = document.getElementById('aiSuggestion').textContent;
|
const suggestion = document.getElementById('aiSuggestion').textContent;
|
||||||
if (suggestion && suggestion !== 'Обработка...' && !suggestion.startsWith('Ошибка:')) {
|
if (suggestion && suggestion !== 'Обработка...' && !suggestion.startsWith('Ошибка:')) {
|
||||||
document.getElementById('editDesc').value = suggestion;
|
document.getElementById('editDesc').value = suggestion;
|
||||||
|
editAiSuggestion = suggestion.trim();
|
||||||
}
|
}
|
||||||
closeAiCorrect();
|
closeAiCorrect();
|
||||||
}
|
}
|
||||||
@@ -1190,6 +1193,11 @@ async function recheckAi(id) {
|
|||||||
refreshEntryCard(id);
|
refreshEntryCard(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function editSourceFor(description) {
|
||||||
|
if (!editAiSuggestion) return 'manual';
|
||||||
|
return description === editAiSuggestion ? 'ai' : 'ai_manual';
|
||||||
|
}
|
||||||
|
|
||||||
async function saveEdit() {
|
async function saveEdit() {
|
||||||
if (!editId) return;
|
if (!editId) return;
|
||||||
const name = document.getElementById('editName').value.trim();
|
const name = document.getElementById('editName').value.trim();
|
||||||
@@ -1205,6 +1213,7 @@ async function saveEdit() {
|
|||||||
formData.append('group_id', group_id);
|
formData.append('group_id', group_id);
|
||||||
formData.append('description', description);
|
formData.append('description', description);
|
||||||
formData.append('module_id', moduleId);
|
formData.append('module_id', moduleId);
|
||||||
|
formData.append('edit_source', editSourceFor(description));
|
||||||
|
|
||||||
const photoFiles = document.getElementById('editPhoto').files;
|
const photoFiles = document.getElementById('editPhoto').files;
|
||||||
for (const f of photoFiles) formData.append('photo', f);
|
for (const f of photoFiles) formData.append('photo', f);
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
const PAGE_SIZE = 25;
|
||||||
|
|
||||||
|
let nfItems = [];
|
||||||
|
let nfTotal = 0;
|
||||||
|
let nfUnread = 0;
|
||||||
|
let nfUnreadOnly = false;
|
||||||
|
let nfLoading = false;
|
||||||
|
|
||||||
|
|
||||||
|
async function nfMarkRead(id) {
|
||||||
|
const res = await fetch(`${API}/api/notifications/${id}/read`, { method: 'POST', headers: hdr() });
|
||||||
|
if (!res.ok) { showToast('Не удалось отметить уведомление'); return; }
|
||||||
|
const item = nfItems.find(n => n.id === id);
|
||||||
|
if (item) item.read = true;
|
||||||
|
nfUnread = Math.max(0, nfUnread - 1);
|
||||||
|
if (typeof notifyList !== 'undefined') {
|
||||||
|
const shared = notifyList.find(n => n.id === id);
|
||||||
|
if (shared) shared.read = true;
|
||||||
|
}
|
||||||
|
if (nfUnreadOnly) {
|
||||||
|
nfItems = nfItems.filter(n => n.id !== id);
|
||||||
|
nfTotal = Math.max(0, nfTotal - 1);
|
||||||
|
loadNotifyPage(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
renderNotifyPage();
|
||||||
|
if (typeof setNotifyUnread === 'function') setNotifyUnread(nfUnread);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function nfDelete(id) {
|
||||||
|
if (!confirm('Удалить уведомление у всех пользователей?')) return;
|
||||||
|
const res = await fetch(`${API}/api/notifications/${id}`, { method: 'DELETE', headers: hdr() });
|
||||||
|
if (!res.ok) { showToast('Не удалось удалить уведомление'); return; }
|
||||||
|
if (typeof notifyList !== 'undefined') notifyList = notifyList.filter(n => n.id !== id);
|
||||||
|
loadNotifyPage(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function nfMarkAllRead() {
|
||||||
|
const res = await fetch(`${API}/api/notifications/read-all`, { method: 'POST', headers: hdr() });
|
||||||
|
if (!res.ok) { showToast('Не удалось отметить уведомления'); return; }
|
||||||
|
showToast('Все уведомления прочитаны');
|
||||||
|
if (typeof notifyList !== 'undefined') notifyList.forEach(n => { n.read = true; });
|
||||||
|
if (typeof setNotifyUnread === 'function') setNotifyUnread(0);
|
||||||
|
loadNotifyPage(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function nfClearAll() {
|
||||||
|
if (!confirm('Удалить все уведомления у всех пользователей? Действие необратимо.')) return;
|
||||||
|
const res = await fetch(`${API}/api/notifications`, { method: 'DELETE', headers: hdr() });
|
||||||
|
if (!res.ok) { showToast('Не удалось очистить уведомления'); return; }
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
showToast(`Удалено уведомлений: ${data.deleted || 0}`);
|
||||||
|
if (typeof notifyList !== 'undefined') notifyList = [];
|
||||||
|
if (typeof setNotifyUnread === 'function') setNotifyUnread(0);
|
||||||
|
loadNotifyPage(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onNotificationArrived() {
|
||||||
|
loadNotifyPage(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onNotifyListChanged() {
|
||||||
|
loadNotifyPage(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('nfList').addEventListener('click', e => {
|
||||||
|
const readBtn = e.target.closest('[data-nf-read]');
|
||||||
|
if (readBtn) { e.preventDefault(); nfMarkRead(parseInt(readBtn.dataset.nfRead, 10)); return; }
|
||||||
|
const delBtn = e.target.closest('[data-nf-del]');
|
||||||
|
if (delBtn) { e.preventDefault(); nfDelete(parseInt(delBtn.dataset.nfDel, 10)); return; }
|
||||||
|
if (e.target.closest('a[href]')) {
|
||||||
|
const card = e.target.closest('[data-nf-card]');
|
||||||
|
if (card) nfMarkRead(parseInt(card.dataset.nfCard, 10));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('notifyFilter').addEventListener('click', e => {
|
||||||
|
const btn = e.target.closest('button[data-filter]');
|
||||||
|
if (!btn) return;
|
||||||
|
nfUnreadOnly = btn.dataset.filter === 'unread';
|
||||||
|
document.querySelectorAll('#notifyFilter button').forEach(b => b.classList.toggle('active', b === btn));
|
||||||
|
loadNotifyPage(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('nfReadAllBtn').addEventListener('click', nfMarkAllRead);
|
||||||
|
document.getElementById('nfClearBtn').addEventListener('click', nfClearAll);
|
||||||
|
document.getElementById('nfMoreBtn').addEventListener('click', () => loadNotifyPage(false));
|
||||||
|
document.getElementById('nfReloadBtn').addEventListener('click', () => loadNotifyPage(true));
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
if (!(await requireAdminPage())) return;
|
||||||
|
buildSidebar(document.body.dataset.page);
|
||||||
|
const clearBtn = document.getElementById('nfClearBtn');
|
||||||
|
if (clearBtn) clearBtn.hidden = !isAdmin();
|
||||||
|
const authStatus = document.getElementById('authStatus');
|
||||||
|
if (authStatus) authStatus.classList.add('loaded');
|
||||||
|
await loadNotifyPage(true);
|
||||||
|
})();
|
||||||
|
|
||||||
|
function nfIcon(type) {
|
||||||
|
return typeof NOTIFY_ICONS !== 'undefined' && NOTIFY_ICONS[type] ? NOTIFY_ICONS[type] : 'bell';
|
||||||
|
}
|
||||||
|
|
||||||
|
function nfLevelClass(level) {
|
||||||
|
return level === 'warning' || level === 'critical' ? ` level-${level}` : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function nfCardHtml(n) {
|
||||||
|
const when = typeof notifyTime === 'function' ? notifyTime(n.created_at) : '';
|
||||||
|
const linkAttr = typeof attrEsc === 'function' ? attrEsc(n.link) : esc(n.link);
|
||||||
|
return `<div class="notify-card${n.read ? '' : ' unread'}${nfLevelClass(n.level)}" data-nf-card="${n.id}">
|
||||||
|
<span class="notify-ic"><i data-lucide="${nfIcon(n.type)}"></i></span>
|
||||||
|
<span class="notify-body">
|
||||||
|
<span class="notify-title">${esc(n.title)}</span>
|
||||||
|
${n.body ? `<span class="notify-text">${esc(n.body)}</span>` : ''}
|
||||||
|
<span class="notify-time">${esc(when)}${n.read ? '' : ' · не прочитано'}</span>
|
||||||
|
</span>
|
||||||
|
<span class="notify-card-actions">
|
||||||
|
${n.link ? `<a class="btn-primary ghost" style="padding:6px 12px;font-size:.78rem" href="${linkAttr}">Открыть</a>` : ''}
|
||||||
|
${n.read ? '' : `<button type="button" class="notify-link" data-nf-read="${n.id}">Прочитано</button>`}
|
||||||
|
${isAdmin() ? `<button type="button" class="notify-link" style="color:#ef4444" data-nf-del="${n.id}">Удалить</button>` : ''}
|
||||||
|
</span>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderNotifyPage() {
|
||||||
|
const list = document.getElementById('nfList');
|
||||||
|
const state = document.getElementById('nfState');
|
||||||
|
const more = document.getElementById('nfMoreBtn');
|
||||||
|
if (!list) return;
|
||||||
|
list.innerHTML = nfItems.length
|
||||||
|
? nfItems.map(nfCardHtml).join('')
|
||||||
|
: `<div class="notify-empty">${nfUnreadOnly ? 'Непрочитанных уведомлений нет' : 'Уведомлений пока нет'}</div>`;
|
||||||
|
if (state) {
|
||||||
|
state.textContent = nfItems.length
|
||||||
|
? `Показано ${nfItems.length} из ${nfTotal} · непрочитанных: ${nfUnread}`
|
||||||
|
: `Непрочитанных: ${nfUnread}`;
|
||||||
|
}
|
||||||
|
if (more) more.hidden = nfItems.length >= nfTotal;
|
||||||
|
renderIcons();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadNotifyPage(reset) {
|
||||||
|
if (nfLoading) return;
|
||||||
|
nfLoading = true;
|
||||||
|
const offset = reset ? 0 : nfItems.length;
|
||||||
|
try {
|
||||||
|
const q = new URLSearchParams({ limit: String(PAGE_SIZE), offset: String(offset) });
|
||||||
|
if (nfUnreadOnly) q.set('unread', '1');
|
||||||
|
const res = await fetch(`${API}/api/notifications?${q.toString()}`, { headers: hdr() });
|
||||||
|
if (!res.ok) { showToast('Ошибка загрузки уведомлений'); return; }
|
||||||
|
const data = await res.json();
|
||||||
|
const items = data.items || [];
|
||||||
|
nfItems = reset ? items : nfItems.concat(items);
|
||||||
|
nfTotal = Number(data.total) || 0;
|
||||||
|
nfUnread = Number(data.unread) || 0;
|
||||||
|
renderNotifyPage();
|
||||||
|
if (typeof setNotifyUnread === 'function') setNotifyUnread(nfUnread);
|
||||||
|
} catch {
|
||||||
|
showToast('Ошибка сети');
|
||||||
|
} finally {
|
||||||
|
nfLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
const PAGE_SIZE = 24;
|
||||||
|
let page = 1;
|
||||||
|
let totalPhotos = 0;
|
||||||
|
let currentPhotos = [];
|
||||||
|
let groupsLoaded = false;
|
||||||
|
let studentsLoaded = false;
|
||||||
|
let sourcesLoaded = false;
|
||||||
|
let searchTimer = null;
|
||||||
|
|
||||||
|
function humanSize(b) {
|
||||||
|
if (!b) return '';
|
||||||
|
if (b < 1024) return b + ' Б';
|
||||||
|
if (b < 1048576) return (b / 1024).toFixed(1) + ' КБ';
|
||||||
|
return (b / 1048576).toFixed(1) + ' МБ';
|
||||||
|
}
|
||||||
|
|
||||||
|
function photoLink(p) {
|
||||||
|
if (p.source_type === 'entry') return `journal.html#entry-${p.source_id}`;
|
||||||
|
if (p.source_type === 'entry_photo') return `journal.html#entry-${p.source_id}`;
|
||||||
|
if (p.source_type === 'group_photo') return 'groups.html';
|
||||||
|
if (p.source_type === 'student_photo') return 'students.html';
|
||||||
|
if (p.source_type === 'module_photo') return 'modules.html';
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function photoCardHTML(p) {
|
||||||
|
const url = p.path;
|
||||||
|
const meta = [];
|
||||||
|
if (p.student_name) meta.push(esc(p.student_name));
|
||||||
|
if (p.group_name) meta.push(esc(p.group_name));
|
||||||
|
const link = photoLink(p);
|
||||||
|
return `
|
||||||
|
<div class="ph-card">
|
||||||
|
<img src="${esc(thumbSrc(url))}" data-img="${esc(url)}" loading="lazy" decoding="async" alt="">
|
||||||
|
<div class="ph-body">
|
||||||
|
<span class="ph-badge">${esc(p.source_label)}</span>
|
||||||
|
<div class="ph-title">${esc(p.title || 'Без описания')}</div>
|
||||||
|
${meta.length ? `<div class="ph-meta">${meta.join(' · ')}</div>` : ''}
|
||||||
|
<div class="ph-time">${new Date(p.created_at).toLocaleString('ru')}</div>
|
||||||
|
${link ? `<a class="ph-link" href="${link}">Открыть источник →</a>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderGrid() {
|
||||||
|
const grid = document.getElementById('grid');
|
||||||
|
if (!currentPhotos.length) {
|
||||||
|
grid.innerHTML = '<div class="empty" style="grid-column:1/-1">Фотографий не найдено</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
grid.innerHTML = currentPhotos.map(photoCardHTML).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadGroupOptions() {
|
||||||
|
if (groupsLoaded) return;
|
||||||
|
const sel = document.getElementById('groupFilter');
|
||||||
|
while (sel.options.length > 1) sel.remove(1);
|
||||||
|
const res = await fetch(`${API}/api/groups`, { headers: hdr() });
|
||||||
|
const groups = await res.json();
|
||||||
|
groups.forEach(g => {
|
||||||
|
const o = document.createElement('option');
|
||||||
|
o.value = g.id; o.textContent = g.name;
|
||||||
|
sel.appendChild(o);
|
||||||
|
});
|
||||||
|
groupsLoaded = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadStudentOptions() {
|
||||||
|
if (studentsLoaded) return;
|
||||||
|
const dl = document.getElementById('studentFilterList');
|
||||||
|
const res = await fetch(`${API}/api/students/names`, { headers: hdr() });
|
||||||
|
const names = await res.json();
|
||||||
|
dl.innerHTML = names.map(n => `<option value="${esc(n)}">`).join('');
|
||||||
|
studentsLoaded = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fillSourceOptions() {
|
||||||
|
if (sourcesLoaded) return;
|
||||||
|
const sel = document.getElementById('sourceFilter');
|
||||||
|
const labels = [];
|
||||||
|
currentPhotos.forEach(p => { if (!labels.includes(p.source_label)) labels.push(p.source_label); });
|
||||||
|
const known = ['Главное фото записи', 'Фото записи', 'Фотохроника группы', 'Фото ученика', 'Тема модуля'];
|
||||||
|
known.concat(labels.filter(l => !known.includes(l))).forEach(label => {
|
||||||
|
const o = document.createElement('option');
|
||||||
|
o.value = label; o.textContent = label;
|
||||||
|
sel.appendChild(o);
|
||||||
|
});
|
||||||
|
sourcesLoaded = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPhotos() {
|
||||||
|
const p = new URLSearchParams();
|
||||||
|
const q = document.getElementById('searchInput').value;
|
||||||
|
const sn = document.getElementById('studentFilter').value;
|
||||||
|
const gid = document.getElementById('groupFilter').value;
|
||||||
|
const df = document.getElementById('dateFrom').value;
|
||||||
|
const dt = document.getElementById('dateTo').value;
|
||||||
|
if (q) p.set('search', q);
|
||||||
|
if (sn) p.set('student_name', sn);
|
||||||
|
if (gid) p.set('group_id', gid);
|
||||||
|
if (df) p.set('date_from', df);
|
||||||
|
if (dt) p.set('date_to', dt);
|
||||||
|
p.set('limit', PAGE_SIZE);
|
||||||
|
p.set('offset', (page - 1) * PAGE_SIZE);
|
||||||
|
const res = await fetch(`${API}/api/photos?${p}`, { headers: hdr() });
|
||||||
|
if (!res.ok) {
|
||||||
|
document.getElementById('grid').innerHTML = '<div class="empty" style="grid-column:1/-1">Не удалось загрузить фотографии</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
const srcFilter = document.getElementById('sourceFilter').value;
|
||||||
|
currentPhotos = srcFilter ? data.photos.filter(x => x.source_label === srcFilter) : data.photos;
|
||||||
|
totalPhotos = srcFilter ? currentPhotos.length : data.total;
|
||||||
|
fillSourceOptions();
|
||||||
|
renderGrid();
|
||||||
|
renderPager(document.getElementById('pager'), page, Math.max(1, Math.ceil(totalPhotos / PAGE_SIZE)), goPage);
|
||||||
|
}
|
||||||
|
|
||||||
|
function goPage(p) {
|
||||||
|
page = p;
|
||||||
|
loadPhotos();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('studentFilter').addEventListener('change', () => { page = 1; loadPhotos(); });
|
||||||
|
document.getElementById('groupFilter').addEventListener('change', () => { page = 1; loadPhotos(); });
|
||||||
|
document.getElementById('sourceFilter').addEventListener('change', () => { page = 1; loadPhotos(); });
|
||||||
|
document.getElementById('dateFrom').addEventListener('change', () => { page = 1; loadPhotos(); });
|
||||||
|
document.getElementById('dateTo').addEventListener('change', () => { page = 1; loadPhotos(); });
|
||||||
|
document.getElementById('searchInput').addEventListener('input', () => {
|
||||||
|
clearTimeout(searchTimer);
|
||||||
|
searchTimer = setTimeout(() => { page = 1; loadPhotos(); }, 300);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('imgModal').addEventListener('click', e => e.currentTarget.classList.remove('open'));
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
if (await checkAuth()) {
|
||||||
|
buildSidebar('photos');
|
||||||
|
await loadGroupOptions();
|
||||||
|
await loadStudentOptions();
|
||||||
|
loadPhotos();
|
||||||
|
}
|
||||||
|
})();
|
||||||
+273
-22
@@ -1,5 +1,6 @@
|
|||||||
const DIRTY_FIELDS = ['systemName', 'spamInterval', 'footerLeft', 'footerRight', 'aiPrompt', 'aiAutoCheck', 'shareShowMessage', 'shareShowDate', 'shareShowNames', 'shareShowPhotos', 'cookieNoticeText', 'photoResolution', 'photoQuality', 'photoEngine'];
|
const DIRTY_FIELDS = ['systemName', 'spamInterval', 'footerLeft', 'footerRight', 'aiPrompt', 'aiAutoCheck', 'shareShowMessage', 'shareShowDate', 'shareShowNames', 'shareShowPhotos', 'cookieNoticeText', 'photoResolution', 'photoQuality', 'photoEngine', 'camEnabled', 'trashPurgeDays', 'notifyEnabled', 'notifyRetentionDays'];
|
||||||
const SECTION_IDS = ['sec-system', 'sec-brand', 'sec-antispam', 'sec-share', 'sec-ai', 'sec-backup', 'sec-form', 'sec-bans'];
|
const NOTIFY_IDS = [];
|
||||||
|
const SECTION_IDS = ['sec-system', 'sec-stack', 'sec-brand', 'sec-antispam', 'sec-share', 'sec-ai', 'sec-backup', 'sec-trash', 'sec-form', 'sec-notify', 'sec-bans'];
|
||||||
const SPAM_MAX = 10080;
|
const SPAM_MAX = 10080;
|
||||||
const BAN_REASONS = {
|
const BAN_REASONS = {
|
||||||
'honeypot': 'Антиспам-поле',
|
'honeypot': 'Антиспам-поле',
|
||||||
@@ -10,6 +11,7 @@ const BAN_REASONS = {
|
|||||||
|
|
||||||
let currentLogo = '';
|
let currentLogo = '';
|
||||||
let logoOriginal = '';
|
let logoOriginal = '';
|
||||||
|
let currentSystemName = '';
|
||||||
|
|
||||||
function renderLogoPreview() {
|
function renderLogoPreview() {
|
||||||
const img = document.getElementById('logoPreviewImg');
|
const img = document.getElementById('logoPreviewImg');
|
||||||
@@ -153,6 +155,38 @@ function initSectionNav() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function renderNotifyTypes(s) {
|
||||||
|
const el = document.getElementById('notifyTypes');
|
||||||
|
if (!el) return;
|
||||||
|
const enabledEl = document.getElementById('notifyEnabled');
|
||||||
|
if (enabledEl) enabledEl.checked = s.notify_enabled !== 'false';
|
||||||
|
const retentionEl = document.getElementById('notifyRetentionDays');
|
||||||
|
if (retentionEl) retentionEl.value = s.notify_retention_days !== undefined && s.notify_retention_days !== '' ? s.notify_retention_days : '30';
|
||||||
|
NOTIFY_IDS.length = 0;
|
||||||
|
let types = [];
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API}/api/notifications/meta`, { headers: hdr() });
|
||||||
|
if (res.ok) types = (await res.json()).types || [];
|
||||||
|
} catch {}
|
||||||
|
if (!types.length) {
|
||||||
|
el.innerHTML = '<span class="hint">Не удалось загрузить список событий</span>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
el.innerHTML = types.map(t => {
|
||||||
|
NOTIFY_IDS.push(t.key);
|
||||||
|
if (!DIRTY_FIELDS.includes(t.key)) DIRTY_FIELDS.push(t.key);
|
||||||
|
const raw = s[t.key];
|
||||||
|
const value = raw !== undefined && raw !== '' ? raw : (t.default_enabled ? 'true' : 'false');
|
||||||
|
const badge = t.admin_only ? '<span class="notify-type-badge">только админ</span>' : '';
|
||||||
|
return `<label class="toggle">
|
||||||
|
<input type="checkbox" id="${esc(t.key)}" ${value !== 'false' ? 'checked' : ''}>
|
||||||
|
<span class="toggle-track"><span class="toggle-thumb"></span></span>
|
||||||
|
<span class="toggle-text"><b>${esc(t.label)}${badge}</b><small>${esc(t.hint || '')}</small></span>
|
||||||
|
</label>`;
|
||||||
|
}).join('');
|
||||||
|
renderIcons();
|
||||||
|
}
|
||||||
|
|
||||||
async function loadSettings() {
|
async function loadSettings() {
|
||||||
const [sRes, stRes] = await Promise.all([
|
const [sRes, stRes] = await Promise.all([
|
||||||
fetch(`${API}/api/settings`, { headers: hdr() }),
|
fetch(`${API}/api/settings`, { headers: hdr() }),
|
||||||
@@ -161,10 +195,12 @@ async function loadSettings() {
|
|||||||
if (!sRes.ok) { showError('Не удалось загрузить настройки'); return; }
|
if (!sRes.ok) { showError('Не удалось загрузить настройки'); return; }
|
||||||
const s = await sRes.json();
|
const s = await sRes.json();
|
||||||
document.getElementById('systemName').value = s.system_name !== undefined ? s.system_name : 'WhatIDo';
|
document.getElementById('systemName').value = s.system_name !== undefined ? s.system_name : 'WhatIDo';
|
||||||
|
currentSystemName = s.system_name || 'WhatIDo';
|
||||||
currentLogo = s.system_logo || '';
|
currentLogo = s.system_logo || '';
|
||||||
logoOriginal = currentLogo;
|
logoOriginal = currentLogo;
|
||||||
renderLogoPreview();
|
renderLogoPreview();
|
||||||
if (s.spam_interval_min !== undefined) document.getElementById('spamInterval').value = s.spam_interval_min;
|
if (s.spam_interval_min !== undefined) document.getElementById('spamInterval').value = s.spam_interval_min;
|
||||||
|
document.getElementById('trashPurgeDays').value = s.trash_purge_days !== undefined ? s.trash_purge_days : '30';
|
||||||
document.getElementById('footerLeft').value = s.footer_left || '';
|
document.getElementById('footerLeft').value = s.footer_left || '';
|
||||||
document.getElementById('footerRight').value = s.footer_right || '';
|
document.getElementById('footerRight').value = s.footer_right || '';
|
||||||
document.getElementById('aiPrompt').value = s.ai_prompt || '';
|
document.getElementById('aiPrompt').value = s.ai_prompt || '';
|
||||||
@@ -181,6 +217,8 @@ async function loadSettings() {
|
|||||||
const pe = document.getElementById('photoEngine');
|
const pe = document.getElementById('photoEngine');
|
||||||
pe.value = ['auto', 'server', 'client'].includes(s.photo_enhance_engine) ? s.photo_enhance_engine : 'auto';
|
pe.value = ['auto', 'server', 'client'].includes(s.photo_enhance_engine) ? s.photo_enhance_engine : 'auto';
|
||||||
if (!pe.value) pe.value = 'auto';
|
if (!pe.value) pe.value = 'auto';
|
||||||
|
document.getElementById('camEnabled').checked = s.camera_enabled !== 'false';
|
||||||
|
await renderNotifyTypes(s);
|
||||||
snapshotDirty();
|
snapshotDirty();
|
||||||
updateSpamUi();
|
updateSpamUi();
|
||||||
loadAiQueue();
|
loadAiQueue();
|
||||||
@@ -214,20 +252,32 @@ async function refreshStats() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadSystemInfo() {
|
async function loadSystemInfo(btn) {
|
||||||
const el = document.getElementById('systemInfo');
|
const el = document.getElementById('systemInfo');
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
|
if (btn) btn.classList.add('spinning');
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API}/api/system-info`, { headers: hdr() });
|
const res = await fetch(`${API}/api/system-info`, { headers: hdr() });
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
renderSystemInfo(await res.json());
|
const data = await res.json();
|
||||||
|
renderSystemInfo(data);
|
||||||
|
renderStackInfo(data.stack);
|
||||||
|
const stamp = 'обновлено ' + new Date().toLocaleTimeString('ru', { hour: '2-digit', minute: '2-digit' });
|
||||||
const live = document.getElementById('systemLive');
|
const live = document.getElementById('systemLive');
|
||||||
if (live) live.textContent = 'обновлено ' + new Date().toLocaleTimeString('ru', { hour: '2-digit', minute: '2-digit' });
|
if (live) live.textContent = stamp;
|
||||||
|
const stackLive = document.getElementById('stackLive');
|
||||||
|
if (stackLive) stackLive.textContent = stamp;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
el.innerHTML = '<div class="sys-card err">Ошибка загрузки</div>';
|
el.innerHTML = '<div class="sys-card err">Ошибка загрузки</div>';
|
||||||
|
const st = document.getElementById('stackInfo');
|
||||||
|
if (st) st.innerHTML = '<div class="sys-card err">Ошибка загрузки</div>';
|
||||||
} catch {
|
} catch {
|
||||||
el.innerHTML = '<div class="sys-card err">Ошибка сети</div>';
|
el.innerHTML = '<div class="sys-card err">Ошибка сети</div>';
|
||||||
|
const st = document.getElementById('stackInfo');
|
||||||
|
if (st) st.innerHTML = '<div class="sys-card err">Ошибка сети</div>';
|
||||||
|
} finally {
|
||||||
|
if (btn) btn.classList.remove('spinning');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,6 +286,7 @@ function renderSystemInfo(si) {
|
|||||||
const photos = si.photos || {};
|
const photos = si.photos || {};
|
||||||
const files = si.files || {};
|
const files = si.files || {};
|
||||||
const uploads = si.uploads || {};
|
const uploads = si.uploads || {};
|
||||||
|
const storage = si.storage || {};
|
||||||
const disk = si.disk;
|
const disk = si.disk;
|
||||||
|
|
||||||
let tablesHtml = '';
|
let tablesHtml = '';
|
||||||
@@ -281,10 +332,150 @@ function renderSystemInfo(si) {
|
|||||||
<div class="sys-item"><span class="sys-label">Файлов</span><span class="sys-value">${uploads.count || 0}</span></div>
|
<div class="sys-item"><span class="sys-label">Файлов</span><span class="sys-value">${uploads.count || 0}</span></div>
|
||||||
<div class="sys-item"><span class="sys-label">Размер</span><span class="sys-value">${esc(uploads.size || '0 B')}</span></div>
|
<div class="sys-item"><span class="sys-label">Размер</span><span class="sys-value">${esc(uploads.size || '0 B')}</span></div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="sys-card">
|
||||||
|
<div class="sys-title">Хранилище</div>
|
||||||
|
<div class="sys-item"><span class="sys-label">Драйвер</span><span class="sys-value">${storage.driver === 's3' ? 'S3' : 'Локальное'}</span></div>
|
||||||
|
${storage.bucket ? `<div class="sys-item"><span class="sys-label">Бакет</span><span class="sys-value">${esc(storage.bucket)}${storage.prefix ? '/' + esc(storage.prefix) : ''}</span></div>` : ''}
|
||||||
|
<div class="sys-item"><span class="sys-label">Объектов</span><span class="sys-value">${storage.count || 0}</span></div>
|
||||||
|
<div class="sys-item"><span class="sys-label">Размер</span><span class="sys-value">${esc(storage.size || '0 B')}</span></div>
|
||||||
|
</div>
|
||||||
${diskHtml || '<div class="sys-card"><div class="sys-title">Диск</div><div class="sys-meta">Недоступно</div></div>'}
|
${diskHtml || '<div class="sys-card"><div class="sys-title">Диск</div><div class="sys-meta">Недоступно</div></div>'}
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function fmtDuration(sec) {
|
||||||
|
const s = Math.max(0, Math.round(Number(sec) || 0));
|
||||||
|
if (!s) return '—';
|
||||||
|
const d = Math.floor(s / 86400);
|
||||||
|
const h = Math.floor((s % 86400) / 3600);
|
||||||
|
const m = Math.floor((s % 3600) / 60);
|
||||||
|
if (d) return `${d} ${plural(d, 'день', 'дня', 'дней')} ${h} ${plural(h, 'час', 'часа', 'часов')}`;
|
||||||
|
if (h) return `${h} ${plural(h, 'час', 'часа', 'часов')} ${m} ${plural(m, 'минута', 'минуты', 'минут')}`;
|
||||||
|
if (m) return `${m} ${plural(m, 'минута', 'минуты', 'минут')}`;
|
||||||
|
return `${s} ${plural(s, 'секунда', 'секунды', 'секунд')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stackState(cls, text) {
|
||||||
|
return `<span class="sys-inline"><span class="worker-dot ${cls}"></span>${esc(text)}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stackBar(pct, color) {
|
||||||
|
const p = Math.min(100, Math.max(0, Math.round(pct || 0)));
|
||||||
|
return `<div class="sys-bar-wrap"><div class="sys-bar" style="width:${p}%; background:${color}"></div></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stackRow(label, value) {
|
||||||
|
if (value === null || value === undefined || value === '') return '';
|
||||||
|
return `<div class="sys-item"><span class="sys-label">${esc(label)}</span><span class="sys-value">${value}</span></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderStackInfo(stack) {
|
||||||
|
const el = document.getElementById('stackInfo');
|
||||||
|
if (!el) return;
|
||||||
|
if (!stack) {
|
||||||
|
el.innerHTML = '<div class="sys-card err">Стек недоступен</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const app = stack.app || {};
|
||||||
|
const rt = stack.runtime || {};
|
||||||
|
const db = stack.database || {};
|
||||||
|
const cache = stack.cache || {};
|
||||||
|
const st = stack.storage || {};
|
||||||
|
const deps = stack.deps || {};
|
||||||
|
const load = Array.isArray(rt.cpu_load) ? rt.cpu_load : [];
|
||||||
|
const cpus = rt.cpus || 0;
|
||||||
|
|
||||||
|
let memPct = 0;
|
||||||
|
if (rt.mem_total_bytes) memPct = Math.round(((rt.mem_total_bytes - (rt.mem_free_bytes || 0)) / rt.mem_total_bytes) * 100);
|
||||||
|
const memColor = memPct > 90 ? '#ef4444' : memPct > 70 ? '#f59e0b' : '#16a34a';
|
||||||
|
|
||||||
|
const loadPct = cpus && load[0] ? Math.min(100, (load[0] / cpus) * 100) : 0;
|
||||||
|
const loadColor = loadPct > 90 ? '#ef4444' : loadPct > 60 ? '#f59e0b' : '#16a34a';
|
||||||
|
|
||||||
|
const depsList = Object.entries(deps).filter(([, v]) => v);
|
||||||
|
const depsHtml = depsList.length
|
||||||
|
? '<details class="sys-details"><summary>Библиотеки</summary><div class="sys-table-list">' +
|
||||||
|
depsList.map(([name, v]) => `<div class="sys-table-row"><span>${esc(name)}</span><span>${esc(v)}</span></div>`).join('') +
|
||||||
|
'</div></details>'
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const appTitle = [currentSystemName || app.name, app.version].filter(Boolean).join(' ');
|
||||||
|
const build = app.commit
|
||||||
|
? `${esc(appTitle)} · <span title="${esc(app.commit_date || '')}">${esc(app.commit)}</span>`
|
||||||
|
: esc(appTitle);
|
||||||
|
|
||||||
|
const cacheState = cache.driver === 'redis' && cache.ready
|
||||||
|
? stackState('on', 'подключён')
|
||||||
|
: cache.enabled
|
||||||
|
? stackState('err', 'нет связи — в памяти')
|
||||||
|
: stackState('warn', 'отключён — в памяти');
|
||||||
|
|
||||||
|
const cacheTitle = cache.driver === 'redis'
|
||||||
|
? `Redis${cache.version ? ' ' + cache.version : ''}`
|
||||||
|
: 'In-memory';
|
||||||
|
|
||||||
|
const s3 = st.driver === 's3';
|
||||||
|
const where = s3
|
||||||
|
? stackRow('Бакет', esc(`${st.bucket || '—'}${st.region ? ` · ${st.region}` : ''}`))
|
||||||
|
: stackRow('Каталог', esc(st.dir || '—'));
|
||||||
|
|
||||||
|
el.innerHTML = `
|
||||||
|
<div class="sys-card">
|
||||||
|
<div class="sys-title">Приложение</div>
|
||||||
|
${stackRow('Версия', build)}
|
||||||
|
${stackRow('Node.js', esc(app.node || '—'))}
|
||||||
|
${stackRow('Процесс', esc(`PID ${app.pid} · ${app.rss || '—'}`))}
|
||||||
|
${stackRow('Аптайм', esc(fmtDuration(app.uptime_s)))}
|
||||||
|
<div class="sys-meta">Куча: ${esc(app.heap_used || '—')}${app.name ? ` · пакет ${esc(app.name)}` : ''}</div>
|
||||||
|
${depsHtml}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sys-card${memPct > 90 ? ' danger' : memPct > 70 ? ' warn' : ''}">
|
||||||
|
<div class="sys-title">Сервер</div>
|
||||||
|
${stackRow('ОС', esc(rt.os || '—'))}
|
||||||
|
${stackRow('Ядро', esc(rt.kernel || '—'))}
|
||||||
|
${stackRow('Архитектура', esc(`${rt.arch || '—'} · ${cpus || '—'} ядер`))}
|
||||||
|
${rt.cpu_model ? `<div class="sys-meta">${esc(rt.cpu_model)}</div>` : ''}
|
||||||
|
${stackRow('Загрузка', esc(load.length ? load.map(v => v.toFixed(2)).join(' / ') : '—'))}
|
||||||
|
${load.length ? stackBar(loadPct, loadColor) : ''}
|
||||||
|
${stackRow('Память', esc(`${rt.mem_free || '—'} свободно из ${rt.mem_total || '—'}`))}
|
||||||
|
${stackBar(memPct, memColor)}
|
||||||
|
${stackRow('Аптайм', esc(fmtDuration(rt.uptime_s)))}
|
||||||
|
${stackRow('Среда', rt.container ? esc(rt.container) : esc('без контейнера'))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sys-card">
|
||||||
|
<div class="sys-title">База данных</div>
|
||||||
|
${stackRow('Состояние', db.version ? stackState('on', 'отвечает') : stackState('err', 'нет связи'))}
|
||||||
|
${stackRow('Движок', esc(`${db.engine || 'PostgreSQL'}${db.version ? ' ' + db.version : ''}`))}
|
||||||
|
${stackRow('Адрес', esc(db.host || '—'))}
|
||||||
|
${stackRow('Соединения', esc(`${db.pool_idle ?? 0} свободно из ${db.pool_total ?? 0}${db.pool_waiting ? ` · в очереди ${db.pool_waiting}` : ''}`))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sys-card">
|
||||||
|
<div class="sys-title">Кэш</div>
|
||||||
|
${stackRow('Состояние', cacheState)}
|
||||||
|
${stackRow('Движок', esc(cacheTitle))}
|
||||||
|
${stackRow('Адрес', esc(cache.host || '—'))}
|
||||||
|
${stackRow('Ключей', esc(cache.keys === null || cache.keys === undefined ? '—' : String(cache.keys)))}
|
||||||
|
${stackRow('Память', esc(cache.used_memory || '—'))}
|
||||||
|
${stackRow('Попадания / промахи', esc(`${cache.hits ?? 0} / ${cache.misses ?? 0}`))}
|
||||||
|
${cache.fallback_ops ? stackRow('Операций в памяти', esc(String(cache.fallback_ops))) : ''}
|
||||||
|
${stackRow('Аптайм', esc(fmtDuration(cache.uptime_s)))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sys-card">
|
||||||
|
<div class="sys-title">Хранилище</div>
|
||||||
|
${stackRow('Состояние', stackState('on', s3 ? 'S3-совместимое' : 'локальные файлы'))}
|
||||||
|
${stackRow('Драйвер', esc(s3 ? 's3' : 'local'))}
|
||||||
|
${s3 && st.endpoint ? stackRow('Адрес', esc(st.endpoint)) : ''}
|
||||||
|
${where}
|
||||||
|
${stackRow('Локальный кеш', s3 ? esc(st.local_fallback ? 'включён' : 'выключен') : esc('—'))}
|
||||||
|
${s3 ? stackRow('Локальные копии', esc(st.keep_local ? 'хранятся' : 'удаляются')) : ''}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
async function loadAiProfiles() {
|
async function loadAiProfiles() {
|
||||||
const listEl = document.getElementById('aiProfilesList');
|
const listEl = document.getElementById('aiProfilesList');
|
||||||
const select = document.getElementById('aiActiveProfile');
|
const select = document.getElementById('aiActiveProfile');
|
||||||
@@ -490,6 +681,18 @@ document.addEventListener('keydown', e => {
|
|||||||
if (e.key === 'Escape' && document.getElementById('aiProfileModal').classList.contains('open')) closeAiProfileForm();
|
if (e.key === 'Escape' && document.getElementById('aiProfileModal').classList.contains('open')) closeAiProfileForm();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function notifySettingsPayload(retentionDays) {
|
||||||
|
const out = {
|
||||||
|
notify_enabled: document.getElementById('notifyEnabled').checked ? 'true' : 'false',
|
||||||
|
notify_retention_days: String(retentionDays),
|
||||||
|
};
|
||||||
|
NOTIFY_IDS.forEach(id => {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (el) out[id] = el.checked ? 'true' : 'false';
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
async function saveSettings() {
|
async function saveSettings() {
|
||||||
const v = spamInput.value;
|
const v = spamInput.value;
|
||||||
const n = Number(v);
|
const n = Number(v);
|
||||||
@@ -500,6 +703,18 @@ async function saveSettings() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
clearFieldError('spamInterval');
|
clearFieldError('spamInterval');
|
||||||
|
const trashValue = document.getElementById('trashPurgeDays').value;
|
||||||
|
const trashDays = Math.round(Number(trashValue));
|
||||||
|
if (trashValue === '' || !Number.isFinite(trashDays) || trashDays < 1 || trashDays > 365) {
|
||||||
|
showError('Проверьте поле «Время до безвозвратного удаления» (от 1 до 365 дней)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const notifyRetentionValue = document.getElementById('notifyRetentionDays').value;
|
||||||
|
const notifyRetentionDays = Math.round(Number(notifyRetentionValue));
|
||||||
|
if (notifyRetentionValue === '' || !Number.isFinite(notifyRetentionDays) || notifyRetentionDays < 1 || notifyRetentionDays > 365) {
|
||||||
|
showError('Проверьте поле «Хранение уведомлений» (от 1 до 365 дней)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
const btn = document.getElementById('saveBtn');
|
const btn = document.getElementById('saveBtn');
|
||||||
btn.dataset.loadingText = 'Сохранение...';
|
btn.dataset.loadingText = 'Сохранение...';
|
||||||
setBtnLoading(btn, true);
|
setBtnLoading(btn, true);
|
||||||
@@ -522,6 +737,9 @@ async function saveSettings() {
|
|||||||
photo_capture_resolution: document.getElementById('photoResolution').value,
|
photo_capture_resolution: document.getElementById('photoResolution').value,
|
||||||
photo_capture_quality: String(document.getElementById('photoQuality').value),
|
photo_capture_quality: String(document.getElementById('photoQuality').value),
|
||||||
photo_enhance_engine: document.getElementById('photoEngine').value,
|
photo_enhance_engine: document.getElementById('photoEngine').value,
|
||||||
|
camera_enabled: document.getElementById('camEnabled').checked ? 'true' : 'false',
|
||||||
|
trash_purge_days: String(trashDays),
|
||||||
|
...notifySettingsPayload(notifyRetentionDays),
|
||||||
} })
|
} })
|
||||||
});
|
});
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
@@ -540,25 +758,43 @@ async function saveSettings() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatBackupSize(b) {
|
||||||
|
if (!b || b < 0) return '';
|
||||||
|
if (b >= 1073741824) return (b / 1073741824).toFixed(2) + ' ГБ';
|
||||||
|
return (b / 1048576).toFixed(1) + ' МБ';
|
||||||
|
}
|
||||||
|
|
||||||
async function downloadBackup() {
|
async function downloadBackup() {
|
||||||
const btn = document.getElementById('backupBtn');
|
const btn = document.getElementById('backupBtn');
|
||||||
|
const st = document.getElementById('backupStatus');
|
||||||
btn.dataset.loadingText = 'Формирование...';
|
btn.dataset.loadingText = 'Формирование...';
|
||||||
setBtnLoading(btn, true);
|
setBtnLoading(btn, true);
|
||||||
|
if (st) { st.classList.remove('err'); st.textContent = 'Формирование архива на сервере…'; }
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API}/api/backup`, { headers: hdr() });
|
const res = await fetch(`${API}/api/backup`, { method: 'POST', headers: hdr() });
|
||||||
if (!res.ok) { showError('Ошибка скачивания: ' + (await res.text())); return; }
|
const data = await res.json().catch(() => ({}));
|
||||||
const blob = await res.blob();
|
if (!res.ok) {
|
||||||
const cd = res.headers.get('Content-Disposition') || '';
|
if (st) { st.classList.add('err'); st.textContent = 'Не удалось сформировать бэкап'; }
|
||||||
const m = cd.match(/filename="([^"]+)"/);
|
showError('Ошибка формирования бэкапа: ' + (data.error || res.status));
|
||||||
const name = m ? m[1] : `whatido-backup-${new Date().toISOString().slice(0, 10)}.json.gz`;
|
return;
|
||||||
const url = URL.createObjectURL(blob);
|
}
|
||||||
|
const head = await fetch(data.url, { method: 'HEAD' }).catch(() => null);
|
||||||
|
if (head && !head.ok) {
|
||||||
|
if (st) { st.classList.add('err'); st.textContent = 'Ссылка на архив устарела, попробуйте ещё раз'; }
|
||||||
|
showError('Ошибка скачивания бэкапа');
|
||||||
|
return;
|
||||||
|
}
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
a.href = url;
|
a.href = data.url;
|
||||||
a.download = name;
|
a.download = data.filename || 'whatido-backup.tar.gz';
|
||||||
|
document.body.appendChild(a);
|
||||||
a.click();
|
a.click();
|
||||||
URL.revokeObjectURL(url);
|
a.remove();
|
||||||
showToast('Бэкап скачан');
|
const size = formatBackupSize(data.size);
|
||||||
} catch {
|
if (st) st.textContent = `Архив ${data.filename}${size ? ` (${size})` : ''} — скачивается браузером`;
|
||||||
|
showToast('Бэкап сформирован' + (size ? ': ' + size : ''));
|
||||||
|
} catch (err) {
|
||||||
|
if (st) { st.classList.add('err'); st.textContent = 'Не удалось сформировать бэкап: ' + err.message; }
|
||||||
showError('Ошибка сети при формировании бэкапа');
|
showError('Ошибка сети при формировании бэкапа');
|
||||||
} finally {
|
} finally {
|
||||||
setBtnLoading(btn, false);
|
setBtnLoading(btn, false);
|
||||||
@@ -647,10 +883,25 @@ document.getElementById('saveBtn').addEventListener('click', saveSettings);
|
|||||||
document.getElementById('resetBtn').addEventListener('click', revertChanges);
|
document.getElementById('resetBtn').addEventListener('click', revertChanges);
|
||||||
|
|
||||||
const systemRefreshBtn = document.getElementById('systemRefreshBtn');
|
const systemRefreshBtn = document.getElementById('systemRefreshBtn');
|
||||||
if (systemRefreshBtn) systemRefreshBtn.addEventListener('click', async () => {
|
if (systemRefreshBtn) systemRefreshBtn.addEventListener('click', () => loadSystemInfo(systemRefreshBtn));
|
||||||
systemRefreshBtn.classList.add('spinning');
|
|
||||||
await loadSystemInfo();
|
const stackRefreshBtn = document.getElementById('stackRefreshBtn');
|
||||||
systemRefreshBtn.classList.remove('spinning');
|
if (stackRefreshBtn) stackRefreshBtn.addEventListener('click', () => loadSystemInfo(stackRefreshBtn));
|
||||||
|
|
||||||
|
const notifyTestBtn = document.getElementById('notifyTestBtn');
|
||||||
|
if (notifyTestBtn) notifyTestBtn.addEventListener('click', async () => {
|
||||||
|
notifyTestBtn.classList.add('spinning');
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API}/api/notifications/test`, { method: 'POST', headers: hdr() });
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) { showError(data.error || 'Ошибка отправки уведомления'); return; }
|
||||||
|
if (data.delivered) showToast('Тестовое уведомление отправлено');
|
||||||
|
else showError('Уведомление не создано: выключен общий переключатель или событие «Тестовое уведомление»');
|
||||||
|
} catch {
|
||||||
|
showError('Ошибка сети');
|
||||||
|
} finally {
|
||||||
|
notifyTestBtn.classList.remove('spinning');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const statsRefreshBtn = document.getElementById('statsRefreshBtn');
|
const statsRefreshBtn = document.getElementById('statsRefreshBtn');
|
||||||
@@ -737,12 +988,12 @@ window.addEventListener('beforeunload', e => {
|
|||||||
(async () => {
|
(async () => {
|
||||||
if (await requireAdminPage()) {
|
if (await requireAdminPage()) {
|
||||||
buildSidebar(document.body.dataset.page);
|
buildSidebar(document.body.dataset.page);
|
||||||
bindDirty();
|
|
||||||
initSectionNav();
|
initSectionNav();
|
||||||
updateSpamUi();
|
updateSpamUi();
|
||||||
const authStatus = document.getElementById('authStatus');
|
const authStatus = document.getElementById('authStatus');
|
||||||
try {
|
try {
|
||||||
await loadSettings();
|
await loadSettings();
|
||||||
|
bindDirty();
|
||||||
if (authStatus) authStatus.classList.add('loaded');
|
if (authStatus) authStatus.classList.add('loaded');
|
||||||
} catch {
|
} catch {
|
||||||
if (authStatus) { authStatus.classList.remove('loaded'); authStatus.classList.add('err'); authStatus.textContent = 'Ошибка загрузки настроек'; }
|
if (authStatus) { authStatus.classList.remove('loaded'); authStatus.classList.add('err'); authStatus.textContent = 'Ошибка загрузки настроек'; }
|
||||||
|
|||||||
+177
-13
@@ -1,7 +1,14 @@
|
|||||||
const PAGE_SIZE = 20;
|
const PAGE_SIZE = 20;
|
||||||
let currentEntries = [];
|
let currentEntries = [];
|
||||||
|
let currentGroups = [];
|
||||||
|
let pendingEntries = [];
|
||||||
|
let pendingGroups = [];
|
||||||
let page = 1;
|
let page = 1;
|
||||||
let totalEntries = 0;
|
let totalEntries = 0;
|
||||||
|
let totalGroups = 0;
|
||||||
|
let pendingTotalEntries = 0;
|
||||||
|
let pendingTotalGroups = 0;
|
||||||
|
let purgeDays = 30;
|
||||||
|
|
||||||
function isImageFile(name) {
|
function isImageFile(name) {
|
||||||
return /\.(jpe?g|jfif|png|gif|webp|bmp|avif|svg|ico)$/i.test(name || '');
|
return /\.(jpe?g|jfif|png|gif|webp|bmp|avif|svg|ico)$/i.test(name || '');
|
||||||
@@ -28,32 +35,129 @@ function entryHTML(e) {
|
|||||||
<div class="trash-time">Удалена: ${new Date(e.deleted_at).toLocaleString('ru')}</div>
|
<div class="trash-time">Удалена: ${new Date(e.deleted_at).toLocaleString('ru')}</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="restore-btn" data-restore="${e.id}" title="Восстановить">Восстановить</button>
|
<button class="restore-btn" data-restore="${e.id}" title="Восстановить">Восстановить</button>
|
||||||
<button class="del-btn" data-hard="${e.id}" title="Удалить навсегда (вместе с файлами)">×</button>
|
<button class="del-btn" data-hard="${e.id}" title="Пометить на удаление (через ${purgeDays} дней будет удалена безвозвратно)">×</button>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function groupHTML(g) {
|
||||||
|
return `
|
||||||
|
<div class="card">
|
||||||
|
${g.cover_path ? `<img src="${thumbSrc(g.cover_path)}" data-img="${API}${g.cover_path}" loading="lazy" decoding="async" alt="">` : ''}
|
||||||
|
<div class="info">
|
||||||
|
<div class="name">${esc(g.name)}</div>
|
||||||
|
${g.branch_name ? `<div class="group">${esc(g.branch_name)}</div>` : ''}
|
||||||
|
<div class="trash-time">Удалена: ${new Date(g.deleted_at).toLocaleString('ru')}</div>
|
||||||
|
</div>
|
||||||
|
<button class="restore-btn" data-restore-group="${g.id}" title="Восстановить">Восстановить</button>
|
||||||
|
<button class="del-btn" data-hard-group="${g.id}" title="Пометить на удаление (через ${purgeDays} дней будет удалена безвозвратно вместе с записями и фото)">×</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pendingEntryHTML(e) {
|
||||||
|
return `
|
||||||
|
<div class="card pending">
|
||||||
|
${e.photo_path ? `<img src="${thumbSrc(e.photo_path)}" data-img="${API}${e.photo_path}" loading="lazy" decoding="async" alt="">` : ''}
|
||||||
|
<div class="info">
|
||||||
|
<div class="name">${esc(e.student_name)}</div>
|
||||||
|
<div class="group">${esc(e.group_name)}</div>
|
||||||
|
<div class="desc">${esc(e.description)}</div>
|
||||||
|
${filesHTML(e.files)}
|
||||||
|
<div class="trash-time warn">Удаление через ${purgeDays} дн.: ${new Date(e.purge_at).toLocaleString('ru')}</div>
|
||||||
|
</div>
|
||||||
|
<button class="restore-btn" data-unschedule="${e.id}" title="Вернуть в корзину">В корзину</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pendingGroupHTML(g) {
|
||||||
|
return `
|
||||||
|
<div class="card pending">
|
||||||
|
${g.cover_path ? `<img src="${thumbSrc(g.cover_path)}" data-img="${API}${g.cover_path}" loading="lazy" decoding="async" alt="">` : ''}
|
||||||
|
<div class="info">
|
||||||
|
<div class="name">${esc(g.name)}</div>
|
||||||
|
${g.branch_name ? `<div class="group">${esc(g.branch_name)}</div>` : ''}
|
||||||
|
<div class="trash-time warn">Удаление через ${purgeDays} дн.: ${new Date(g.purge_at).toLocaleString('ru')}</div>
|
||||||
|
</div>
|
||||||
|
<button class="restore-btn" data-unschedule-group="${g.id}" title="Вернуть в корзину">В корзину</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderGroups() {
|
||||||
|
const grid = document.getElementById('groupsGrid');
|
||||||
|
grid.className = 'grid';
|
||||||
|
grid.innerHTML = currentGroups.length
|
||||||
|
? currentGroups.map(groupHTML).join('')
|
||||||
|
: '<div class="empty">Групп в корзине нет</div>';
|
||||||
|
document.getElementById('groupsInfo').textContent =
|
||||||
|
totalGroups > 0 ? `— групп в корзине: ${totalGroups}` : '';
|
||||||
|
}
|
||||||
|
|
||||||
function renderGrid() {
|
function renderGrid() {
|
||||||
const grid = document.getElementById('grid');
|
const grid = document.getElementById('grid');
|
||||||
grid.className = 'grid';
|
grid.className = 'grid';
|
||||||
grid.innerHTML = currentEntries.length
|
grid.innerHTML = currentEntries.length
|
||||||
? currentEntries.map(entryHTML).join('')
|
? currentEntries.map(entryHTML).join('')
|
||||||
: '<div class="empty">Корзина пуста</div>';
|
: '<div class="empty">Записей в корзине нет</div>';
|
||||||
|
document.getElementById('entriesInfo').textContent =
|
||||||
|
totalEntries > 0 ? `— записей в корзине: ${totalEntries}` : '';
|
||||||
document.getElementById('trashInfo').textContent =
|
document.getElementById('trashInfo').textContent =
|
||||||
totalEntries > 0 ? `Записей в корзине: ${totalEntries}` : '';
|
totalEntries + totalGroups > 0
|
||||||
document.getElementById('emptyBtn').disabled = totalEntries === 0;
|
? `В корзине: записей ${totalEntries}, групп ${totalGroups}`
|
||||||
|
: 'Корзина пуста';
|
||||||
|
document.getElementById('emptyBtn').disabled = totalEntries + totalGroups === 0;
|
||||||
renderPager(document.getElementById('pager'), page, Math.max(1, Math.ceil(totalEntries / PAGE_SIZE)), goPage);
|
renderPager(document.getElementById('pager'), page, Math.max(1, Math.ceil(totalEntries / PAGE_SIZE)), goPage);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderPending() {
|
||||||
|
document.getElementById('pendingGroupsGrid').className = 'grid';
|
||||||
|
document.getElementById('pendingGroupsGrid').innerHTML = pendingGroups.length
|
||||||
|
? pendingGroups.map(pendingGroupHTML).join('')
|
||||||
|
: '<div class="empty">Нет групп, помеченных на удаление</div>';
|
||||||
|
document.getElementById('pendingGrid').className = 'grid';
|
||||||
|
document.getElementById('pendingGrid').innerHTML = pendingEntries.length
|
||||||
|
? pendingEntries.map(pendingEntryHTML).join('')
|
||||||
|
: '<div class="empty">Нет записей, помеченных на удаление</div>';
|
||||||
|
document.getElementById('pendingGroupsInfo').textContent =
|
||||||
|
pendingTotalGroups > 0 ? `— ${pendingTotalGroups}` : '';
|
||||||
|
document.getElementById('pendingEntriesInfo').textContent =
|
||||||
|
pendingTotalEntries > 0 ? `— ${pendingTotalEntries}` : '';
|
||||||
|
const n = pendingTotalEntries + pendingTotalGroups;
|
||||||
|
document.getElementById('pendingInfo').textContent =
|
||||||
|
n > 0 ? `— будет удалено: ${n}` : '';
|
||||||
|
const hint = document.getElementById('pendingToggleHint');
|
||||||
|
if (hint) {
|
||||||
|
hint.textContent = n > 0
|
||||||
|
? `Помечено: записей ${pendingTotalEntries}, групп ${pendingTotalGroups} — безвозвратно удалятся через ${purgeDays} дней`
|
||||||
|
: 'Данных, помеченных на удаление, нет';
|
||||||
|
}
|
||||||
|
document.getElementById('pendingSection').hidden = !document.getElementById('pendingToggle').checked;
|
||||||
|
}
|
||||||
|
|
||||||
async function loadTrash() {
|
async function loadTrash() {
|
||||||
const p = new URLSearchParams();
|
const p = new URLSearchParams();
|
||||||
p.set('limit', PAGE_SIZE);
|
p.set('limit', PAGE_SIZE);
|
||||||
p.set('offset', (page - 1) * PAGE_SIZE);
|
p.set('offset', (page - 1) * PAGE_SIZE);
|
||||||
const res = await fetch(`${API}/api/trash?${p}`, { headers: hdr() });
|
const [trashRes, pendRes] = await Promise.all([
|
||||||
const data = await res.json();
|
fetch(`${API}/api/trash?${p}`, { headers: hdr() }),
|
||||||
|
fetch(`${API}/api/trash/pending`, { headers: hdr() }),
|
||||||
|
]);
|
||||||
|
const data = await trashRes.json();
|
||||||
|
const pend = await pendRes.json();
|
||||||
currentEntries = data.entries;
|
currentEntries = data.entries;
|
||||||
totalEntries = data.total;
|
totalEntries = data.total;
|
||||||
|
currentGroups = data.groups;
|
||||||
|
totalGroups = data.total_groups;
|
||||||
|
pendingEntries = pend.entries;
|
||||||
|
pendingTotalEntries = pend.total;
|
||||||
|
pendingGroups = pend.groups;
|
||||||
|
pendingTotalGroups = pend.total_groups;
|
||||||
|
if (pend.purge_days > 0) purgeDays = pend.purge_days;
|
||||||
|
renderGroups();
|
||||||
renderGrid();
|
renderGrid();
|
||||||
|
renderPending();
|
||||||
}
|
}
|
||||||
|
|
||||||
function goPage(p) { page = p; loadTrash(); }
|
function goPage(p) { page = p; loadTrash(); }
|
||||||
@@ -65,22 +169,55 @@ async function restoreEntry(id) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function hardDelete(id) {
|
async function hardDelete(id) {
|
||||||
if (!confirm('Удалить запись навсегда? Файлы и фото будут удалены безвозвратно.')) return;
|
if (!confirm(`Пометить запись на удаление? Она будет безвозвратно удалена через ${purgeDays} дней вместе с файлами и фото.`)) return;
|
||||||
await fetch(`${API}/api/entries/${id}/permanent`, { method: 'DELETE', headers: hdr() });
|
const res = await fetch(`${API}/api/entries/${id}/permanent`, { method: 'DELETE', headers: hdr() });
|
||||||
showToast('Запись удалена навсегда');
|
if (res.ok) { showToast(`Запись помечена на удаление через ${purgeDays} дн.`); loadTrash(); }
|
||||||
loadTrash();
|
else { const err = await res.json(); showToast(err.error || 'Ошибка'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function restoreGroup(id) {
|
||||||
|
const res = await fetch(`${API}/api/groups/${id}/restore`, { method: 'PUT', headers: hdr() });
|
||||||
|
if (res.ok) { showToast('Группа восстановлена'); loadTrash(); }
|
||||||
|
else { const err = await res.json(); showToast(err.error || 'Ошибка восстановления'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hardDeleteGroup(id, name) {
|
||||||
|
if (!confirm(`Пометить группу «${name}» на удаление? Записи журнала, фото и файлы группы будут безвозвратно удалены через ${purgeDays} дней, ученики открепятся.`)) return;
|
||||||
|
const res = await fetch(`${API}/api/groups/${id}/permanent`, { method: 'DELETE', headers: hdr() });
|
||||||
|
if (res.ok) { showToast(`Группа помечена на удаление через ${purgeDays} дн.`); loadTrash(); }
|
||||||
|
else { const err = await res.json(); showToast(err.error || 'Ошибка'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function unscheduleEntry(id) {
|
||||||
|
const res = await fetch(`${API}/api/entries/${id}/unschedule`, { method: 'PUT', headers: hdr() });
|
||||||
|
if (res.ok) { showToast('Запись возвращена в корзину'); loadTrash(); }
|
||||||
|
else { const err = await res.json(); showToast(err.error || 'Ошибка'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function unscheduleGroup(id) {
|
||||||
|
const res = await fetch(`${API}/api/groups/${id}/unschedule`, { method: 'PUT', headers: hdr() });
|
||||||
|
if (res.ok) { showToast('Группа возвращена в корзину'); loadTrash(); }
|
||||||
|
else { const err = await res.json(); showToast(err.error || 'Ошибка'); }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function emptyTrash() {
|
async function emptyTrash() {
|
||||||
if (totalEntries === 0) return;
|
const parts = [];
|
||||||
if (!confirm(`Удалить все записи из корзины (${totalEntries})? Файлы будут удалены безвозвратно.`)) return;
|
if (totalEntries) parts.push(`записей: ${totalEntries}`);
|
||||||
|
if (totalGroups) parts.push(`групп: ${totalGroups}`);
|
||||||
|
if (!parts.length) return;
|
||||||
|
if (!confirm(`Пометить всё из корзины (${parts.join(', ')}) на удаление? Данные будут безвозвратно удалены через ${purgeDays} дней.`)) return;
|
||||||
const res = await fetch(`${API}/api/trash`, { method: 'DELETE', headers: hdr() });
|
const res = await fetch(`${API}/api/trash`, { method: 'DELETE', headers: hdr() });
|
||||||
if (res.ok) { showToast('Корзина очищена'); page = 1; loadTrash(); }
|
if (res.ok) { showToast(`Всё помечено на удаление через ${purgeDays} дн.`); page = 1; loadTrash(); }
|
||||||
else { const err = await res.json(); alert(err.error || 'Ошибка'); }
|
else { const err = await res.json(); alert(err.error || 'Ошибка'); }
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById('emptyBtn').addEventListener('click', emptyTrash);
|
document.getElementById('emptyBtn').addEventListener('click', emptyTrash);
|
||||||
document.getElementById('imgModal').addEventListener('click', e => e.currentTarget.classList.remove('open'));
|
document.getElementById('imgModal').addEventListener('click', e => e.currentTarget.classList.remove('open'));
|
||||||
|
document.getElementById('pendingToggle').addEventListener('change', () => {
|
||||||
|
const open = document.getElementById('pendingToggle').checked;
|
||||||
|
document.getElementById('pendingSection').hidden = !open;
|
||||||
|
if (open) loadTrash();
|
||||||
|
});
|
||||||
|
|
||||||
document.getElementById('grid').addEventListener('click', e => {
|
document.getElementById('grid').addEventListener('click', e => {
|
||||||
const card = e.target.closest('.card');
|
const card = e.target.closest('.card');
|
||||||
@@ -91,6 +228,33 @@ document.getElementById('grid').addEventListener('click', e => {
|
|||||||
if (hd && (e.target === hd || hd.contains(e.target))) { hardDelete(parseInt(hd.dataset.hard, 10)); }
|
if (hd && (e.target === hd || hd.contains(e.target))) { hardDelete(parseInt(hd.dataset.hard, 10)); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
document.getElementById('groupsGrid').addEventListener('click', e => {
|
||||||
|
const card = e.target.closest('.card');
|
||||||
|
if (!card) return;
|
||||||
|
const rs = card.querySelector('[data-restore-group]');
|
||||||
|
if (rs && (e.target === rs || rs.contains(e.target))) { restoreGroup(parseInt(rs.dataset.restoreGroup, 10)); return; }
|
||||||
|
const hd = card.querySelector('[data-hard-group]');
|
||||||
|
if (hd && (e.target === hd || hd.contains(e.target))) {
|
||||||
|
const id = parseInt(hd.dataset.hardGroup, 10);
|
||||||
|
const g = currentGroups.find(x => String(x.id) === String(id));
|
||||||
|
hardDeleteGroup(id, g ? g.name : id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('pendingGrid').addEventListener('click', e => {
|
||||||
|
const card = e.target.closest('.card');
|
||||||
|
if (!card) return;
|
||||||
|
const us = card.querySelector('[data-unschedule]');
|
||||||
|
if (us && (e.target === us || us.contains(e.target))) { unscheduleEntry(parseInt(us.dataset.unschedule, 10)); }
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('pendingGroupsGrid').addEventListener('click', e => {
|
||||||
|
const card = e.target.closest('.card');
|
||||||
|
if (!card) return;
|
||||||
|
const us = card.querySelector('[data-unschedule-group]');
|
||||||
|
if (us && (e.target === us || us.contains(e.target))) { unscheduleGroup(parseInt(us.dataset.unscheduleGroup, 10)); }
|
||||||
|
});
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
if (await checkAuth()) {
|
if (await checkAuth()) {
|
||||||
buildSidebar(document.body.dataset.page);
|
buildSidebar(document.body.dataset.page);
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>Уведомления — Админ-панель</title>
|
||||||
|
<link rel="stylesheet" href="admin.css">
|
||||||
|
</head>
|
||||||
|
<body data-page="notifications">
|
||||||
|
<div class="layout">
|
||||||
|
<div class="sidebar" id="sidebar"></div>
|
||||||
|
<div class="main">
|
||||||
|
<div class="page-head">
|
||||||
|
<h2>Уведомления</h2>
|
||||||
|
<p class="page-sub">События системы: новые записи в журнале, обработка фото нейросетью, блокировки IP и другое</p>
|
||||||
|
<span class="page-status" id="authStatus"><span class="spinner dark"></span> Загрузка…</span>
|
||||||
|
<button class="head-btn" id="nfReloadBtn" type="button" title="Обновить список"><i data-lucide="rotate-ccw"></i> Обновить</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="notify-page-head">
|
||||||
|
<div class="view-toggle" id="notifyFilter">
|
||||||
|
<button type="button" data-filter="all" class="active">Все</button>
|
||||||
|
<button type="button" data-filter="unread">Непрочитанные</button>
|
||||||
|
</div>
|
||||||
|
<span class="grow"></span>
|
||||||
|
<button class="btn-primary ghost" id="nfReadAllBtn" type="button"><i data-lucide="check"></i> Прочитать все</button>
|
||||||
|
<button class="btn-primary danger-ghost" id="nfClearBtn" type="button" hidden><i data-lucide="trash-2"></i> Очистить всё</button>
|
||||||
|
</div>
|
||||||
|
<div class="hint" id="nfState"></div>
|
||||||
|
|
||||||
|
<div class="notify-page-list" id="nfList"></div>
|
||||||
|
<div style="display:flex;justify-content:center;margin-top:16px">
|
||||||
|
<button class="btn-primary ghost" id="nfMoreBtn" type="button" hidden>Показать ещё</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toast" id="toast"></div>
|
||||||
|
<script src="vendor/lucide.min.js"></script>
|
||||||
|
<script src="admin.js"></script>
|
||||||
|
<script src="js/notifications.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>Фото — Админ-панель</title>
|
||||||
|
<link rel="stylesheet" href="admin.css">
|
||||||
|
<style>
|
||||||
|
.ph-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:14px}
|
||||||
|
.ph-card{background:var(--card);border:1px solid var(--border);border-radius:12px;overflow:hidden;display:flex;flex-direction:column;min-width:0}
|
||||||
|
.ph-card img{width:100%;aspect-ratio:4/3;object-fit:cover;display:block;cursor:zoom-in;background:var(--bg);transition:opacity .15s}
|
||||||
|
.ph-card:hover img{opacity:.9}
|
||||||
|
.ph-card .ph-body{padding:10px 12px;display:flex;flex-direction:column;gap:4px;min-width:0}
|
||||||
|
.ph-badge{align-self:flex-start;background:var(--bg);border:1px solid var(--border);color:var(--muted);border-radius:999px;padding:2px 8px;font-size:.68rem;font-weight:700}
|
||||||
|
.ph-title{font-size:.82rem;font-weight:600;overflow:hidden;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;word-break:break-word}
|
||||||
|
.ph-meta{font-size:.72rem;color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||||
|
.ph-time{font-size:.7rem;color:var(--muted)}
|
||||||
|
.ph-link{font-size:.72rem;color:var(--accent);text-decoration:none}
|
||||||
|
.ph-link:hover{text-decoration:underline}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body data-page="photos">
|
||||||
|
<div class="layout">
|
||||||
|
<div class="sidebar" id="sidebar"></div>
|
||||||
|
<div class="main">
|
||||||
|
<h2>Фото</h2>
|
||||||
|
<div class="filters">
|
||||||
|
<div><label>Поиск</label><input type="text" id="searchInput" class="search-input" placeholder="Описание или имя"></div>
|
||||||
|
<div><label>Ученик</label><input type="text" id="studentFilter" list="studentFilterList" placeholder="Все / введите имя..."><datalist id="studentFilterList"></datalist></div>
|
||||||
|
<div><label>Группа</label><select id="groupFilter"><option value="">Все группы</option></select></div>
|
||||||
|
<div><label>Источник</label><select id="sourceFilter"><option value="">Все источники</option></select></div>
|
||||||
|
<div><label>С</label><input type="date" id="dateFrom"></div>
|
||||||
|
<div><label>По</label><input type="date" id="dateTo"></div>
|
||||||
|
</div>
|
||||||
|
<div class="ph-grid" id="grid"><div class="empty" style="grid-column:1/-1">Загрузка...</div></div>
|
||||||
|
<div class="pager" id="pager"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-overlay" id="imgModal">
|
||||||
|
<img id="imgModalSrc">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toast" id="toast"></div>
|
||||||
|
<script src="vendor/lucide.min.js"></script>
|
||||||
|
<script src="admin.js"></script>
|
||||||
|
<script src="js/photos.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+62
-1
@@ -20,13 +20,16 @@
|
|||||||
<div class="settings-layout">
|
<div class="settings-layout">
|
||||||
<aside class="settings-nav" id="settingsNav">
|
<aside class="settings-nav" id="settingsNav">
|
||||||
<a href="#sec-system" data-nav="sec-system" class="active"><i data-lucide="bar-chart-3"></i> Система</a>
|
<a href="#sec-system" data-nav="sec-system" class="active"><i data-lucide="bar-chart-3"></i> Система</a>
|
||||||
|
<a href="#sec-stack" data-nav="sec-stack"><i data-lucide="server"></i> Стек</a>
|
||||||
<a href="#sec-brand" data-nav="sec-brand"><i data-lucide="palette"></i> Брендинг</a>
|
<a href="#sec-brand" data-nav="sec-brand"><i data-lucide="palette"></i> Брендинг</a>
|
||||||
<a href="#sec-antispam" data-nav="sec-antispam"><i data-lucide="shield"></i> Антиспам</a>
|
<a href="#sec-antispam" data-nav="sec-antispam"><i data-lucide="shield"></i> Антиспам</a>
|
||||||
<a href="#sec-share" data-nav="sec-share"><i data-lucide="link"></i> Публичные ссылки</a>
|
<a href="#sec-share" data-nav="sec-share"><i data-lucide="link"></i> Публичные ссылки</a>
|
||||||
<a href="#sec-form" data-nav="sec-form"><i data-lucide="file-text"></i> Форма ответа</a>
|
<a href="#sec-form" data-nav="sec-form"><i data-lucide="file-text"></i> Форма ответа</a>
|
||||||
<a href="#sec-photo" data-nav="sec-photo"><i data-lucide="camera"></i> Фото</a>
|
<a href="#sec-photo" data-nav="sec-photo"><i data-lucide="camera"></i> Фото</a>
|
||||||
|
<a href="#sec-notify" data-nav="sec-notify"><i data-lucide="bell"></i> Уведомления</a>
|
||||||
<a href="#sec-ai" data-nav="sec-ai"><i data-lucide="bot"></i> ИИ</a>
|
<a href="#sec-ai" data-nav="sec-ai"><i data-lucide="bot"></i> ИИ</a>
|
||||||
<a href="#sec-backup" data-nav="sec-backup"><i data-lucide="database"></i> Бэкапы</a>
|
<a href="#sec-backup" data-nav="sec-backup"><i data-lucide="database"></i> Бэкапы</a>
|
||||||
|
<a href="#sec-trash" data-nav="sec-trash"><i data-lucide="trash-2"></i> Корзина</a>
|
||||||
<a href="#sec-bans" data-nav="sec-bans"><i data-lucide="shield-off"></i> Блокировки</a>
|
<a href="#sec-bans" data-nav="sec-bans"><i data-lucide="shield-off"></i> Блокировки</a>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
@@ -43,6 +46,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-card wide" id="sec-stack">
|
||||||
|
<div class="card-head">
|
||||||
|
<div class="ic"><i data-lucide="server"></i></div>
|
||||||
|
<h3>Статус стека</h3>
|
||||||
|
<button class="icon-btn" id="stackRefreshBtn" type="button" title="Обновить" aria-label="Обновить стек"><i data-lucide="refresh-cw"></i></button>
|
||||||
|
<span class="head-live" id="stackLive" title="Версии и состояние сервисов"></span>
|
||||||
|
</div>
|
||||||
|
<p class="hint">На чём работает приложение прямо сейчас: версии компонентов, состояние сервисов и нагрузка. Обновление перезагружает данные и этого блока, и системной информации выше.</p>
|
||||||
|
<div id="stackInfo" class="sys-grid">
|
||||||
|
<div class="sys-card loading">Загрузка…</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="settings-card" id="sec-brand">
|
<div class="settings-card" id="sec-brand">
|
||||||
<div class="card-head">
|
<div class="card-head">
|
||||||
<div class="ic"><i data-lucide="palette"></i></div>
|
<div class="ic"><i data-lucide="palette"></i></div>
|
||||||
@@ -173,7 +189,7 @@
|
|||||||
<h3>Резервное копирование</h3>
|
<h3>Резервное копирование</h3>
|
||||||
<button class="icon-btn" id="statsRefreshBtn" type="button" title="Обновить статистику" aria-label="Обновить статистику"><i data-lucide="refresh-cw"></i></button>
|
<button class="icon-btn" id="statsRefreshBtn" type="button" title="Обновить статистику" aria-label="Обновить статистику"><i data-lucide="refresh-cw"></i></button>
|
||||||
</div>
|
</div>
|
||||||
<p class="hint">Скачайте архив со всеми данными (ученики, группы, журнал, настройки) и фотографиями. Восстановление полностью заменит текущие данные.</p>
|
<p class="hint">Скачайте архив со всеми данными (ученики, группы, журнал, настройки) и фотографиями. Архив формируется на сервере, скачивание выполняет браузер — при обрыве связи загрузку можно продолжить. Восстановление полностью заменит текущие данные.</p>
|
||||||
<div class="data-stats" id="dataStats"><span class="chip">Загрузка статистики…</span></div>
|
<div class="data-stats" id="dataStats"><span class="chip">Загрузка статистики…</span></div>
|
||||||
<div class="card-foot">
|
<div class="card-foot">
|
||||||
<button class="btn-primary ghost" id="backupBtn" type="button"><i data-lucide="download"></i> Скачать бэкап</button>
|
<button class="btn-primary ghost" id="backupBtn" type="button"><i data-lucide="download"></i> Скачать бэкап</button>
|
||||||
@@ -181,6 +197,7 @@
|
|||||||
<input type="file" id="restoreFile" accept=".tar.gz,.gz,application/gzip" style="display:none">
|
<input type="file" id="restoreFile" accept=".tar.gz,.gz,application/gzip" style="display:none">
|
||||||
</div>
|
</div>
|
||||||
<div class="restore-status" id="restoreStatus"></div>
|
<div class="restore-status" id="restoreStatus"></div>
|
||||||
|
<div class="restore-status" id="backupStatus"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="settings-card" id="sec-form">
|
<div class="settings-card" id="sec-form">
|
||||||
@@ -207,6 +224,11 @@
|
|||||||
<h3>Фотография с веб-камеры</h3>
|
<h3>Фотография с веб-камеры</h3>
|
||||||
</div>
|
</div>
|
||||||
<p class="hint">Разрешение и качество снимка на форме ответа ученика. Камера может выдать разрешение, ближайшее к выбранному.</p>
|
<p class="hint">Разрешение и качество снимка на форме ответа ученика. Камера может выдать разрешение, ближайшее к выбранному.</p>
|
||||||
|
<label class="toggle">
|
||||||
|
<input type="checkbox" id="camEnabled">
|
||||||
|
<span class="toggle-track"><span class="toggle-thumb"></span></span>
|
||||||
|
<span class="toggle-text"><b>Кнопка «Камера»</b><small>Показывать кнопку «Камера» на форме ответа. Если выключено, фото делается кликом по превью «Нажми чтобы сделать фото»</small></span>
|
||||||
|
</label>
|
||||||
<div class="settings-row">
|
<div class="settings-row">
|
||||||
<div class="settings-field">
|
<div class="settings-field">
|
||||||
<label for="photoResolution">Разрешение</label>
|
<label for="photoResolution">Разрешение</label>
|
||||||
@@ -237,6 +259,45 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-card" id="sec-trash">
|
||||||
|
<div class="card-head">
|
||||||
|
<div class="ic"><i data-lucide="trash-2"></i></div>
|
||||||
|
<h3>Корзина</h3>
|
||||||
|
</div>
|
||||||
|
<p class="hint">При очистке корзины или удалении пункта данные не удаляются сразу, а помечаются на удаление и исчезают из корзины. Через указанное количество дней они удаляются автоматически и безвозвратно вместе с файлами. До этого помеченные данные можно вернуть в корзину и восстановить (страница «Корзина»).</p>
|
||||||
|
<div class="settings-field">
|
||||||
|
<label for="trashPurgeDays">Время до безвозвратного удаления</label>
|
||||||
|
<div class="unit-row">
|
||||||
|
<input type="number" id="trashPurgeDays" class="settings-input" min="1" max="365" step="1">
|
||||||
|
<span class="unit">дней</span>
|
||||||
|
</div>
|
||||||
|
<span class="hint">От 1 до 365 дней</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-card wide" id="sec-notify">
|
||||||
|
<div class="card-head">
|
||||||
|
<div class="ic"><i data-lucide="bell"></i></div>
|
||||||
|
<h3>Уведомления</h3>
|
||||||
|
<button class="icon-btn" id="notifyTestBtn" type="button" title="Отправить тестовое уведомление" aria-label="Тестовое уведомление"><i data-lucide="send"></i></button>
|
||||||
|
</div>
|
||||||
|
<p class="hint">Уведомления собираются в «колокольчике» админ-панели и на странице «Уведомления». Здесь выбирается, о каких событиях сообщать: новые записи журнала, обработка фото нейросетью, блокировки IP, бэкапы и другое.</p>
|
||||||
|
<label class="toggle">
|
||||||
|
<input type="checkbox" id="notifyEnabled">
|
||||||
|
<span class="toggle-track"><span class="toggle-thumb"></span></span>
|
||||||
|
<span class="toggle-text"><b>Уведомления включены</b><small>Общий выключатель: при снятии галочки не создаётся ни одно уведомление</small></span>
|
||||||
|
</label>
|
||||||
|
<div class="settings-field">
|
||||||
|
<label for="notifyRetentionDays">Хранение уведомлений</label>
|
||||||
|
<div class="unit-row">
|
||||||
|
<input type="number" id="notifyRetentionDays" class="settings-input" min="1" max="365" step="1">
|
||||||
|
<span class="unit">дней</span>
|
||||||
|
</div>
|
||||||
|
<span class="hint">От 1 до 365 дней, старые уведомления удаляются автоматически</span>
|
||||||
|
</div>
|
||||||
|
<div class="notify-types" id="notifyTypes"><span class="hint">Загрузка…</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="settings-card" id="sec-bans">
|
<div class="settings-card" id="sec-bans">
|
||||||
<div class="card-head">
|
<div class="card-head">
|
||||||
<div class="ic"><i data-lucide="shield-off"></i></div>
|
<div class="ic"><i data-lucide="shield-off"></i></div>
|
||||||
|
|||||||
@@ -15,10 +15,30 @@
|
|||||||
<span class="trash-muted" id="trashInfo"></span>
|
<span class="trash-muted" id="trashInfo"></span>
|
||||||
<button class="danger-btn" id="emptyBtn" type="button">Очистить корзину</button>
|
<button class="danger-btn" id="emptyBtn" type="button">Очистить корзину</button>
|
||||||
</div>
|
</div>
|
||||||
|
<label class="toggle pending-toggle" id="pendingToggleRow">
|
||||||
|
<input type="checkbox" id="pendingToggle">
|
||||||
|
<span class="toggle-track"><span class="toggle-thumb"></span></span>
|
||||||
|
<span class="toggle-text"><b>Показать помеченные на удаление</b><small id="pendingToggleHint">Данные не удаляются сразу: они помечаются и исчезают из корзины. Здесь их можно вернуть в корзину и восстановить</small></span>
|
||||||
|
</label>
|
||||||
|
<div class="trash-section" id="pendingSection" hidden>
|
||||||
|
<h3>Помечены на удаление <span class="trash-muted" id="pendingInfo"></span></h3>
|
||||||
|
<p class="trash-muted">Эти данные будут удалены автоматически и безвозвратно вместе с файлами. Чтобы отменить удаление, нажмите «В корзину» — данные вернутся в корзину, откуда их можно восстановить.</p>
|
||||||
|
<h4>Группы <span class="trash-muted" id="pendingGroupsInfo"></span></h4>
|
||||||
|
<div class="grid" id="pendingGroupsGrid"><div class="empty">Загрузка...</div></div>
|
||||||
|
<h4>Записи журнала <span class="trash-muted" id="pendingEntriesInfo"></span></h4>
|
||||||
|
<div class="grid" id="pendingGrid"><div class="empty">Загрузка...</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="trash-section" id="groupsSection">
|
||||||
|
<h3>Группы <span class="trash-muted" id="groupsInfo"></span></h3>
|
||||||
|
<div class="grid" id="groupsGrid"><div class="empty">Загрузка...</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="trash-section">
|
||||||
|
<h3>Записи журнала <span class="trash-muted" id="entriesInfo"></span></h3>
|
||||||
<div class="grid" id="grid"><div class="empty">Загрузка...</div></div>
|
<div class="grid" id="grid"><div class="empty">Загрузка...</div></div>
|
||||||
<div class="pager" id="pager"></div>
|
<div class="pager" id="pager"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="modal-overlay" id="imgModal">
|
<div class="modal-overlay" id="imgModal">
|
||||||
<img id="imgModalSrc">
|
<img id="imgModalSrc">
|
||||||
|
|||||||
@@ -0,0 +1,464 @@
|
|||||||
|
const { createClient } = require('redis');
|
||||||
|
|
||||||
|
const INCR_EXPIRE_LUA = `
|
||||||
|
local n = redis.call('INCR', KEYS[1])
|
||||||
|
if n == 1 then redis.call('PEXPIRE', KEYS[1], ARGV[1]) end
|
||||||
|
return n
|
||||||
|
`;
|
||||||
|
|
||||||
|
const RATE_LIMIT_DEC_LUA = `
|
||||||
|
local hits = redis.call('DECR', KEYS[1])
|
||||||
|
if hits < 0 then
|
||||||
|
redis.call('SET', KEYS[1], '0', 'PX', ARGV[1])
|
||||||
|
hits = 0
|
||||||
|
end
|
||||||
|
return hits
|
||||||
|
`;
|
||||||
|
|
||||||
|
const SCAN_BATCH = 200;
|
||||||
|
|
||||||
|
function parseInfoSections(chunks) {
|
||||||
|
const out = new Map();
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
for (const line of String(chunk || '').split('\n')) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||||
|
const sep = trimmed.indexOf(':');
|
||||||
|
if (sep < 1) continue;
|
||||||
|
out.set(trimmed.slice(0, sep).trim(), trimmed.slice(sep + 1).trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMemoryBackend() {
|
||||||
|
const store = new Map();
|
||||||
|
const listeners = new Map();
|
||||||
|
|
||||||
|
function live(key) {
|
||||||
|
const e = store.get(key);
|
||||||
|
if (!e) return null;
|
||||||
|
if (e.exp && e.exp <= Date.now()) {
|
||||||
|
store.delete(key);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: 'memory',
|
||||||
|
async get(key) {
|
||||||
|
const e = live(key);
|
||||||
|
return e ? e.value : undefined;
|
||||||
|
},
|
||||||
|
async set(key, value, ttlMs) {
|
||||||
|
store.set(key, { value, exp: ttlMs ? Date.now() + ttlMs : 0 });
|
||||||
|
},
|
||||||
|
async del(key) {
|
||||||
|
store.delete(key);
|
||||||
|
},
|
||||||
|
async dropPrefix(prefix) {
|
||||||
|
for (const key of store.keys()) if (key.startsWith(prefix)) store.delete(key);
|
||||||
|
},
|
||||||
|
async clear() {
|
||||||
|
store.clear();
|
||||||
|
},
|
||||||
|
async incr(key, ttlMs) {
|
||||||
|
const e = live(key);
|
||||||
|
if (!e) {
|
||||||
|
store.set(key, { value: 1, exp: ttlMs ? Date.now() + ttlMs : 0 });
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
e.value = (typeof e.value === 'number' ? e.value : 0) + 1;
|
||||||
|
return e.value;
|
||||||
|
},
|
||||||
|
async rateLimitDec(key, ttlMs) {
|
||||||
|
const e = live(key);
|
||||||
|
if (!e) {
|
||||||
|
store.set(key, { value: 0, exp: Date.now() + ttlMs });
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
e.value = Math.max(0, (typeof e.value === 'number' ? e.value : 0) - 1);
|
||||||
|
return e.value;
|
||||||
|
},
|
||||||
|
async publish(channel, payload) {
|
||||||
|
const subs = listeners.get(channel);
|
||||||
|
if (!subs || !subs.size) return 0;
|
||||||
|
for (const fn of [...subs]) {
|
||||||
|
try { fn(payload); } catch (e) { console.error('Cache listener failed:', e.message); }
|
||||||
|
}
|
||||||
|
return subs.size;
|
||||||
|
},
|
||||||
|
on(channel, fn) {
|
||||||
|
if (!listeners.has(channel)) listeners.set(channel, new Set());
|
||||||
|
const set = listeners.get(channel);
|
||||||
|
set.add(fn);
|
||||||
|
return () => { set.delete(fn); };
|
||||||
|
},
|
||||||
|
sweep() {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [key, e] of store) if (e.exp && e.exp <= now) store.delete(key);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createRedis({ url, prefix } = {}) {
|
||||||
|
const namespace = `${prefix && String(prefix).trim() ? String(prefix).trim() : 'whatido'}:`;
|
||||||
|
const memory = createMemoryBackend();
|
||||||
|
const handlers = new Map();
|
||||||
|
const subscribed = new Set();
|
||||||
|
const stats = { hits: 0, misses: 0, writes: 0, drops: 0, errors: 0, publishes: 0, fallbackOps: 0 };
|
||||||
|
const CONNECT_TIMEOUT_MS = Math.max(500, parseInt(process.env.REDIS_CONNECT_TIMEOUT_MS || '5000', 10) || 5000);
|
||||||
|
let client = null;
|
||||||
|
let sub = null;
|
||||||
|
let connecting = null;
|
||||||
|
let warned = false;
|
||||||
|
let sweeper = null;
|
||||||
|
let closed = false;
|
||||||
|
|
||||||
|
function enabled() {
|
||||||
|
return typeof url === 'string' && url.trim() !== '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function ready() {
|
||||||
|
return Boolean(client && client.isReady);
|
||||||
|
}
|
||||||
|
|
||||||
|
function noteError(where, err) {
|
||||||
|
if (closed) return;
|
||||||
|
stats.errors += 1;
|
||||||
|
if (warned) return;
|
||||||
|
warned = true;
|
||||||
|
console.error(`Redis ${where}: ${err && err.message ? err.message : err} — используется in-memory`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function withTimeout(promise, ms, label) {
|
||||||
|
let timer = null;
|
||||||
|
const guard = new Promise((_, reject) => {
|
||||||
|
timer = setTimeout(() => reject(new Error(`${label} timeout ${ms}ms`)), ms);
|
||||||
|
if (timer.unref) timer.unref();
|
||||||
|
});
|
||||||
|
promise.catch(() => {});
|
||||||
|
return Promise.race([promise, guard]).finally(() => { if (timer) clearTimeout(timer); });
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullKey = key => namespace + key;
|
||||||
|
|
||||||
|
async function via(fn, fallback) {
|
||||||
|
if (!ready()) {
|
||||||
|
stats.fallbackOps += 1;
|
||||||
|
return fallback();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return await fn(client);
|
||||||
|
} catch (e) {
|
||||||
|
noteError('command', e);
|
||||||
|
stats.fallbackOps += 1;
|
||||||
|
return fallback();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function scanDelete(client, target, isGlob) {
|
||||||
|
let cursor = '0';
|
||||||
|
do {
|
||||||
|
const res = await client.scan(cursor, { MATCH: (isGlob ? target : target + '*'), COUNT: SCAN_BATCH });
|
||||||
|
cursor = String(res.cursor);
|
||||||
|
if (res.keys.length) await client.del(res.keys);
|
||||||
|
} while (cursor !== '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
function deliver(channel, message) {
|
||||||
|
const set = handlers.get(channel);
|
||||||
|
if (!set) return;
|
||||||
|
for (const fn of [...set]) {
|
||||||
|
try { fn(message); } catch (e) { console.error('Redis handler failed:', e.message); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function subscribeChannel(channel) {
|
||||||
|
if (!sub || !sub.isOpen || subscribed.has(channel)) return;
|
||||||
|
try {
|
||||||
|
await sub.subscribe(channel, message => deliver(channel, message));
|
||||||
|
subscribed.add(channel);
|
||||||
|
} catch (e) {
|
||||||
|
noteError('subscribe', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resubscribeAll() {
|
||||||
|
subscribed.clear();
|
||||||
|
for (const channel of handlers.keys()) await subscribeChannel(channel);
|
||||||
|
}
|
||||||
|
|
||||||
|
const api = {
|
||||||
|
isEnabled: enabled,
|
||||||
|
isReady: ready,
|
||||||
|
namespace,
|
||||||
|
stats: () => ({ ...stats, enabled: enabled(), ready: ready(), namespace }),
|
||||||
|
|
||||||
|
async info() {
|
||||||
|
const base = { ...stats, enabled: enabled(), ready: ready(), namespace };
|
||||||
|
const offline = {
|
||||||
|
...base,
|
||||||
|
driver: 'memory',
|
||||||
|
version: null,
|
||||||
|
server_uptime_s: null,
|
||||||
|
used_memory_bytes: null,
|
||||||
|
used_memory_human: null,
|
||||||
|
keys: null,
|
||||||
|
};
|
||||||
|
if (!ready()) return offline;
|
||||||
|
try {
|
||||||
|
const [raw, dbsize, server] = await Promise.all([
|
||||||
|
client.info('memory'),
|
||||||
|
client.dbSize(),
|
||||||
|
client.info('server'),
|
||||||
|
]);
|
||||||
|
const fields = parseInfoSections([raw, server]);
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
driver: 'redis',
|
||||||
|
version: fields.get('redis_version') || null,
|
||||||
|
server_uptime_s: fields.has('uptime_in_seconds') ? Number(fields.get('uptime_in_seconds')) || null : null,
|
||||||
|
used_memory_bytes: fields.has('used_memory') ? Number(fields.get('used_memory')) || null : null,
|
||||||
|
used_memory_human: fields.get('used_memory_human') || null,
|
||||||
|
keys: Number(dbsize) || 0,
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
noteError('info', e);
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
driver: 'redis',
|
||||||
|
version: null,
|
||||||
|
server_uptime_s: null,
|
||||||
|
used_memory_bytes: null,
|
||||||
|
used_memory_human: null,
|
||||||
|
keys: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async get(key) {
|
||||||
|
const raw = await via(
|
||||||
|
c => c.get(fullKey(key)),
|
||||||
|
() => memory.get(fullKey(key))
|
||||||
|
);
|
||||||
|
if (raw === null || raw === undefined) {
|
||||||
|
stats.misses += 1;
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
stats.hits += 1;
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw);
|
||||||
|
} catch (e) {
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async set(key, value, ttlMs) {
|
||||||
|
const payload = JSON.stringify(value === undefined ? null : value);
|
||||||
|
stats.writes += 1;
|
||||||
|
return via(
|
||||||
|
c => (ttlMs ? c.set(fullKey(key), payload, { PX: ttlMs }) : c.set(fullKey(key), payload)),
|
||||||
|
() => memory.set(fullKey(key), value, ttlMs)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
async del(key) {
|
||||||
|
return via(
|
||||||
|
c => c.del(fullKey(key)),
|
||||||
|
() => memory.del(fullKey(key))
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
async dropPrefix(keyPrefix) {
|
||||||
|
const target = fullKey(keyPrefix);
|
||||||
|
stats.drops += 1;
|
||||||
|
return via(
|
||||||
|
c => scanDelete(c, target),
|
||||||
|
() => memory.dropPrefix(target)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
async dropMatch(pattern) {
|
||||||
|
const target = fullKey(pattern);
|
||||||
|
stats.drops += 1;
|
||||||
|
return via(
|
||||||
|
c => scanDelete(c, target, true),
|
||||||
|
() => memory.dropPrefix(target.split('*')[0])
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
async clear() {
|
||||||
|
stats.drops += 1;
|
||||||
|
return via(
|
||||||
|
c => scanDelete(c, namespace),
|
||||||
|
() => memory.clear()
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
async wrap(key, ttlMs, fn) {
|
||||||
|
const hit = await api.get(key);
|
||||||
|
if (hit !== undefined) return hit;
|
||||||
|
const value = await fn();
|
||||||
|
await api.set(key, value, ttlMs);
|
||||||
|
return value;
|
||||||
|
},
|
||||||
|
|
||||||
|
async incr(key, ttlMs) {
|
||||||
|
return via(
|
||||||
|
c => c.eval(INCR_EXPIRE_LUA, { keys: [fullKey(key)], arguments: [String(ttlMs || 0)] }),
|
||||||
|
() => memory.incr(fullKey(key), ttlMs)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
rateLimitStore(id, defaultWindowMs) {
|
||||||
|
let windowMs = defaultWindowMs || 60 * 1000;
|
||||||
|
const base = fullKey(`rl:${id}:`);
|
||||||
|
|
||||||
|
const bucketFor = key => {
|
||||||
|
const bucket = Math.floor(Date.now() / windowMs);
|
||||||
|
return { key: `${base}${key}:${bucket}`, resetTime: (bucket + 1) * windowMs };
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
async init(options) {
|
||||||
|
if (options && Number.isFinite(options.windowMs) && options.windowMs > 0) {
|
||||||
|
windowMs = options.windowMs;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async increment(key) {
|
||||||
|
const { key: redisKey, resetTime } = bucketFor(key);
|
||||||
|
const ttlMs = windowMs + Math.ceil(windowMs / 10);
|
||||||
|
const hits = await via(
|
||||||
|
c => c.eval(INCR_EXPIRE_LUA, { keys: [redisKey], arguments: [String(ttlMs)] }),
|
||||||
|
() => memory.incr(redisKey, ttlMs)
|
||||||
|
);
|
||||||
|
return { totalHits: Number(hits), resetTime: new Date(resetTime) };
|
||||||
|
},
|
||||||
|
async decrement(key) {
|
||||||
|
const { key: redisKey, resetTime } = bucketFor(key);
|
||||||
|
const ttlMs = Math.max(1, resetTime - Date.now());
|
||||||
|
await via(
|
||||||
|
c => c.eval(RATE_LIMIT_DEC_LUA, { keys: [redisKey], arguments: [String(ttlMs)] }),
|
||||||
|
() => memory.rateLimitDec(redisKey, ttlMs)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
async resetKey(key) {
|
||||||
|
const targets = [];
|
||||||
|
for (let i = 0; i < 2; i += 1) {
|
||||||
|
targets.push(`${base}${key}:${Math.floor(Date.now() / windowMs) - i}`);
|
||||||
|
}
|
||||||
|
return via(
|
||||||
|
c => c.del(targets),
|
||||||
|
async () => { for (const k of targets) await memory.del(k); }
|
||||||
|
);
|
||||||
|
},
|
||||||
|
async resetAll() {
|
||||||
|
return via(
|
||||||
|
c => scanDelete(c, base),
|
||||||
|
() => memory.dropPrefix(base)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
async publish(channel, payload) {
|
||||||
|
stats.publishes += 1;
|
||||||
|
const message = typeof payload === 'string' ? payload : JSON.stringify(payload);
|
||||||
|
if (!ready()) {
|
||||||
|
stats.fallbackOps += 1;
|
||||||
|
return memory.publish(channel, message);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await client.publish(channel, message);
|
||||||
|
return 1;
|
||||||
|
} catch (e) {
|
||||||
|
noteError('publish', e);
|
||||||
|
stats.fallbackOps += 1;
|
||||||
|
return memory.publish(channel, message);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
on(channel, fn) {
|
||||||
|
if (!handlers.has(channel)) handlers.set(channel, new Set());
|
||||||
|
handlers.get(channel).add(fn);
|
||||||
|
memory.on(channel, fn);
|
||||||
|
if (sub && sub.isOpen) subscribeChannel(channel);
|
||||||
|
return () => {
|
||||||
|
const set = handlers.get(channel);
|
||||||
|
if (set) set.delete(fn);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
async connect() {
|
||||||
|
if (!enabled()) {
|
||||||
|
console.log('Redis: REDIS_URL не задан, используется in-memory кэш');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (connecting) return connecting;
|
||||||
|
if (ready()) return true;
|
||||||
|
closed = false;
|
||||||
|
connecting = (async () => {
|
||||||
|
if (!sweeper) {
|
||||||
|
sweeper = setInterval(() => memory.sweep(), 60 * 1000);
|
||||||
|
sweeper.unref();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
client = createClient({
|
||||||
|
url,
|
||||||
|
socket: { reconnectStrategy: retries => Math.min(200 + retries * 200, 5000) },
|
||||||
|
});
|
||||||
|
client.on('error', e => noteError('connection', e));
|
||||||
|
client.on('ready', () => {
|
||||||
|
warned = false;
|
||||||
|
ensureSubscriber().catch(e => noteError('subscriber', e));
|
||||||
|
});
|
||||||
|
await withTimeout(client.connect(), CONNECT_TIMEOUT_MS, 'connect');
|
||||||
|
await ensureSubscriber();
|
||||||
|
const pong = await withTimeout(client.ping(), CONNECT_TIMEOUT_MS, 'ping');
|
||||||
|
console.log(`Redis connected (${pong}), namespace=${namespace}`);
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
noteError('connect', e);
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
connecting = null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return connecting;
|
||||||
|
},
|
||||||
|
|
||||||
|
async close() {
|
||||||
|
closed = true;
|
||||||
|
if (sweeper) {
|
||||||
|
clearInterval(sweeper);
|
||||||
|
sweeper = null;
|
||||||
|
}
|
||||||
|
const pending = [sub, client].filter(Boolean);
|
||||||
|
sub = null;
|
||||||
|
client = null;
|
||||||
|
subscribed.clear();
|
||||||
|
await Promise.all(pending.map(async c => {
|
||||||
|
try { await withTimeout(c.quit(), 2000, 'quit'); } catch (e) {
|
||||||
|
try { c.destroy(); } catch (err) {}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
async function ensureSubscriber() {
|
||||||
|
if (closed || !ready()) return;
|
||||||
|
if (!sub) {
|
||||||
|
sub = client.duplicate();
|
||||||
|
sub.on('error', e => noteError('subscriber', e));
|
||||||
|
sub.on('ready', () => { resubscribeAll().catch(e => noteError('subscribe', e)); });
|
||||||
|
}
|
||||||
|
if (!sub.isOpen) await withTimeout(sub.connect(), CONNECT_TIMEOUT_MS, 'subscriber connect');
|
||||||
|
if (sub.isReady) await resubscribeAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
return api;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { createRedis, createMemoryBackend };
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
const assert = require('assert');
|
||||||
|
const { createRedis } = require('./redis');
|
||||||
|
|
||||||
|
function loadEnv() {
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const file = path.join(__dirname, '.env');
|
||||||
|
if (!fs.existsSync(file)) return;
|
||||||
|
for (const line of fs.readFileSync(file, 'utf8').split('\n')) {
|
||||||
|
const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/);
|
||||||
|
if (m && !(m[1] in process.env)) process.env[m[1]] = m[2];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
loadEnv();
|
||||||
|
const url = process.env.REDIS_URL || `redis://:${process.env.REDIS_PASSWORD || ''}@127.0.0.1:6379`;
|
||||||
|
const prefix = 'test-' + Date.now();
|
||||||
|
const cache = createRedis({ url, prefix });
|
||||||
|
|
||||||
|
const connected = await cache.connect();
|
||||||
|
console.log('connect:', connected, 'ready:', cache.isReady());
|
||||||
|
assert.strictEqual(connected, true, 'must connect');
|
||||||
|
assert.strictEqual(cache.isReady(), true, 'must be ready');
|
||||||
|
|
||||||
|
await cache.set('setting:foo', 'bar', 60000);
|
||||||
|
assert.strictEqual(await cache.get('setting:foo'), 'bar', 'string roundtrip');
|
||||||
|
|
||||||
|
await cache.set('obj', { a: 1, b: [2, 3], c: null }, 60000);
|
||||||
|
assert.deepStrictEqual(await cache.get('obj'), { a: 1, b: [2, 3], c: null }, 'json roundtrip');
|
||||||
|
|
||||||
|
assert.strictEqual(await cache.get('missing'), undefined, 'miss returns undefined');
|
||||||
|
await cache.set('setting:ttl', 'x', 120);
|
||||||
|
await new Promise(r => setTimeout(r, 250));
|
||||||
|
assert.strictEqual(await cache.get('setting:ttl'), undefined, 'ttl expiry');
|
||||||
|
|
||||||
|
await cache.set('groups:1', 'g1', 60000);
|
||||||
|
await cache.set('groups:2', 'g2', 60000);
|
||||||
|
await cache.set('students:1', 's1', 60000);
|
||||||
|
await cache.dropPrefix('groups:');
|
||||||
|
assert.strictEqual(await cache.get('groups:1'), undefined, 'dropPrefix removes groups:1');
|
||||||
|
assert.strictEqual(await cache.get('groups:2'), undefined, 'dropPrefix removes groups:2');
|
||||||
|
assert.strictEqual(await cache.get('students:1'), 's1', 'dropPrefix keeps students:1');
|
||||||
|
|
||||||
|
await cache.set('fail:login:1.2.3.4', 3, 60000);
|
||||||
|
await cache.set('fail:login:5.6.7.8', 9, 60000);
|
||||||
|
await cache.dropMatch('fail:*:1.2.3.4');
|
||||||
|
assert.strictEqual(await cache.get('fail:login:1.2.3.4'), undefined, 'dropMatch suffix removes target');
|
||||||
|
assert.strictEqual(await cache.get('fail:login:5.6.7.8'), 9, 'dropMatch keeps other ip');
|
||||||
|
|
||||||
|
const c1 = await cache.incr('counter:a', 60000);
|
||||||
|
const c2 = await cache.incr('counter:a', 60000);
|
||||||
|
assert.strictEqual(c1, 1, 'incr first = 1');
|
||||||
|
assert.strictEqual(c2, 2, 'incr second = 2');
|
||||||
|
await cache.incr('counter:short', 120);
|
||||||
|
await new Promise(r => setTimeout(r, 250));
|
||||||
|
assert.strictEqual(await cache.incr('counter:short', 60000), 1, 'incr ttl set on first call');
|
||||||
|
|
||||||
|
const hits = [];
|
||||||
|
const received = new Promise(resolve => {
|
||||||
|
cache.on('test:chan', msg => { hits.push(msg); resolve(msg); });
|
||||||
|
});
|
||||||
|
await new Promise(r => setTimeout(r, 200));
|
||||||
|
await cache.publish('test:chan', { hello: 'world' });
|
||||||
|
const got = await Promise.race([received, new Promise(r => setTimeout(() => r('TIMEOUT'), 3000))]);
|
||||||
|
assert.notStrictEqual(got, 'TIMEOUT', 'pubsub must deliver');
|
||||||
|
assert.deepStrictEqual(JSON.parse(got), { hello: 'world' }, 'pubsub payload');
|
||||||
|
assert.strictEqual(hits.length, 1, 'pubsub delivered exactly once (no double delivery)');
|
||||||
|
|
||||||
|
let wraps = 0;
|
||||||
|
const v1 = await cache.wrap('wrap:key', 60000, async () => { wraps += 1; return 'computed'; });
|
||||||
|
const v2 = await cache.wrap('wrap:key', 60000, async () => { wraps += 1; return 'other'; });
|
||||||
|
assert.strictEqual(v1, 'computed', 'wrap returns computed');
|
||||||
|
assert.strictEqual(v2, 'computed', 'wrap returns cached');
|
||||||
|
assert.strictEqual(wraps, 1, 'wrap computed once');
|
||||||
|
|
||||||
|
const store = cache.rateLimitStore('test', 60000);
|
||||||
|
await store.init({ windowMs: 60000 });
|
||||||
|
const r1 = await store.increment('ip1');
|
||||||
|
const r2 = await store.increment('ip1');
|
||||||
|
const r3 = await store.increment('ip2');
|
||||||
|
assert.strictEqual(r1.totalHits, 1, 'rl first');
|
||||||
|
assert.strictEqual(r2.totalHits, 2, 'rl second');
|
||||||
|
assert.strictEqual(r3.totalHits, 1, 'rl separate key');
|
||||||
|
assert.ok(r2.resetTime > Date.now(), 'rl resetTime in future');
|
||||||
|
assert.ok(r2.resetTime instanceof Date, 'rl resetTime is a Date');
|
||||||
|
assert.strictEqual(r1.resetTime.getTime(), r2.resetTime.getTime(), 'rl same window');
|
||||||
|
const ttl = await cache.info();
|
||||||
|
await store.decrement('ip1');
|
||||||
|
assert.strictEqual((await store.increment('ip1')).totalHits, 2, 'decrement then increment');
|
||||||
|
await store.resetKey('ip1');
|
||||||
|
assert.strictEqual((await store.increment('ip1')).totalHits, 1, 'resetKey clears');
|
||||||
|
await store.resetAll();
|
||||||
|
assert.strictEqual((await store.increment('ip1')).totalHits, 1, 'resetAll clears');
|
||||||
|
|
||||||
|
await cache.del('obj');
|
||||||
|
assert.strictEqual(await cache.get('obj'), undefined, 'del');
|
||||||
|
|
||||||
|
const info = await cache.info();
|
||||||
|
console.log('info:', JSON.stringify(info));
|
||||||
|
assert.strictEqual(info.driver, 'redis', 'info driver is redis');
|
||||||
|
assert.ok(info.used_memory_bytes > 0, 'info has memory');
|
||||||
|
|
||||||
|
await cache.clear();
|
||||||
|
assert.strictEqual(await cache.get('students:1'), undefined, 'clear removes all');
|
||||||
|
|
||||||
|
const other = createRedis({ url, prefix: prefix + '-other' });
|
||||||
|
await other.connect();
|
||||||
|
await other.set('iso', 'yes', 60000);
|
||||||
|
assert.strictEqual(await cache.get('iso'), undefined, 'namespaces are isolated');
|
||||||
|
await other.close();
|
||||||
|
|
||||||
|
await cache.close();
|
||||||
|
assert.strictEqual(cache.isReady(), false, 'closed');
|
||||||
|
|
||||||
|
const offline = createRedis({ url: 'redis://127.0.0.1:1/', prefix: 'off-' + Date.now() });
|
||||||
|
const okConn = await offline.connect();
|
||||||
|
assert.strictEqual(okConn, false, 'bad url must not throw');
|
||||||
|
await offline.set('k', 'v', 1000);
|
||||||
|
assert.strictEqual(await offline.get('k'), 'v', 'fallback set/get works');
|
||||||
|
await offline.set('groups:a', 1, 1000);
|
||||||
|
await offline.set('students:a', 1, 1000);
|
||||||
|
await offline.dropPrefix('groups:');
|
||||||
|
assert.strictEqual(await offline.get('groups:a'), undefined, 'fallback dropPrefix');
|
||||||
|
assert.strictEqual(await offline.get('students:a'), 1, 'fallback dropPrefix isolation');
|
||||||
|
assert.strictEqual(await offline.incr('c', 1000), 1, 'fallback incr');
|
||||||
|
assert.strictEqual(await offline.incr('c', 1000), 2, 'fallback incr 2');
|
||||||
|
const offStore = offline.rateLimitStore('t', 60000);
|
||||||
|
assert.strictEqual((await offStore.increment('k1')).totalHits, 1, 'fallback rl');
|
||||||
|
assert.strictEqual((await offStore.increment('k1')).totalHits, 2, 'fallback rl 2');
|
||||||
|
let localHit = null;
|
||||||
|
const localRecv = new Promise(r => { offline.on('c', m => { localHit = m; r(m); }); });
|
||||||
|
await offline.publish('c', { x: 1 });
|
||||||
|
assert.deepStrictEqual(JSON.parse(await Promise.race([localRecv, new Promise(r => setTimeout(() => r('T'), 2000))])), { x: 1 }, 'fallback local pubsub');
|
||||||
|
assert.deepStrictEqual(JSON.parse(localHit), { x: 1 }, 'fallback local payload');
|
||||||
|
await offline.close();
|
||||||
|
|
||||||
|
const disabled = createRedis({});
|
||||||
|
assert.strictEqual(await disabled.connect(), false, 'disabled connect false');
|
||||||
|
await disabled.set('a', 1, 1000);
|
||||||
|
assert.strictEqual(await disabled.get('a'), 1, 'disabled memory works');
|
||||||
|
await disabled.close();
|
||||||
|
|
||||||
|
console.log('\nALL REDIS TESTS PASSED');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(e => { console.error('FAILED:', e.message); console.error(e.stack); process.exit(1); });
|
||||||
+5
-2
@@ -10,10 +10,13 @@ mkdir -p backups
|
|||||||
echo ">> Экспорт БД"
|
echo ">> Экспорт БД"
|
||||||
docker compose exec -T db pg_dump -U app -d whereldo | gzip > backups/db.sql.gz
|
docker compose exec -T db pg_dump -U app -d whereldo | gzip > backups/db.sql.gz
|
||||||
|
|
||||||
echo ">> Копирование фотографий"
|
echo ">> Экспорт файлов из хранилища"
|
||||||
rm -rf backups/_uploads
|
rm -rf backups/_uploads
|
||||||
mkdir -p backups/_uploads
|
mkdir -p backups/_uploads
|
||||||
docker cp "whatido-app-1:/app/uploads/." "backups/_uploads/"
|
docker compose exec -T app rm -rf /tmp/whatido-export
|
||||||
|
docker compose exec -T app node scripts/storage-sync.js export /tmp/whatido-export
|
||||||
|
docker cp "whatido-app-1:/tmp/whatido-export/." "backups/_uploads/"
|
||||||
|
docker compose exec -T app rm -rf /tmp/whatido-export
|
||||||
rm -rf backups/_uploads/.thumbs
|
rm -rf backups/_uploads/.thumbs
|
||||||
|
|
||||||
echo ">> Сборка архива"
|
echo ">> Сборка архива"
|
||||||
|
|||||||
Executable
+53
@@ -0,0 +1,53 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Обновление кода на сервере: pull ветки, сборка образа с версией коммита, перезапуск app.
|
||||||
|
# Использование: ./scripts/deploy.sh [ветка] (по умолчанию master либо $DEPLOY_BRANCH)
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
BRANCH="${1:-${DEPLOY_BRANCH:-master}}"
|
||||||
|
|
||||||
|
if docker compose version >/dev/null 2>&1; then
|
||||||
|
DC="docker compose"
|
||||||
|
else
|
||||||
|
DC="docker-compose"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ">> Рабочая копия"
|
||||||
|
if [ -n "$(git status --porcelain --untracked-files=no -- . ':(exclude)public/version.json')" ]; then
|
||||||
|
git status --short
|
||||||
|
echo "Есть незакоммиченные изменения. Задайте DEPLOY_ALLOW_DIRTY=1, чтобы продолжить."
|
||||||
|
[ "${DEPLOY_ALLOW_DIRTY:-0}" = "1" ] || exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ">> Обновление ветки $BRANCH"
|
||||||
|
git fetch origin --prune
|
||||||
|
git checkout "$BRANCH"
|
||||||
|
git pull --ff-only origin "$BRANCH"
|
||||||
|
|
||||||
|
GIT_COMMIT="$(git rev-parse HEAD)"
|
||||||
|
GIT_COMMIT_DATE="$(git log -1 --format=%cI)"
|
||||||
|
export GIT_COMMIT GIT_COMMIT_DATE
|
||||||
|
echo ">> Сборка: $GIT_COMMIT ($GIT_COMMIT_DATE)"
|
||||||
|
|
||||||
|
$DC build --build-arg "GIT_COMMIT=$GIT_COMMIT" --build-arg "GIT_COMMIT_DATE=$GIT_COMMIT_DATE" app
|
||||||
|
$DC up -d --force-recreate app
|
||||||
|
|
||||||
|
echo ">> Проверка"
|
||||||
|
$DC exec -T app cat /app/public/version.json
|
||||||
|
HOST_MD5="$(md5sum server.js | cut -d' ' -f1)"
|
||||||
|
APP_MD5="$($DC exec -T app md5sum /app/server.js | tr -d '\r' | cut -d' ' -f1)"
|
||||||
|
if [ "$HOST_MD5" = "$APP_MD5" ]; then
|
||||||
|
echo "server.js в контейнере совпадает с рабочей копией: $APP_MD5"
|
||||||
|
else
|
||||||
|
echo "ВНИМАНИЕ: server.js в контейнере ($APP_MD5) не совпадает с рабочей копией ($HOST_MD5)"
|
||||||
|
fi
|
||||||
|
if command -v curl >/dev/null 2>&1; then
|
||||||
|
echo ">> /version.json"
|
||||||
|
for _ in $(seq 1 20); do
|
||||||
|
curl -skf --max-time 3 https://127.0.0.1:3443/version.json >/dev/null 2>&1 && break
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
curl -sk --max-time 5 https://127.0.0.1:3443/version.json || echo "Не удалось получить /version.json (приложение ещё поднимается или образ собран без аргументов версии)"
|
||||||
|
echo
|
||||||
|
fi
|
||||||
|
$DC ps
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
const path = require('path');
|
||||||
|
const { createStorage } = require('../storage');
|
||||||
|
|
||||||
|
const SKIP_PREFIXES = ['.thumbs/', '.cache/'];
|
||||||
|
|
||||||
|
function parseArgs(argv) {
|
||||||
|
const opts = { dryRun: false, deleteLocal: false, verifyOnly: false, concurrency: 3, prefix: null, help: false };
|
||||||
|
for (const arg of argv.slice(2)) {
|
||||||
|
if (arg === '--dry-run') opts.dryRun = true;
|
||||||
|
else if (arg === '--delete-local') opts.deleteLocal = true;
|
||||||
|
else if (arg === '--keep-local') opts.deleteLocal = false;
|
||||||
|
else if (arg === '--verify-only') opts.verifyOnly = true;
|
||||||
|
else if (arg.startsWith('--concurrency=')) opts.concurrency = Math.max(1, parseInt(arg.split('=')[1], 10) || 1);
|
||||||
|
else if (arg.startsWith('--prefix=')) opts.prefix = arg.split('=')[1].replace(/^\/+|\/+$/g, '');
|
||||||
|
else if (arg === '--help' || arg === '-h') opts.help = true;
|
||||||
|
}
|
||||||
|
return opts;
|
||||||
|
}
|
||||||
|
|
||||||
|
function usage() {
|
||||||
|
console.log(`Миграция uploads/ -> S3 (MinIO).
|
||||||
|
|
||||||
|
Использование:
|
||||||
|
node scripts/migrate-to-s3.js [ключи]
|
||||||
|
|
||||||
|
Ключи:
|
||||||
|
--dry-run показать, что будет загружено, ничего не менять
|
||||||
|
--verify-only только проверить наличие объектов в S3
|
||||||
|
--delete-local удалить локальные файлы после успешной проверки
|
||||||
|
--keep-local оставить локальные файлы (по умолчанию)
|
||||||
|
--concurrency=N параллельных загрузок (по умолчанию 3)
|
||||||
|
--prefix=PREFIX префикс ключей внутри бакета
|
||||||
|
|
||||||
|
Переменные окружения: STORAGE_DRIVER=s3, S3_ENDPOINT, S3_BUCKET, S3_ACCESS_KEY, S3_SECRET_KEY.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runPool(items, limit, worker) {
|
||||||
|
const queue = items.slice();
|
||||||
|
const results = { ok: 0, skip: 0, fail: 0 };
|
||||||
|
const runners = Array.from({ length: Math.min(limit, queue.length) }, async () => {
|
||||||
|
while (queue.length) {
|
||||||
|
const item = queue.shift();
|
||||||
|
const res = await worker(item);
|
||||||
|
if (res === 'ok') results.ok++;
|
||||||
|
else if (res === 'skip') results.skip++;
|
||||||
|
else results.fail++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await Promise.all(runners);
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const opts = parseArgs(process.argv);
|
||||||
|
if (opts.help) return usage();
|
||||||
|
const dir = path.join(__dirname, '..', 'uploads');
|
||||||
|
const source = createStorage({ dir, driver: 'local' });
|
||||||
|
const target = createStorage({ driver: 's3', prefix: opts.prefix || undefined });
|
||||||
|
if (!target.isRemote()) {
|
||||||
|
console.error('STORAGE_DRIVER не s3: задайте STORAGE_DRIVER=s3 и переменные S3_*.');
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
if (!target.bucket) {
|
||||||
|
console.error('Не задан S3_BUCKET.');
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
const all = await source.listAll('');
|
||||||
|
const objects = all.filter(o => !SKIP_PREFIXES.some(p => o.key.startsWith(p)));
|
||||||
|
const totalBytes = objects.reduce((sum, o) => sum + (o.size || 0), 0);
|
||||||
|
if (!opts.dryRun) await target.ensureBucket();
|
||||||
|
console.log(`Локально: ${objects.length} файлов, ${(totalBytes / 1048576).toFixed(2)} МБ`);
|
||||||
|
console.log(`Бакет: ${target.bucket}${opts.prefix ? ' префикс ' + opts.prefix : ''} (${process.env.S3_ENDPOINT || 'endpoint по умолчанию'})`);
|
||||||
|
if (opts.dryRun) console.log('Режим --dry-run: изменения не применяются.');
|
||||||
|
|
||||||
|
const failures = [];
|
||||||
|
const result = await runPool(objects, opts.concurrency, async (obj) => {
|
||||||
|
const localPath = source.localFile(obj.key);
|
||||||
|
if (!localPath) {
|
||||||
|
failures.push(`${obj.key}: некорректный путь`);
|
||||||
|
return 'fail';
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const remote = await target.head(obj.key);
|
||||||
|
if (remote && remote.size === obj.size) {
|
||||||
|
if (opts.deleteLocal && !opts.dryRun) source.unlinkLocalOnly(obj.key);
|
||||||
|
return 'skip';
|
||||||
|
}
|
||||||
|
if (opts.verifyOnly) {
|
||||||
|
failures.push(`${obj.key}: нет объекта в S3`);
|
||||||
|
return 'fail';
|
||||||
|
}
|
||||||
|
if (opts.dryRun) {
|
||||||
|
console.log(` + ${obj.key} (${obj.size} Б)`);
|
||||||
|
return 'ok';
|
||||||
|
}
|
||||||
|
await target.putFile(obj.key, localPath);
|
||||||
|
const check = await target.head(obj.key);
|
||||||
|
if (!check || check.size !== obj.size) {
|
||||||
|
failures.push(`${obj.key}: размер не совпал после загрузки`);
|
||||||
|
return 'fail';
|
||||||
|
}
|
||||||
|
if (opts.deleteLocal) source.unlinkLocalOnly(obj.key);
|
||||||
|
return 'ok';
|
||||||
|
} catch (err) {
|
||||||
|
failures.push(`${obj.key}: ${err.message}`);
|
||||||
|
return 'fail';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const usage = await target.usage().catch(() => null);
|
||||||
|
console.log(`Загружено: ${result.ok}, уже было: ${result.skip}, ошибок: ${result.fail}`);
|
||||||
|
if (usage) console.log(`В бакете: ${usage.count} объектов, ${(usage.size_bytes / 1048576).toFixed(2)} МБ`);
|
||||||
|
if (failures.length) {
|
||||||
|
console.error('Ошибки:');
|
||||||
|
for (const f of failures.slice(0, 50)) console.error(' - ' + f);
|
||||||
|
if (failures.length > 50) console.error(` ... ещё ${failures.length - 50}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
if (!opts.deleteLocal && !opts.dryRun) {
|
||||||
|
console.log('Локальные файлы оставлены. Удалить после проверки: node scripts/migrate-to-s3.js --delete-local');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(err => {
|
||||||
|
console.error('Миграция не выполнена:', err.message);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
+11
-2
@@ -28,8 +28,17 @@ echo ">> Восстановление БД (чистая схема)"
|
|||||||
docker compose exec -T db psql -U app -d whereldo -c 'DROP SCHEMA public CASCADE; CREATE SCHEMA public;' >/dev/null
|
docker compose exec -T db psql -U app -d whereldo -c 'DROP SCHEMA public CASCADE; CREATE SCHEMA public;' >/dev/null
|
||||||
gzip -dc "$TMP/db.sql.gz" | docker compose exec -T db psql -U app -d whereldo
|
gzip -dc "$TMP/db.sql.gz" | docker compose exec -T db psql -U app -d whereldo
|
||||||
|
|
||||||
echo ">> Восстановление фотографий"
|
echo ">> Загрузка файлов в хранилище"
|
||||||
docker cp "$TMP/_uploads/." "whatido-app-1:/app/uploads/"
|
docker compose start app
|
||||||
|
for _ in $(seq 1 30); do
|
||||||
|
docker compose exec -T app true >/dev/null 2>&1 && break
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
docker compose exec -T app rm -rf /tmp/whatido-import
|
||||||
|
docker compose exec -T app mkdir -p /tmp/whatido-import
|
||||||
|
docker cp "$TMP/_uploads/." "whatido-app-1:/tmp/whatido-import/"
|
||||||
|
docker compose exec -T app node scripts/storage-sync.js import /tmp/whatido-import
|
||||||
|
docker compose exec -T app rm -rf /tmp/whatido-import
|
||||||
|
|
||||||
echo ">> Запуск приложения"
|
echo ">> Запуск приложения"
|
||||||
docker compose start app
|
docker compose start app
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
const path = require('path');
|
||||||
|
const { createStorage } = require('../storage');
|
||||||
|
|
||||||
|
function usage() {
|
||||||
|
console.log(`Экспорт/импорт файлов хранилища (для scripts/backup.sh и scripts/restore.sh).
|
||||||
|
|
||||||
|
Использование:
|
||||||
|
node scripts/storage-sync.js export <каталог> выгрузить все объекты в каталог
|
||||||
|
node scripts/storage-sync.js import <каталог> загрузить все файлы из каталога в хранилище
|
||||||
|
|
||||||
|
Работает с текущим драйвером из переменных окружения (local — ./uploads, s3 — бакет MinIO/S3).`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const [, , mode, dirArg] = process.argv;
|
||||||
|
if (!mode || !dirArg || mode === '--help' || mode === '-h') {
|
||||||
|
usage();
|
||||||
|
return process.exit(mode ? 0 : 2);
|
||||||
|
}
|
||||||
|
const dir = path.resolve(dirArg);
|
||||||
|
const storage = createStorage({ dir: path.join(__dirname, '..', 'uploads') });
|
||||||
|
if (mode === 'export') {
|
||||||
|
const count = await storage.downloadAll(dir);
|
||||||
|
console.log(`Экспортировано объектов: ${count} -> ${dir}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (mode === 'import') {
|
||||||
|
const count = await storage.uploadTree(dir);
|
||||||
|
console.log(`Импортировано файлов: ${count} <- ${dir}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
usage();
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(err => {
|
||||||
|
console.error('storage-sync failed:', err.message);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
+543
@@ -0,0 +1,543 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const { pipeline } = require('stream/promises');
|
||||||
|
const {
|
||||||
|
S3Client,
|
||||||
|
PutObjectCommand,
|
||||||
|
GetObjectCommand,
|
||||||
|
HeadObjectCommand,
|
||||||
|
DeleteObjectCommand,
|
||||||
|
CopyObjectCommand,
|
||||||
|
ListObjectsV2Command,
|
||||||
|
HeadBucketCommand,
|
||||||
|
CreateBucketCommand,
|
||||||
|
} = require('@aws-sdk/client-s3');
|
||||||
|
|
||||||
|
const MIME_TYPES = {
|
||||||
|
'.jpg': 'image/jpeg',
|
||||||
|
'.jpeg': 'image/jpeg',
|
||||||
|
'.jfif': 'image/jpeg',
|
||||||
|
'.png': 'image/png',
|
||||||
|
'.gif': 'image/gif',
|
||||||
|
'.webp': 'image/webp',
|
||||||
|
'.bmp': 'image/bmp',
|
||||||
|
'.avif': 'image/avif',
|
||||||
|
'.ico': 'image/x-icon',
|
||||||
|
'.heic': 'image/heic',
|
||||||
|
'.heif': 'image/heif',
|
||||||
|
'.pdf': 'application/pdf',
|
||||||
|
'.txt': 'text/plain; charset=utf-8',
|
||||||
|
'.md': 'text/markdown; charset=utf-8',
|
||||||
|
'.html': 'text/html; charset=utf-8',
|
||||||
|
'.htm': 'text/html; charset=utf-8',
|
||||||
|
'.zip': 'application/zip',
|
||||||
|
'.rar': 'application/vnd.rar',
|
||||||
|
'.7z': 'application/x-7z-compressed',
|
||||||
|
'.doc': 'application/msword',
|
||||||
|
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||||
|
};
|
||||||
|
|
||||||
|
const SAFE_SEGMENT = /^[\w,.()-]+$/;
|
||||||
|
const NON_OBJECT_PREFIXES = ['.thumbs/', '.cache/'];
|
||||||
|
|
||||||
|
function mimeFor(name) {
|
||||||
|
const m = String(name || '').toLowerCase().match(/\.[a-z0-9]+$/);
|
||||||
|
return (m && MIME_TYPES[m[0]]) || 'application/octet-stream';
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeKey(input) {
|
||||||
|
if (typeof input !== 'string' || !input) return null;
|
||||||
|
let key = input.replace(/\\/g, '/').replace(/^\/+/, '');
|
||||||
|
if (key.startsWith('uploads/')) key = key.slice('uploads/'.length);
|
||||||
|
if (!key || key.length > 255 || key.includes('..')) return null;
|
||||||
|
const segments = key.split('/');
|
||||||
|
if (segments.some(seg => !SAFE_SEGMENT.test(seg))) return null;
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isObjectKey(key) {
|
||||||
|
return !NON_OBJECT_PREFIXES.some(p => key.startsWith(p));
|
||||||
|
}
|
||||||
|
|
||||||
|
function createStorage(options = {}) {
|
||||||
|
const driver = options.driver || process.env.STORAGE_DRIVER || 'local';
|
||||||
|
const dir = path.resolve(options.dir || path.join(__dirname, 'uploads'));
|
||||||
|
const cacheDir = path.resolve(options.cacheDir || path.join(dir, '.cache'));
|
||||||
|
const localFallback = options.localFallback !== undefined
|
||||||
|
? !!options.localFallback
|
||||||
|
: process.env.STORAGE_LOCAL_FALLBACK !== '0';
|
||||||
|
const keepLocal = options.keepLocal !== undefined
|
||||||
|
? !!options.keepLocal
|
||||||
|
: process.env.STORAGE_KEEP_LOCAL === '1';
|
||||||
|
const bucket = options.bucket || process.env.S3_BUCKET || '';
|
||||||
|
const prefix = String(options.prefix !== undefined ? options.prefix : process.env.S3_PREFIX || '').replace(/^\/+|\/+$/g, '');
|
||||||
|
const cacheMaxAgeMs = Math.max(0, parseInt(options.cacheMaxAgeHours || process.env.STORAGE_CACHE_MAX_AGE_HOURS || '168', 10) || 0) * 3600 * 1000;
|
||||||
|
const remote = driver === 's3';
|
||||||
|
const client = remote
|
||||||
|
? new S3Client({
|
||||||
|
region: options.region || process.env.S3_REGION || 'us-east-1',
|
||||||
|
endpoint: options.endpoint || process.env.S3_ENDPOINT || undefined,
|
||||||
|
forcePathStyle: options.forcePathStyle !== undefined
|
||||||
|
? !!options.forcePathStyle
|
||||||
|
: process.env.S3_FORCE_PATH_STYLE !== '0',
|
||||||
|
credentials: {
|
||||||
|
accessKeyId: options.accessKey || process.env.S3_ACCESS_KEY || '',
|
||||||
|
secretAccessKey: options.secretKey || process.env.S3_SECRET_KEY || '',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
|
||||||
|
function isRemote() {
|
||||||
|
return remote;
|
||||||
|
}
|
||||||
|
|
||||||
|
function objectKey(key) {
|
||||||
|
const k = normalizeKey(key);
|
||||||
|
if (!k) return null;
|
||||||
|
return prefix ? `${prefix}/${k}` : k;
|
||||||
|
}
|
||||||
|
|
||||||
|
function localFile(key) {
|
||||||
|
const k = normalizeKey(key);
|
||||||
|
if (!k) return null;
|
||||||
|
const abs = path.resolve(dir, ...k.split('/'));
|
||||||
|
if (abs !== dir && !abs.startsWith(dir + path.sep)) return null;
|
||||||
|
return abs;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cacheFile(key) {
|
||||||
|
const k = normalizeKey(key);
|
||||||
|
if (!k) return null;
|
||||||
|
return path.join(cacheDir, ...k.split('/'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function keyFromPath(p) {
|
||||||
|
return normalizeKey(p);
|
||||||
|
}
|
||||||
|
|
||||||
|
function localExists(key) {
|
||||||
|
const fp = localFile(key);
|
||||||
|
if (!fp) return false;
|
||||||
|
try {
|
||||||
|
return fs.statSync(fp).isFile();
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function unlinkLocalOnly(key) {
|
||||||
|
for (const fp of [localFile(key), cacheFile(key)]) {
|
||||||
|
if (!fp) continue;
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(fp)) fs.unlinkSync(fp);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMissingError(err) {
|
||||||
|
if (!err) return false;
|
||||||
|
const code = err.name || err.Code || err.code;
|
||||||
|
const status = err.$metadata && err.$metadata.httpStatusCode;
|
||||||
|
return code === 'NoSuchKey' || code === 'NotFound' || code === 'ENOENT' || status === 404;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function put(key, body, opts = {}) {
|
||||||
|
const objKey = objectKey(key);
|
||||||
|
if (!objKey) return false;
|
||||||
|
if (!remote) {
|
||||||
|
const fp = localFile(key);
|
||||||
|
if (!fp) return false;
|
||||||
|
fs.mkdirSync(path.dirname(fp), { recursive: true });
|
||||||
|
if (Buffer.isBuffer(body) || typeof body === 'string') fs.writeFileSync(fp, body);
|
||||||
|
else await pipeline(body, fs.createWriteStream(fp));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
await client.send(new PutObjectCommand({
|
||||||
|
Bucket: bucket,
|
||||||
|
Key: objKey,
|
||||||
|
Body: body,
|
||||||
|
ContentType: opts.contentType || mimeFor(key),
|
||||||
|
ContentLength: opts.contentLength,
|
||||||
|
}));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function putFile(key, localPath, opts = {}) {
|
||||||
|
const objKey = objectKey(key);
|
||||||
|
if (!objKey) return false;
|
||||||
|
let size = null;
|
||||||
|
try {
|
||||||
|
size = fs.statSync(localPath).size;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!remote) {
|
||||||
|
const fp = localFile(key);
|
||||||
|
if (!fp) return false;
|
||||||
|
fs.mkdirSync(path.dirname(fp), { recursive: true });
|
||||||
|
if (path.resolve(localPath) !== fp) fs.copyFileSync(localPath, fp);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
await client.send(new PutObjectCommand({
|
||||||
|
Bucket: bucket,
|
||||||
|
Key: objKey,
|
||||||
|
Body: fs.createReadStream(localPath),
|
||||||
|
ContentLength: size,
|
||||||
|
ContentType: opts.contentType || mimeFor(key),
|
||||||
|
}));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function head(key) {
|
||||||
|
const objKey = objectKey(key);
|
||||||
|
if (!objKey) return null;
|
||||||
|
if (!remote) {
|
||||||
|
const fp = localFile(key);
|
||||||
|
if (!fp) return null;
|
||||||
|
try {
|
||||||
|
const st = fs.statSync(fp);
|
||||||
|
if (!st.isFile()) return null;
|
||||||
|
return { key: normalizeKey(key), size: st.size, contentType: mimeFor(key), etag: null };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const out = await client.send(new HeadObjectCommand({ Bucket: bucket, Key: objKey }));
|
||||||
|
return {
|
||||||
|
key: normalizeKey(key),
|
||||||
|
size: out.ContentLength || 0,
|
||||||
|
contentType: out.ContentType || mimeFor(key),
|
||||||
|
etag: out.ETag ? out.ETag.replace(/"/g, '') : null,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
if (isMissingError(err)) return null;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exists(key) {
|
||||||
|
if (localFallback && localExists(key)) return true;
|
||||||
|
return !!(await head(key));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sizeOf(key) {
|
||||||
|
const info = await head(key);
|
||||||
|
return info ? info.size : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getStream(key) {
|
||||||
|
const objKey = objectKey(key);
|
||||||
|
if (!objKey) return null;
|
||||||
|
if (!remote) {
|
||||||
|
const fp = localFile(key);
|
||||||
|
if (!fp || !fs.existsSync(fp)) return null;
|
||||||
|
return { stream: fs.createReadStream(fp), contentLength: fs.statSync(fp).size, contentType: mimeFor(key) };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const out = await client.send(new GetObjectCommand({ Bucket: bucket, Key: objKey }));
|
||||||
|
return { stream: out.Body, contentLength: out.ContentLength || 0, contentType: out.ContentType || mimeFor(key) };
|
||||||
|
} catch (err) {
|
||||||
|
if (isMissingError(err)) return null;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getBuffer(key) {
|
||||||
|
const fp = await localize(key);
|
||||||
|
if (fp) return fs.readFileSync(fp);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function del(key) {
|
||||||
|
const k = normalizeKey(key);
|
||||||
|
if (!k) return false;
|
||||||
|
unlinkLocalOnly(k);
|
||||||
|
if (!remote) return true;
|
||||||
|
const objKey = objectKey(k);
|
||||||
|
try {
|
||||||
|
await client.send(new DeleteObjectCommand({ Bucket: bucket, Key: objKey }));
|
||||||
|
} catch (err) {
|
||||||
|
if (!isMissingError(err)) throw err;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyObject(srcKey, dstKey) {
|
||||||
|
const src = normalizeKey(srcKey);
|
||||||
|
const dst = normalizeKey(dstKey);
|
||||||
|
if (!src || !dst) return false;
|
||||||
|
if (!remote) {
|
||||||
|
const from = localFile(src);
|
||||||
|
const to = localFile(dst);
|
||||||
|
if (!from || !to || !fs.existsSync(from)) return false;
|
||||||
|
fs.mkdirSync(path.dirname(to), { recursive: true });
|
||||||
|
fs.copyFileSync(from, to);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await client.send(new CopyObjectCommand({
|
||||||
|
Bucket: bucket,
|
||||||
|
Key: objectKey(dst),
|
||||||
|
CopySource: `${bucket}/${objectKey(src)}`,
|
||||||
|
ContentType: mimeFor(dst),
|
||||||
|
MetadataDirective: 'REPLACE',
|
||||||
|
}));
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
if (isMissingError(err)) return false;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listAll(listPrefix = '') {
|
||||||
|
const out = [];
|
||||||
|
if (!remote) {
|
||||||
|
const base = listPrefix ? localFile(listPrefix) : dir;
|
||||||
|
if (!base || !fs.existsSync(base)) return out;
|
||||||
|
const walk = (abs, rel) => {
|
||||||
|
for (const name of fs.readdirSync(abs)) {
|
||||||
|
const childAbs = path.join(abs, name);
|
||||||
|
const childRel = rel ? `${rel}/${name}` : name;
|
||||||
|
let st;
|
||||||
|
try {
|
||||||
|
st = fs.statSync(childAbs);
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (st.isDirectory()) walk(childAbs, childRel);
|
||||||
|
else out.push({ key: childRel, size: st.size });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
walk(base, listPrefix ? normalizeKey(listPrefix) || '' : '');
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
let token = null;
|
||||||
|
const fullPrefix = prefix ? `${prefix}/${listPrefix}` : listPrefix;
|
||||||
|
do {
|
||||||
|
const res = await client.send(new ListObjectsV2Command({
|
||||||
|
Bucket: bucket,
|
||||||
|
Prefix: fullPrefix || undefined,
|
||||||
|
ContinuationToken: token || undefined,
|
||||||
|
}));
|
||||||
|
for (const item of res.Contents || []) {
|
||||||
|
const key = prefix ? String(item.Key).slice(prefix.length + 1) : String(item.Key);
|
||||||
|
out.push({ key, size: item.Size || 0 });
|
||||||
|
}
|
||||||
|
token = res.IsTruncated ? res.NextContinuationToken : null;
|
||||||
|
} while (token);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
async function localize(key, opts = {}) {
|
||||||
|
const k = normalizeKey(key);
|
||||||
|
if (!k) return null;
|
||||||
|
if (localExists(k)) return localFile(k);
|
||||||
|
if (!remote) return null;
|
||||||
|
const cf = cacheFile(k);
|
||||||
|
if (cf && fs.existsSync(cf)) {
|
||||||
|
const info = await head(k);
|
||||||
|
if (info && info.size === fs.statSync(cf).size) return cf;
|
||||||
|
try { fs.unlinkSync(cf); } catch {}
|
||||||
|
}
|
||||||
|
const source = await getStream(k);
|
||||||
|
if (!source) return null;
|
||||||
|
fs.mkdirSync(path.dirname(cf), { recursive: true });
|
||||||
|
const tmp = `${cf}.${process.pid}.${Date.now()}.tmp`;
|
||||||
|
await pipeline(source.stream, fs.createWriteStream(tmp));
|
||||||
|
if (opts.useCache === false) return tmp;
|
||||||
|
fs.renameSync(tmp, cf);
|
||||||
|
return cf;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function persist(key, localPath) {
|
||||||
|
const k = normalizeKey(key);
|
||||||
|
if (!k) return false;
|
||||||
|
const src = path.resolve(localPath);
|
||||||
|
if (!fs.existsSync(src)) return false;
|
||||||
|
if (!remote) return true;
|
||||||
|
const info = await head(k);
|
||||||
|
if (info && info.size === fs.statSync(src).size) {
|
||||||
|
if (!keepLocal) unlinkLocalOnly(k);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
await putFile(k, src);
|
||||||
|
if (!keepLocal) {
|
||||||
|
try { fs.unlinkSync(src); } catch {}
|
||||||
|
const cf = cacheFile(k);
|
||||||
|
if (cf && fs.existsSync(cf)) {
|
||||||
|
try { fs.unlinkSync(cf); } catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function streamTo(res, key, opts = {}) {
|
||||||
|
const k = normalizeKey(key);
|
||||||
|
if (!k) return false;
|
||||||
|
if (localFallback && localExists(k)) {
|
||||||
|
const fp = localFile(k);
|
||||||
|
if (opts.cacheControl) res.setHeader('Cache-Control', opts.cacheControl);
|
||||||
|
if (opts.download) {
|
||||||
|
res.download(fp, opts.name || path.basename(fp), opts.callback || (() => {}));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
res.setHeader('Content-Type', opts.contentType || mimeFor(k));
|
||||||
|
res.sendFile(fp);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const source = await getStream(k);
|
||||||
|
if (!source) return false;
|
||||||
|
if (opts.cacheControl) res.setHeader('Cache-Control', opts.cacheControl);
|
||||||
|
res.setHeader('Content-Type', opts.contentType || source.contentType || mimeFor(k));
|
||||||
|
if (source.contentLength) res.setHeader('Content-Length', String(source.contentLength));
|
||||||
|
if (opts.download) {
|
||||||
|
const name = opts.name || path.basename(k);
|
||||||
|
const ascii = name.replace(/[^\x20-\x7E]/g, '_').replace(/"/g, '');
|
||||||
|
res.setHeader('Content-Disposition', `attachment; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(name)}`);
|
||||||
|
}
|
||||||
|
await pipeline(source.stream, res);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadAll(destDir, opts = {}) {
|
||||||
|
fs.mkdirSync(destDir, { recursive: true });
|
||||||
|
const root = path.resolve(destDir);
|
||||||
|
const objects = await listAll('');
|
||||||
|
let count = 0;
|
||||||
|
for (const obj of objects) {
|
||||||
|
if (!isObjectKey(obj.key)) continue;
|
||||||
|
const k = normalizeKey(obj.key);
|
||||||
|
if (!k) continue;
|
||||||
|
const target = path.resolve(root, ...k.split('/'));
|
||||||
|
if (!target.startsWith(root + path.sep)) continue;
|
||||||
|
const source = await getStream(k);
|
||||||
|
if (!source) continue;
|
||||||
|
fs.mkdirSync(path.dirname(target), { recursive: true });
|
||||||
|
await pipeline(source.stream, fs.createWriteStream(target));
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
if (opts.pruneThumbs !== false) {
|
||||||
|
const thumbs = path.join(root, '.thumbs');
|
||||||
|
if (fs.existsSync(thumbs)) fs.rmSync(thumbs, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadTree(srcDir) {
|
||||||
|
const root = path.resolve(srcDir);
|
||||||
|
if (!fs.existsSync(root)) return 0;
|
||||||
|
let count = 0;
|
||||||
|
const walk = async (abs, rel) => {
|
||||||
|
for (const name of fs.readdirSync(abs)) {
|
||||||
|
const childAbs = path.join(abs, name);
|
||||||
|
const childRel = rel ? `${rel}/${name}` : name;
|
||||||
|
let st;
|
||||||
|
try {
|
||||||
|
st = fs.statSync(childAbs);
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (st.isDirectory()) {
|
||||||
|
if (name === '.thumbs' || name === '.cache') continue;
|
||||||
|
await walk(childAbs, childRel);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const k = normalizeKey(childRel);
|
||||||
|
if (!k) continue;
|
||||||
|
await putFile(k, childAbs);
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
await walk(root, '');
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureBucket() {
|
||||||
|
if (!remote || !bucket) return false;
|
||||||
|
try {
|
||||||
|
await client.send(new HeadBucketCommand({ Bucket: bucket }));
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
const status = err && err.$metadata && err.$metadata.httpStatusCode;
|
||||||
|
const name = err && (err.name || err.Code);
|
||||||
|
if (status && status !== 404 && name !== 'NotFound' && name !== 'NoSuchBucket') throw err;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await client.send(new CreateBucketCommand({ Bucket: bucket }));
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
const name = err && (err.name || err.Code);
|
||||||
|
if (name === 'BucketAlreadyOwnedByYou' || name === 'BucketAlreadyExists') return true;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function usage() {
|
||||||
|
const objects = (await listAll('')).filter(o => isObjectKey(o.key));
|
||||||
|
return {
|
||||||
|
count: objects.length,
|
||||||
|
size_bytes: objects.reduce((sum, o) => sum + (o.size || 0), 0),
|
||||||
|
bucket: remote ? bucket : null,
|
||||||
|
prefix: prefix || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function pruneCache(now = Date.now()) {
|
||||||
|
if (!cacheMaxAgeMs || !fs.existsSync(cacheDir)) return 0;
|
||||||
|
let removed = 0;
|
||||||
|
const walk = (abs) => {
|
||||||
|
for (const name of fs.readdirSync(abs)) {
|
||||||
|
const childAbs = path.join(abs, name);
|
||||||
|
let st;
|
||||||
|
try {
|
||||||
|
st = fs.statSync(childAbs);
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (st.isDirectory()) {
|
||||||
|
walk(childAbs);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (now - st.mtimeMs > cacheMaxAgeMs) {
|
||||||
|
try { fs.unlinkSync(childAbs); removed++; } catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
walk(cacheDir);
|
||||||
|
return removed;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
bucket,
|
||||||
|
cacheDir,
|
||||||
|
isRemote,
|
||||||
|
keyFromPath,
|
||||||
|
localFile,
|
||||||
|
unlinkLocalOnly,
|
||||||
|
normalizeKey,
|
||||||
|
put,
|
||||||
|
putFile,
|
||||||
|
head,
|
||||||
|
exists,
|
||||||
|
sizeOf,
|
||||||
|
getStream,
|
||||||
|
getBuffer,
|
||||||
|
del,
|
||||||
|
copyObject,
|
||||||
|
listAll,
|
||||||
|
localize,
|
||||||
|
persist,
|
||||||
|
streamTo,
|
||||||
|
downloadAll,
|
||||||
|
uploadTree,
|
||||||
|
ensureBucket,
|
||||||
|
usage,
|
||||||
|
pruneCache,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { createStorage, mimeFor, normalizeKey };
|
||||||
|
|
||||||
@@ -6,14 +6,24 @@ const MIN_TEXT_CHARS = 4;
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
|
const { textDiff, FIELD_LABELS } = require('./diff');
|
||||||
const PHOTO_MAX_ATTEMPTS = 3;
|
const PHOTO_MAX_ATTEMPTS = 3;
|
||||||
const PHOTO_AI_TIMEOUT_MS = 300000;
|
const PHOTO_AI_TIMEOUT_MS = 300000;
|
||||||
|
|
||||||
function createPhotoEnhanceWorker({ pool, getSetting, logAudit, invalidateEntries, sharp, photoAiUrl, uploadsDir, originalsDir }) {
|
function createPhotoEnhanceWorker({ pool, getSetting, logAudit, invalidateEntries, sharp, photoAiUrl, uploadsDir, storage, bus, notifyEvent }) {
|
||||||
const AI_URL = photoAiUrl || process.env.PHOTO_AI_URL || '';
|
const AI_URL = photoAiUrl || process.env.PHOTO_AI_URL || '';
|
||||||
const IDLE_MIN = 2000;
|
const IDLE_MIN = 2000;
|
||||||
const IDLE_MAX = 30000;
|
const IDLE_MAX = 30000;
|
||||||
|
|
||||||
|
async function sendNotification(entryId, payload) {
|
||||||
|
if (!notifyEvent) return;
|
||||||
|
try {
|
||||||
|
await notifyEvent(entryId, payload);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Photo worker notification failed:', e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let started = false;
|
let started = false;
|
||||||
let stopped = false;
|
let stopped = false;
|
||||||
let idleMs = IDLE_MIN;
|
let idleMs = IDLE_MIN;
|
||||||
@@ -37,10 +47,17 @@ function createPhotoEnhanceWorker({ pool, getSetting, logAudit, invalidateEntrie
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function notify() {
|
function wakeLocal() {
|
||||||
if (wake) wake();
|
if (wake) wake();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function notify() {
|
||||||
|
wakeLocal();
|
||||||
|
if (bus) bus.publish();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bus) bus.subscribe(wakeLocal);
|
||||||
|
|
||||||
async function isEnabled() {
|
async function isEnabled() {
|
||||||
const v = await getSetting('photo_worker_enabled', 'true');
|
const v = await getSetting('photo_worker_enabled', 'true');
|
||||||
return String(v) !== 'false';
|
return String(v) !== 'false';
|
||||||
@@ -71,30 +88,7 @@ function createPhotoEnhanceWorker({ pool, getSetting, logAudit, invalidateEntrie
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function safeUnlinkPath(p) {
|
async function enhanceWithSharp(srcKey, params) {
|
||||||
try {
|
|
||||||
if (!p) return;
|
|
||||||
const abs = path.resolve(p);
|
|
||||||
const root = path.resolve(uploadsDir);
|
|
||||||
if (abs !== root && abs.startsWith(root + path.sep)) fs.unlinkSync(abs);
|
|
||||||
} catch (e) { console.error('photo worker unlink:', e.message); }
|
|
||||||
}
|
|
||||||
|
|
||||||
function backupOldFile(oldRelPath) {
|
|
||||||
if (!oldRelPath) return null;
|
|
||||||
const oldAbs = path.join(uploadsDir, String(oldRelPath).replace(/^\/+/, '').replace(/^uploads\//, ''));
|
|
||||||
if (!fs.existsSync(oldAbs)) return null;
|
|
||||||
const backupName = crypto.randomBytes(12).toString('hex') + (path.extname(oldAbs) || '.jpg');
|
|
||||||
const backupPath = path.join(originalsDir, backupName);
|
|
||||||
try {
|
|
||||||
fs.renameSync(oldAbs, backupPath);
|
|
||||||
return `/uploads/.originals/${backupName}`;
|
|
||||||
} catch (e) {
|
|
||||||
console.error('photo worker backup failed:', e.message);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
async function enhanceWithSharp(srcAbs, params) {
|
|
||||||
if (!sharp) throw new Error('sharp недоступен на сервере');
|
if (!sharp) throw new Error('sharp недоступен на сервере');
|
||||||
const p = params || {};
|
const p = params || {};
|
||||||
const clamp = (v, min, max, def) => {
|
const clamp = (v, min, max, def) => {
|
||||||
@@ -106,7 +100,9 @@ function createPhotoEnhanceWorker({ pool, getSetting, logAudit, invalidateEntrie
|
|||||||
const saturate = clamp(p.saturate, 0, 300, 100);
|
const saturate = clamp(p.saturate, 0, 300, 100);
|
||||||
const sharpAmt = clamp(p.sharp, 0, 100, 0);
|
const sharpAmt = clamp(p.sharp, 0, 100, 0);
|
||||||
const denoise = clamp(p.denoise, 0, 100, 0);
|
const denoise = clamp(p.denoise, 0, 100, 0);
|
||||||
let pipeline = sharp(srcAbs).rotate();
|
const srcPath = await storage.localize(srcKey);
|
||||||
|
if (!srcPath) throw new Error('Файл фото не найден');
|
||||||
|
let pipeline = sharp(srcPath).rotate();
|
||||||
if (denoise > 0) pipeline = pipeline.median(denoise > 70 ? 5 : 3);
|
if (denoise > 0) pipeline = pipeline.median(denoise > 70 ? 5 : 3);
|
||||||
pipeline = pipeline.modulate({ brightness: brightness / 100, saturation: saturate / 100 });
|
pipeline = pipeline.modulate({ brightness: brightness / 100, saturation: saturate / 100 });
|
||||||
if (contrast !== 100) {
|
if (contrast !== 100) {
|
||||||
@@ -117,13 +113,16 @@ function createPhotoEnhanceWorker({ pool, getSetting, logAudit, invalidateEntrie
|
|||||||
pipeline = pipeline.sharpen({ sigma: 0.5 + (sharpAmt / 100) * 1.5, m1: 0, m2: 1 + sharpAmt / 50 });
|
pipeline = pipeline.sharpen({ sigma: 0.5 + (sharpAmt / 100) * 1.5, m1: 0, m2: 1 + sharpAmt / 50 });
|
||||||
}
|
}
|
||||||
const newName = crypto.randomBytes(12).toString('hex') + '.jpg';
|
const newName = crypto.randomBytes(12).toString('hex') + '.jpg';
|
||||||
await pipeline.jpeg({ quality: 92, mozjpeg: true }).toFile(path.join(uploadsDir, newName));
|
const outPath = path.join(uploadsDir, newName);
|
||||||
|
await pipeline.jpeg({ quality: 92, mozjpeg: true }).toFile(outPath);
|
||||||
|
await storage.persist(newName, outPath);
|
||||||
return `/uploads/${newName}`;
|
return `/uploads/${newName}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runAiEnhance(srcAbs) {
|
async function runAiEnhance(srcKey) {
|
||||||
if (!AI_URL) throw new Error('PHOTO_AI_URL не настроен');
|
if (!AI_URL) throw new Error('PHOTO_AI_URL не настроен');
|
||||||
const buf = fs.readFileSync(srcAbs);
|
const buf = await storage.getBuffer(srcKey);
|
||||||
|
if (!buf) throw new Error('Файл фото не найден');
|
||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
fd.append('image', new Blob([buf], { type: 'image/jpeg' }), 'photo.jpg');
|
fd.append('image', new Blob([buf], { type: 'image/jpeg' }), 'photo.jpg');
|
||||||
fd.append('scale', '2');
|
fd.append('scale', '2');
|
||||||
@@ -135,7 +134,9 @@ function createPhotoEnhanceWorker({ pool, getSetting, logAudit, invalidateEntrie
|
|||||||
if (!resp.ok) throw new Error('AI service error: ' + resp.status);
|
if (!resp.ok) throw new Error('AI service error: ' + resp.status);
|
||||||
const out = Buffer.from(await resp.arrayBuffer());
|
const out = Buffer.from(await resp.arrayBuffer());
|
||||||
const newName = crypto.randomBytes(12).toString('hex') + '.jpg';
|
const newName = crypto.randomBytes(12).toString('hex') + '.jpg';
|
||||||
fs.writeFileSync(path.join(uploadsDir, newName), out);
|
const outPath = path.join(uploadsDir, newName);
|
||||||
|
fs.writeFileSync(outPath, out);
|
||||||
|
await storage.persist(newName, outPath);
|
||||||
return `/uploads/${newName}`;
|
return `/uploads/${newName}`;
|
||||||
}
|
}
|
||||||
async function applyResult(job, newPath) {
|
async function applyResult(job, newPath) {
|
||||||
@@ -144,6 +145,12 @@ function createPhotoEnhanceWorker({ pool, getSetting, logAudit, invalidateEntrie
|
|||||||
[newPath, job.id]
|
[newPath, job.id]
|
||||||
);
|
);
|
||||||
if (logAudit) await logAudit(null, 'photo.job.preview', { entry_id: job.entry_id, job_id: job.id, action: job.action, after_path: newPath });
|
if (logAudit) await logAudit(null, 'photo.job.preview', { entry_id: job.entry_id, job_id: job.id, action: job.action, after_path: newPath });
|
||||||
|
await sendNotification(job.entry_id, {
|
||||||
|
type: 'photo.job.done',
|
||||||
|
title: job.action === 'ai' ? 'Фото обработано нейросетью: {student}' : 'Фото улучшено на сервере: {student}',
|
||||||
|
body: `Группа {group} · запись #${job.entry_id} · результат ждёт применения`,
|
||||||
|
target: { job_id: job.id, action: job.action, after_path: newPath },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
async function processOne(job) {
|
async function processOne(job) {
|
||||||
stats.jobs++;
|
stats.jobs++;
|
||||||
@@ -156,12 +163,17 @@ function createPhotoEnhanceWorker({ pool, getSetting, logAudit, invalidateEntrie
|
|||||||
stats.errors++;
|
stats.errors++;
|
||||||
stats.last_at = new Date().toISOString();
|
stats.last_at = new Date().toISOString();
|
||||||
stats.last_error = 'У записи нет фото';
|
stats.last_error = 'У записи нет фото';
|
||||||
|
await sendNotification(job.entry_id, {
|
||||||
|
type: 'photo.job.error',
|
||||||
|
title: 'Ошибка обработки фото: {student}',
|
||||||
|
body: `Группа {group} · запись #${job.entry_id} · у записи нет фото для обработки`,
|
||||||
|
target: { job_id: job.id, action: job.action },
|
||||||
|
});
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
const photoPath = rows[0].photo_path;
|
const photoPath = rows[0].photo_path;
|
||||||
const srcAbs = path.join(uploadsDir, String(photoPath).replace(/^\/+/, '').replace(/^uploads\//, ''));
|
|
||||||
try {
|
try {
|
||||||
const newPath = job.action === 'ai' ? await runAiEnhance(srcAbs) : await enhanceWithSharp(srcAbs, job.params);
|
const newPath = job.action === 'ai' ? await runAiEnhance(photoPath) : await enhanceWithSharp(photoPath, job.params);
|
||||||
await applyResult(job, newPath);
|
await applyResult(job, newPath);
|
||||||
stats.done++;
|
stats.done++;
|
||||||
stats.last_at = new Date().toISOString();
|
stats.last_at = new Date().toISOString();
|
||||||
@@ -184,6 +196,12 @@ function createPhotoEnhanceWorker({ pool, getSetting, logAudit, invalidateEntrie
|
|||||||
);
|
);
|
||||||
stats.errors++;
|
stats.errors++;
|
||||||
if (logAudit) await logAudit(null, 'photo.job.error', { entry_id: job.entry_id, job_id: job.id, error: message });
|
if (logAudit) await logAudit(null, 'photo.job.error', { entry_id: job.entry_id, job_id: job.id, error: message });
|
||||||
|
await sendNotification(job.entry_id, {
|
||||||
|
type: 'photo.job.error',
|
||||||
|
title: 'Ошибка обработки фото: {student}',
|
||||||
|
body: `Группа {group} · запись #${job.entry_id} · ${message}`,
|
||||||
|
target: { job_id: job.id, action: job.action, error: message },
|
||||||
|
});
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -250,7 +268,7 @@ function createPhotoEnhanceWorker({ pool, getSetting, logAudit, invalidateEntrie
|
|||||||
return { start, notify, getInfo };
|
return { start, notify, getInfo };
|
||||||
}
|
}
|
||||||
|
|
||||||
function createEntryAutoChecker({ pool, getSetting, logAudit, aiUrl, defaultPrompt, model }) {
|
function createEntryAutoChecker({ pool, getSetting, logAudit, aiUrl, defaultPrompt, model, bus }) {
|
||||||
const AI_URL = aiUrl || process.env.AI_URL || 'http://text-corrector:8080';
|
const AI_URL = aiUrl || process.env.AI_URL || 'http://text-corrector:8080';
|
||||||
const MODEL = model || process.env.AI_MODEL || 'qwen2.5-1.5b-instruct-q4_k_m.gguf';
|
const MODEL = model || process.env.AI_MODEL || 'qwen2.5-1.5b-instruct-q4_k_m.gguf';
|
||||||
const DEFAULT_PROMPT = defaultPrompt || process.env.AI_PROMPT || 'Ты — редактор текстов. Исправь ТОЛЬКО грамматические, орфографические и пунктуационные ошибки в тексте. Приведи к правильному регистру буквы. НЕ меняй слова, структуру предложений, стиль или смысл текста. Верни ТОЛЬКО исправленный текст без пояснений.';
|
const DEFAULT_PROMPT = defaultPrompt || process.env.AI_PROMPT || 'Ты — редактор текстов. Исправь ТОЛЬКО грамматические, орфографические и пунктуационные ошибки в тексте. Приведи к правильному регистру буквы. НЕ меняй слова, структуру предложений, стиль или смысл текста. Верни ТОЛЬКО исправленный текст без пояснений.';
|
||||||
@@ -283,10 +301,17 @@ function createEntryAutoChecker({ pool, getSetting, logAudit, aiUrl, defaultProm
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function notify() {
|
function wakeLocal() {
|
||||||
if (wake) wake();
|
if (wake) wake();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function notify() {
|
||||||
|
wakeLocal();
|
||||||
|
if (bus) bus.publish();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bus) bus.subscribe(wakeLocal);
|
||||||
|
|
||||||
async function isEnabled() {
|
async function isEnabled() {
|
||||||
const v = await getSetting('ai_autocheck_enabled', 'true');
|
const v = await getSetting('ai_autocheck_enabled', 'true');
|
||||||
return String(v) !== 'false';
|
return String(v) !== 'false';
|
||||||
@@ -414,7 +439,18 @@ function createEntryAutoChecker({ pool, getSetting, logAudit, aiUrl, defaultProm
|
|||||||
if (changed) stats.corrected++; else stats.unchanged++;
|
if (changed) stats.corrected++; else stats.unchanged++;
|
||||||
stats.last_at = new Date().toISOString();
|
stats.last_at = new Date().toISOString();
|
||||||
stats.last_error = null;
|
stats.last_error = null;
|
||||||
if (logAudit) await logAudit(null, 'entry.ai.auto-check', { id: row.id, changed });
|
if (logAudit) {
|
||||||
|
const d = textDiff(String(row.description ?? ''), changed ? checked : original);
|
||||||
|
await logAudit(null, 'entry.ai.auto-check', {
|
||||||
|
id: row.id,
|
||||||
|
source: 'ai',
|
||||||
|
changed,
|
||||||
|
fields: changed ? ['description'] : [],
|
||||||
|
changes: changed
|
||||||
|
? [{ field: 'description', label: FIELD_LABELS.description, stats: d.stats, diff: d.segments, truncated: d.truncated }]
|
||||||
|
: []
|
||||||
|
});
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const message = (e && e.message ? e.message : 'error').slice(0, 500);
|
const message = (e && e.message ? e.message : 'error').slice(0, 500);
|
||||||
|
|||||||
Reference in New Issue
Block a user