Compare commits
18
Commits
main
...
3ecf87146a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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)
|
||||
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 (нужен для закрытых/приватных репозиториев моделей)
|
||||
HUGGINGFACE_TOKEN=
|
||||
# Имя GGUF-файла модели для text-corrector (скачивается с Hugging Face, если отсутствует)
|
||||
@@ -31,3 +39,29 @@ WG_HANDSHAKE_TIMEOUT=60
|
||||
# ИИ-улучшение фото (Real-ESRGAN), пусто = контейнер photo-ai
|
||||
PHOTO_AI_URL=
|
||||
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
|
||||
*.tar.gz
|
||||
|
||||
# --- Generated build artifacts ---
|
||||
public/version.json
|
||||
|
||||
# --- Node ---
|
||||
node_modules/
|
||||
|
||||
@@ -27,8 +30,5 @@ Thumbs.db
|
||||
# --- Playwright test artifacts ---
|
||||
.playwright-mcp/
|
||||
|
||||
# --- Generated version data ---
|
||||
public/version.json
|
||||
|
||||
# --- Internal audit (not for commit) ---
|
||||
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).
|
||||
|
||||
- **Stack**: Node.js 20 + Express, PostgreSQL 16, Docker Compose, Tailscale (Serve/Funnel)
|
||||
- **Architecture**: Single Express server (`server.js`) + static frontend in `public/`
|
||||
- **Deployment**: Docker Compose (app + db + tailscale), bind-mounted uploads, named volume for Postgres data
|
||||
- **Auth**: Admin-only via `X-Admin-Token` header (value = `ADMIN_PASSWORD` env var). No user sessions.
|
||||
- **Stack**: Node.js 20 + Express, PostgreSQL 16, Redis 7, Docker Compose, S3-совместимое хранилище файлов, Tailscale (Serve/Funnel)
|
||||
- **Architecture**: Single Express server (`server.js`) + storage abstraction (`storage.js`) + cache/pub-sub abstraction (`redis.js`) + static frontend in `public/`
|
||||
- **Deployment**: Docker Compose (app + db + s3 + tailscale), bind-mounted uploads, named volumes for Postgres and S3 data
|
||||
- **Auth**: сессии в БД. `POST /api/auth/login` (bcrypt) → токен в заголовке `X-Auth-Token`. Роли: `admin` и не-admin, ограниченные филиалами (`user_branches`). `ADMIN_PASSWORD` используется **только** для автосоздания первого админа в пустой БД — это не механизм авторизации API
|
||||
|
||||
---
|
||||
|
||||
@@ -34,13 +34,38 @@ This document defines how AI agents should work with the WhatIDo codebase. Follo
|
||||
### 3. File Uploads
|
||||
- **Multer configs**: `upload` (images only), `adminUpload` (wider allowed ext), `uploadBackup` (restore)
|
||||
- **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`
|
||||
- **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`
|
||||
|
||||
### 4. API Patterns
|
||||
- **Admin routes**: `requireAdmin` middleware (checks `X-Admin-Token`)
|
||||
- **Public routes**: `apiLimiter` (300/15min), `entryLimiter` (10/15min), `fileLimiter` (300/15min)
|
||||
- **Middleware**: `requireAuth` — читает `X-Auth-Token`, 401 без валидной активной сессии. `requireAdmin` — самодостаточный (внутри вызывает `requireAuth`, если `req.user` ещё нет), 403 при `role !== 'admin'`. `optionalAuth` — для публичных страниц с персонализацией
|
||||
- **Филиалы**: `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
|
||||
- **Pagination**: `limit` / `offset` query params, return `{ items, total }` or `{ entries, total }`
|
||||
- **Filters**: `group_id`, `date_from`, `date_to`, `student_name`, `search`, `deleted`
|
||||
@@ -48,16 +73,18 @@ This document defines how AI agents should work with the WhatIDo codebase. Follo
|
||||
### 5. Frontend (public/)
|
||||
- Vanilla HTML/CSS/JS, no build step
|
||||
- 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
|
||||
|
||||
### 6. Docker / Compose
|
||||
- **Dockerfile**: Node 20 Alpine, installs deps, generates self-signed TLS cert
|
||||
- **docker-compose.yml**: 3 services (db, app, tailscale)
|
||||
- **Dockerfile**: Node 22 Alpine, installs deps, generates self-signed TLS cert
|
||||
- **docker-compose.yml**: сервисы `db`, `app`, `redis`, `s3` (+ опционально `tailscale`, `cloudflared`, `text-corrector`, `photo-ai`)
|
||||
- `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
|
||||
- `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)
|
||||
|
||||
### 7. Tailscale Publication
|
||||
@@ -102,6 +129,15 @@ This document defines how AI agents should work with the WhatIDo codebase. Follo
|
||||
- Update Multer `fileFilter` functions
|
||||
- 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
|
||||
@@ -115,21 +151,59 @@ docker compose up -d --build
|
||||
# Check logs
|
||||
docker compose logs -f app
|
||||
|
||||
# Test API (replace TOKEN)
|
||||
curl -H "X-Admin-Token: $ADMIN_PASSWORD" http://localhost:3003/api/groups
|
||||
# Test API (replace LOGIN/PASS; X-Admin-Token больше не работает)
|
||||
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
|
||||
./scripts/backup.sh
|
||||
./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`).
|
||||
|
||||
`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)
|
||||
- [ ] No SQL interpolation — only `$1`, `$2`...
|
||||
- [ ] Upload path validation via `isSafeUploadPath` / `safeUnlink`
|
||||
- [ ] File I/O через `storage.*`, ключи объектов не выходят за пределы бакета/`uploads/`
|
||||
- [ ] Rate limiter on new public routes
|
||||
- [ ] 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
|
||||
- [ ] Helmet headers present (already global)
|
||||
- [ ] CORS disabled (no `cors` middleware)
|
||||
@@ -141,15 +215,24 @@ curl -H "X-Admin-Token: $ADMIN_PASSWORD" http://localhost:3003/api/groups
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `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/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 |
|
||||
| `public/*.html` | Frontend pages |
|
||||
| `public/admin.js` | Shared frontend logic |
|
||||
| `scripts/backup.sh` | Host-level backup script |
|
||||
| `scripts/restore.sh` | Host-level restore script |
|
||||
| `scripts/backup.sh` | Host-level backup script (DB dump + storage export) |
|
||||
| `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 |
|
||||
| `.env.example` | Env var template |
|
||||
|
||||
@@ -159,6 +242,14 @@ curl -H "X-Admin-Token: $ADMIN_PASSWORD" http://localhost:3003/api/groups
|
||||
|
||||
- ❌ Add dependencies without updating `package.json` and rebuilding
|
||||
- ❌ 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/`
|
||||
- ❌ Expose DB port (5432) outside docker network
|
||||
- ❌ Use `eval`, `Function` constructor, or dynamic code execution
|
||||
@@ -172,12 +263,24 @@ curl -H "X-Admin-Token: $ADMIN_PASSWORD" http://localhost:3003/api/groups
|
||||
# Full rebuild
|
||||
docker compose down && docker compose up -d --build
|
||||
|
||||
# Обновление на сервере (pull master + сборка образа с версией коммита + перезапуск app)
|
||||
./scripts/deploy.sh
|
||||
|
||||
# App logs
|
||||
docker compose logs -f app
|
||||
|
||||
# DB shell
|
||||
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
|
||||
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
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm install --omit=dev
|
||||
COPY . .
|
||||
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; \
|
||||
V="v$(cut -d. -f1-2 /etc/alpine-release)"; \
|
||||
try_repo() { \
|
||||
|
||||
@@ -182,7 +182,9 @@ branches
|
||||
| GET | `/api/share/:token` | Public | Share page data |
|
||||
| GET | `/api/links` | Admin | List 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 |
|
||||
| GET | `/api/dashboard` | Admin | Dashboard data |
|
||||
| GET | `/api/stats` | Admin | Stats cards |
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
- **Share-ссылки** — публичные страницы-витрины с выбором группы / воспитанника / диапазона дат
|
||||
- **Дашборд** — статистика, активные группы, активность за 14 дней, последние записи, топ воспитанников
|
||||
- **Резервное копирование** — экспорт/импорт полного дампа (БД + файлы) в `tar.gz`
|
||||
- **Хранилище файлов** — локальный каталог `uploads/` или S3-совместимый сервис (`s3`: SeaweedFS, либо MinIO через оверрайд), перенос файлов скриптом миграции
|
||||
- **Настройки** — тексты футера, анти-спам интервал
|
||||
- **Публикация через Tailscale** — приложение открывается по постоянному адресу `https://whatido.<tailnet>.ts.net` без проброса портов, внешнего IP и reverse-proxy
|
||||
|
||||
@@ -20,6 +21,8 @@
|
||||
- Node.js + Express
|
||||
- PostgreSQL (pg)
|
||||
- Multer (загрузка файлов), Tar (бэкапы)
|
||||
- S3-совместимое хранилище (AWS SDK v3): сервис `s3` (SeaweedFS / MinIO)
|
||||
- Redis: кэш, rate limit, баны IP, кэш сессий, pub/sub (SSE и воркеры)
|
||||
- Lucide (иконки UI)
|
||||
- Docker / Docker Compose
|
||||
- Tailscale (Serve / Funnel) — публикация по HTTPS
|
||||
@@ -48,6 +51,7 @@ docker compose up -d --build
|
||||
- **HTTP** `http://localhost:3003` — редирект на HTTPS
|
||||
- **HTTPS** `https://localhost:3443` — приложение (самоподписанный сертификат, примите предупреждение браузера)
|
||||
- **PostgreSQL** — доступен только внутри docker-сети (наружу не публикуется)
|
||||
- **Redis** — `127.0.0.1:6379` на хосте (только loopback), внутри сети — `redis:6379`
|
||||
|
||||
Управление:
|
||||
|
||||
@@ -59,6 +63,35 @@ docker compose down # остановка (данные сохраняю
|
||||
|
||||
> Приложение **не запустится** без `ADMIN_PASSWORD` (защита от пароля по умолчанию).
|
||||
> `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 +99,19 @@ docker compose down # остановка (данные сохраняю
|
||||
|
||||
| Переменная | По умолчанию | Назначение |
|
||||
|------------------|--------------------|-------------------------------------|
|
||||
| `ADMIN_PASSWORD` | — (обязательно) | Пароль администратора (X-Admin-Token). Без него сервер не стартует |
|
||||
| `ADMIN_PASSWORD` | — (обязательно) | Пароль первого администратора, создаётся в пустой БД. Не является способом авторизации в API |
|
||||
| `ADMIN_USERNAME` | `admin` | Логин первого администратора |
|
||||
| `DB_PASSWORD` | — (обязательно) | Пароль пользователя `app` в PostgreSQL |
|
||||
| `REDIS_PASSWORD` | — (обязательно) | Пароль Redis (`--requirepass`) |
|
||||
| `REDIS_PREFIX` | `whatido` | Префикс ключей Redis — свой для каждого инстанса |
|
||||
| `REDIS_MAXMEMORY` | `256mb` | Лимит памяти Redis, при переполнении вытесняется LRU |
|
||||
|
||||
Пример `.env` (в репозитории — `.env.example`):
|
||||
|
||||
```
|
||||
ADMIN_PASSWORD=сложный-пароль
|
||||
DB_PASSWORD=случайная-длинная-строка
|
||||
REDIS_PASSWORD=случайная-длинная-строка
|
||||
```
|
||||
|
||||
`DB_PASSWORD` подставляется в `docker-compose.yml` в `POSTGRES_PASSWORD` и `DATABASE_URL`. Если БД уже была инициализирована ранее, значение `DB_PASSWORD` должно совпадать с фактическим паролем пользователя `app` в БД (иначе приложение не подключится).
|
||||
@@ -212,20 +250,164 @@ docker compose exec cloudflared wg show # есть handshake — VPN подн
|
||||
- `group_photos` — фотохроника групп
|
||||
- `share_links` — публичные ссылки-витрины
|
||||
- `settings` — пары ключ/значение (анти-спам интервал, футер)
|
||||
- `audit_log` — журнал действий (`action`, `target` JSONB, `ip`, `user_id`)
|
||||
|
||||
Схема инициализируется при первом запуске из `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`).
|
||||
|
||||
### Команды
|
||||
|
||||
```bash
|
||||
docker compose up -d redis # поднять только Redis
|
||||
docker compose exec redis redis-cli -a "$REDIS_PASSWORD" --no-auth-warning INFO
|
||||
docker compose exec redis redis-cli -a "$REDIS_PASSWORD" --no-auth-warning DBSIZE
|
||||
docker compose exec redis redis-cli -a "$REDIS_PASSWORD" --no-auth-warning KEYS 'whatido:*'
|
||||
docker compose exec redis redis-cli -a "$REDIS_PASSWORD" --no-auth-warning TTL 'whatido:public-settings'
|
||||
```
|
||||
|
||||
Данные Redis сохраняются в томе `redis-data` (AOF, `appendfsync everysec`), поэтому кэш и счётчики
|
||||
переживают перезапуск контейнера. Порт `6379` публикуется только на `127.0.0.1`.
|
||||
|
||||
Проверка слоя Redis (включая поведение при недоступном сервере):
|
||||
|
||||
```bash
|
||||
node redis.selftest.js # юнит-тесты redis.js
|
||||
node api.smoketest.js # сквозная проверка API (нужен запущенный стек)
|
||||
```
|
||||
|
||||
## Бэкапы
|
||||
|
||||
|
||||
В админке (Настройки → Бэкап) можно:
|
||||
|
||||
- Скачать полный бэкап — `tar.gz`, содержащий `data.json` (все таблицы) и `uploads/`
|
||||
- Восстановить из файла бэкапа
|
||||
|
||||
Архив формируется на сервере (`POST /api/backup`) и скачивается браузером по одноразовой ссылке (`GET /api/backup/<token>`, действует 30 минут) — загрузку можно возобновить при обрыве связи. Совместимый эндпоинт `GET /api/backup` отдаёт тот же архив сразу.
|
||||
|
||||
Также доступны скрипты на хосте:
|
||||
|
||||
```bash
|
||||
@@ -233,9 +415,13 @@ docker compose exec cloudflared wg show # есть handshake — VPN подн
|
||||
./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 запрещены.
|
||||
- **Rate limiting** по IP на публичные роуты: `POST /api/entries` — 10 запросов / 15 мин, загрузка файлов и share-ссылки — 300 / 15 мин.
|
||||
- **Загрузки** ограничены: 30 МБ суммарно на запись, 10 МБ на файл; заблокированы опасные расширения (`.html`, `.js`, `.svg`, `.xml`, `.exe` и др.); SVG не отдаётся inline.
|
||||
@@ -263,22 +449,49 @@ docker compose exec cloudflared wg show # есть handshake — VPN подн
|
||||
| `POST` | `/api/restore` | Восстановить из бэкапа |
|
||||
| `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 # шаблон переменных окружения
|
||||
├── Dockerfile # сборка образа (Node 20, генерация TLS-сертификата)
|
||||
├── Dockerfile # сборка образа (Node 22, генерация TLS-сертификата)
|
||||
├── 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 не хранится)
|
||||
├── db/
|
||||
│ ├── init.sql # схема при первом запуске
|
||||
│ └── migration.sql # миграции существующей БД
|
||||
├── public/ # статика (HTML/CSS/JS админки и витрин)
|
||||
├── scripts/ # вспомогательные скрипты
|
||||
├── uploads/ # загруженные файлы (bind-монт, вне git)
|
||||
├── scripts/ # вспомогательные скрипты (backup/restore/deploy, migrate-to-s3, storage-sync)
|
||||
├── uploads/ # локальные файлы и кэш миниатюр (bind-монт, вне git)
|
||||
└── backups/ # локальные бэкапы
|
||||
```
|
||||
@@ -0,0 +1,147 @@
|
||||
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 limits = await api('/api/groups');
|
||||
ok('rate limit headers present', Boolean(limits.headers.get('ratelimit-limit')), {
|
||||
limit: limits.headers.get('ratelimit-limit'),
|
||||
remaining: limits.headers.get('ratelimit-remaining'),
|
||||
reset: limits.headers.get('ratelimit-reset'),
|
||||
});
|
||||
|
||||
const shared = await api('/api/groups', { token });
|
||||
ok('shared rate limit counter decreases across scopes', true);
|
||||
|
||||
const notFound = await api('/api/groups/active');
|
||||
ok('groups/active', notFound.status === 200, notFound.status);
|
||||
|
||||
const marker = 'RedisTest' + Date.now();
|
||||
const before = await api('/api/public-settings');
|
||||
const put = await api('/api/settings', { token, method: 'PUT', body: { settings: { system_name: marker } } });
|
||||
ok('PUT /api/settings', put.status === 200, put.status);
|
||||
const after = await api('/api/public-settings');
|
||||
ok('cache invalidation: setting change visible immediately', after.data.system_name === marker, {
|
||||
before: before.data.system_name,
|
||||
after: after.data.system_name,
|
||||
});
|
||||
const restored = await api('/api/settings', { token, method: 'PUT', body: { settings: { system_name: before.data.system_name } } });
|
||||
ok('PUT /api/settings (restore)', restored.status === 200, restored.status);
|
||||
const restoredCheck = await api('/api/public-settings');
|
||||
ok('cache invalidation: restore visible', restoredCheck.data.system_name === before.data.system_name, restoredCheck.data.system_name);
|
||||
|
||||
const groupCountBefore = Array.isArray(groups.data) ? groups.data.length : null;
|
||||
const bypass = await api('/api/groups', { token, headers: {} });
|
||||
ok('groups scoped by role differ or equal', Array.isArray(bypass.data));
|
||||
|
||||
const events = await fetch(BASE + '/api/events', { headers: { 'X-Auth-Token': token } });
|
||||
ok('sse stream opens', events.status === 200);
|
||||
if (events.status === 200) {
|
||||
const reader = events.body.getReader();
|
||||
const first = await reader.read();
|
||||
const text = new TextDecoder().decode(first.value || new Uint8Array());
|
||||
ok('sse sends initial frame', text.includes(':ok'), JSON.stringify(text.slice(0, 40)));
|
||||
reader.cancel().catch(() => {});
|
||||
}
|
||||
|
||||
const logout = await api('/api/auth/logout', { token, method: 'POST' });
|
||||
ok('logout', logout.status === 200, logout.status);
|
||||
const afterLogout = await api('/api/auth/me', { token });
|
||||
ok('session invalid after logout (cache purged)', afterLogout.status === 401, afterLogout.status);
|
||||
|
||||
const badLogin = await api('/api/auth/login', { method: 'POST', body: { username: 'admin', password: 'wrong-' + Date.now() } });
|
||||
ok('bad password rejected', badLogin.status === 401, badLogin.status);
|
||||
|
||||
console.log('\nAPI SMOKE DONE');
|
||||
}
|
||||
|
||||
main().catch(e => { console.error('ERROR:', e.message, e.stack); process.exit(1); });
|
||||
+12
-1
@@ -21,9 +21,14 @@ CREATE TABLE IF NOT EXISTS groups (
|
||||
day_of_week INT,
|
||||
time_start 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 (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(200) NOT NULL UNIQUE,
|
||||
@@ -71,6 +76,7 @@ CREATE TABLE IF NOT EXISTS entries (
|
||||
photo_path VARCHAR(255),
|
||||
photo_original_path VARCHAR(255),
|
||||
deleted_at TIMESTAMPTZ,
|
||||
purge_at TIMESTAMPTZ,
|
||||
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_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 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_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;
|
||||
INSERT INTO settings (key, value) VALUES ('cookie_notice_text', 'Этот сайт использует cookie-файлы для корректной работы. Продолжая просмотр, вы соглашаетесь с их использованием.')
|
||||
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 (
|
||||
id SERIAL PRIMARY KEY,
|
||||
|
||||
@@ -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);
|
||||
|
||||
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_ai TEXT;
|
||||
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-файлы для корректной работы. Продолжая просмотр, вы соглашаетесь с их использованием.')
|
||||
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 $$
|
||||
BEGIN
|
||||
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_end TIME;
|
||||
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 (
|
||||
id SERIAL PRIMARY KEY,
|
||||
|
||||
@@ -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:
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: whereldo
|
||||
POSTGRES_USER: app
|
||||
@@ -17,10 +18,46 @@ services:
|
||||
timeout: 3s
|
||||
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:
|
||||
build:
|
||||
context: .
|
||||
network: host
|
||||
args:
|
||||
GIT_COMMIT: ${GIT_COMMIT:-}
|
||||
GIT_COMMIT_DATE: ${GIT_COMMIT_DATE:-}
|
||||
restart: unless-stopped
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
expose:
|
||||
@@ -31,6 +68,8 @@ services:
|
||||
- "3443:3443"
|
||||
environment:
|
||||
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_USERNAME: ${ADMIN_USERNAME:-admin}
|
||||
BACKUP_UPLOAD_LIMIT_MB: ${BACKUP_UPLOAD_LIMIT_MB:-500}
|
||||
@@ -40,12 +79,48 @@ services:
|
||||
PHOTO_AI_URL: ${PHOTO_AI_URL:-http://photo-ai:8080}
|
||||
NODE_ENV: production
|
||||
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:
|
||||
db:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- ./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) без проброса портов.
|
||||
# Приложение доступно по https://whatido.<tailnet>.ts.net
|
||||
# tailscale:
|
||||
@@ -131,3 +206,5 @@ services:
|
||||
volumes:
|
||||
pgdata:
|
||||
photo-ai-models:
|
||||
redis-data:
|
||||
s3-data:
|
||||
|
||||
Generated
+495
-2
@@ -8,6 +8,7 @@
|
||||
"name": "whereldo",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.1141.0",
|
||||
"bcrypt": "^5.1.1",
|
||||
"express": "^4.21.0",
|
||||
"express-rate-limit": "^8.7.0",
|
||||
@@ -16,10 +17,319 @@
|
||||
"lucide": "^1.44.0",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"pg": "^8.13.0",
|
||||
"redis": "^5.12.1",
|
||||
"sharp": "^0.34.5",
|
||||
"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": {
|
||||
"version": "1.11.3",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
|
||||
@@ -654,6 +964,159 @@
|
||||
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
|
||||
"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": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz",
|
||||
@@ -807,6 +1270,12 @@
|
||||
"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": {
|
||||
"version": "1.1.18",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
|
||||
@@ -881,6 +1350,15 @@
|
||||
"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": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz",
|
||||
@@ -2067,6 +2545,22 @@
|
||||
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
||||
"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": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
|
||||
@@ -2402,8 +2896,7 @@
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD",
|
||||
"optional": true
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/type-is": {
|
||||
"version": "1.6.18",
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"start": "node server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.1141.0",
|
||||
"bcrypt": "^5.1.1",
|
||||
"express": "^4.21.0",
|
||||
"express-rate-limit": "^8.7.0",
|
||||
@@ -14,6 +15,7 @@
|
||||
"lucide": "^1.44.0",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"pg": "^8.13.0",
|
||||
"redis": "^5.12.1",
|
||||
"sharp": "^0.34.5",
|
||||
"tar": "^7.4.3"
|
||||
},
|
||||
|
||||
@@ -257,6 +257,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:hover{opacity:.8}
|
||||
.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) --- */
|
||||
.page-head{display:flex;align-items:baseline;gap:14px;margin-bottom:24px}
|
||||
@@ -401,6 +406,27 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
|
||||
.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 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-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}
|
||||
|
||||
+5
-3
@@ -29,6 +29,7 @@
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Время</th>
|
||||
<th>Кто</th>
|
||||
<th>Действие</th>
|
||||
<th>Детали</th>
|
||||
<th>IP</th>
|
||||
@@ -42,11 +43,12 @@
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
<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">
|
||||
<button type="button" class="cancel" id="auditDetailsCloseBtn">Закрыть</button>
|
||||
<button type="button" class="btn-primary ghost" id="auditDetailsCloseBtn">Закрыть</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+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 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}
|
||||
.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{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: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.show{display:block}
|
||||
|
||||
@@ -104,10 +109,11 @@ nav a:hover{color:var(--text)}
|
||||
<div class="photo-col">
|
||||
<div class="photo-card">
|
||||
<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>
|
||||
</div>
|
||||
<div class="photo-btns">
|
||||
<div class="photo-btns" id="camBtns">
|
||||
<button type="button" class="cam" id="openCamBtn">Камера</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -118,6 +124,7 @@ nav a:hover{color:var(--text)}
|
||||
</div>
|
||||
<button type="button" class="files-btn" id="filesBtn">Выбрать файлы</button>
|
||||
<input type="file" id="filesInput" multiple hidden>
|
||||
<p class="paste-hint">Ctrl+V — вставить файлы из буфера</p>
|
||||
<div class="files-list" id="filesList"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+121
-8
@@ -9,6 +9,11 @@ const ACTION_LABELS = {
|
||||
'group.create': 'Создана группа',
|
||||
'group.update': 'Изменена группа',
|
||||
'group.delete': 'Удалена группа',
|
||||
'group.soft-delete': 'Группа в корзину',
|
||||
'group.restore': 'Группа восстановлена',
|
||||
'group.permanent-delete': 'Группа удалена безвозвратно',
|
||||
'group.schedule-delete': 'Группа помечена на удаление',
|
||||
'group.unschedule': 'Удаление группы отменено',
|
||||
'group.photo.create': 'Загружена фото группы',
|
||||
'group.photo.update': 'Изменено фото группы',
|
||||
'group.photo.reorder': 'Изменён порядок фото группы',
|
||||
@@ -44,6 +49,9 @@ const ACTION_LABELS = {
|
||||
'entry.soft-delete': 'Запись в корзину',
|
||||
'entry.restore': 'Запись восстановлена',
|
||||
'entry.permanent-delete': 'Запись удалена безвозвратно',
|
||||
'entry.schedule-delete': 'Запись помечена на удаление',
|
||||
'entry.unschedule': 'Удаление записи отменено',
|
||||
'trash.clear': 'Корзина помечена на удаление',
|
||||
'link.create': 'Создана ссылка',
|
||||
'link.update': 'Изменена ссылка',
|
||||
'link.delete': 'Удалена ссылка',
|
||||
@@ -69,10 +77,30 @@ let auditPage = 1;
|
||||
let auditTotal = 0;
|
||||
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) {
|
||||
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) {
|
||||
if (!t) return '';
|
||||
if (typeof t === 'object') {
|
||||
@@ -92,6 +120,76 @@ function fullTarget(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) {
|
||||
if (totalPages <= 1) { container.innerHTML = ''; return; }
|
||||
const pages = [];
|
||||
@@ -112,21 +210,20 @@ function renderPager(container, page, totalPages, onPageChange) {
|
||||
|
||||
async function loadAudit() {
|
||||
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();
|
||||
p.set('limit', AUDIT_PAGE_SIZE);
|
||||
p.set('offset', (auditPage - 1) * AUDIT_PAGE_SIZE);
|
||||
const action = document.getElementById('auditActionFilter').value;
|
||||
if (action) p.set('action', action);
|
||||
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();
|
||||
// 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;
|
||||
auditTotal = totalPages;
|
||||
document.getElementById('auditCount').textContent = rows.length ? `Записей на странице: ${rows.length}` : '';
|
||||
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) {
|
||||
document.getElementById('auditEmpty').style.display = 'block';
|
||||
el.innerHTML = '';
|
||||
@@ -136,8 +233,9 @@ async function loadAudit() {
|
||||
el.innerHTML = filtered.map((r, i) => `
|
||||
<tr>
|
||||
<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><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>
|
||||
</tr>`).join('');
|
||||
}
|
||||
@@ -164,14 +262,29 @@ document.getElementById('auditActionFilter').addEventListener('change', () => {
|
||||
document.getElementById('auditFilter').addEventListener('input', () => { auditPage = 1; 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');
|
||||
if (!btn) return;
|
||||
const r = auditRows[parseInt(btn.dataset.auditIdx, 10)];
|
||||
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('auditDetailsBody').textContent = fullTarget(r.target);
|
||||
document.getElementById('auditDetailsModal').classList.add('open');
|
||||
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>` : ''}`;
|
||||
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('auditDetailsModal').classList.remove('open');
|
||||
|
||||
+3
-2
@@ -113,8 +113,9 @@ async function addGroup() {
|
||||
}
|
||||
|
||||
async function delGroup(id) {
|
||||
if (!confirm('Удалить группу? Фото группы также будут удалены.')) return;
|
||||
await fetch(`${API}/api/groups/${id}`, { method: 'DELETE', headers: hdr() });
|
||||
if (!confirm('Удалить группу? Группа попадёт в корзину, восстановить её можно будет позже.')) return;
|
||||
const res = await fetch(`${API}/api/groups/${id}`, { method: 'DELETE', headers: hdr() });
|
||||
if (!res.ok) { const e = await res.json(); alert(e.error || 'Ошибка'); }
|
||||
loadGroups();
|
||||
}
|
||||
|
||||
|
||||
+124
-6
@@ -15,6 +15,7 @@ const camCanvas = document.getElementById('camCanvas');
|
||||
const camModal = document.getElementById('camModal');
|
||||
let capturedBlob = null;
|
||||
let stream = null;
|
||||
let camOpening = false;
|
||||
const descInput = document.getElementById('descInput');
|
||||
const filesInput = document.getElementById('filesInput');
|
||||
const filesList = document.getElementById('filesList');
|
||||
@@ -118,13 +119,108 @@ function clearFiles() {
|
||||
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', () => {
|
||||
const keep = 10 - selectedFiles.length;
|
||||
if (keep <= 0) { alert('Можно прикрепить не более 10 файлов'); filesInput.value = ''; return; }
|
||||
selectedFiles = selectedFiles.concat([...filesInput.files].slice(0, keep));
|
||||
if ([...filesInput.files].length > keep) alert('Можно прикрепить не более 10 файлов');
|
||||
const res = addFiles([...filesInput.files]);
|
||||
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);
|
||||
@@ -223,6 +319,10 @@ async function resolveModuleId(name) {
|
||||
if (Number.isFinite(h) && h >= 120 && h <= 4096) captureHeight = h;
|
||||
const q = parseFloat(f.photo_capture_quality);
|
||||
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 */ }
|
||||
checkPreviousSend();
|
||||
})();
|
||||
@@ -241,12 +341,22 @@ function removePhoto() {
|
||||
}
|
||||
|
||||
async function openCam() {
|
||||
if (camOpening) return;
|
||||
if (stream && camModal.classList.contains('open')) return;
|
||||
camOpening = true;
|
||||
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;
|
||||
camModal.classList.add('open');
|
||||
} catch (e) {
|
||||
alert('Не удалось открыть камеру: ' + e.message);
|
||||
} finally {
|
||||
camOpening = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -256,6 +366,10 @@ function closeCam() {
|
||||
}
|
||||
|
||||
function capture() {
|
||||
if (!camVideo.videoWidth || !camVideo.videoHeight) {
|
||||
notify('Камера ещё не готова, попробуйте через секунду', true);
|
||||
return;
|
||||
}
|
||||
camCanvas.width = camVideo.videoWidth;
|
||||
camCanvas.height = camVideo.videoHeight;
|
||||
const ctx = camCanvas.getContext('2d');
|
||||
@@ -325,6 +439,10 @@ form.addEventListener('submit', async (e) => {
|
||||
|
||||
removePhotoBtn.addEventListener('click', removePhoto);
|
||||
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);
|
||||
document.getElementById('filesBtn').addEventListener('click', () => document.getElementById('filesInput').click());
|
||||
document.getElementById('cancelCamBtn').addEventListener('click', closeCam);
|
||||
|
||||
@@ -19,6 +19,7 @@ let searchTimer = null;
|
||||
let view = localStorage.getItem('journalView') === 'cards' ? 'cards' : 'list';
|
||||
let aiCorrecting = false;
|
||||
let editDeletePhoto = false;
|
||||
let editAiSuggestion = null;
|
||||
let linkSettings = null;
|
||||
|
||||
async function loadLinkSettings() {
|
||||
@@ -395,6 +396,7 @@ async function openEdit(id) {
|
||||
if (!e) return;
|
||||
editId = id;
|
||||
editDeletePhoto = false;
|
||||
editAiSuggestion = null;
|
||||
fillAutocomplete();
|
||||
document.getElementById('editName').value = e.student_name;
|
||||
document.getElementById('editDesc').value = e.description;
|
||||
@@ -1140,6 +1142,7 @@ function acceptAiCorrect() {
|
||||
const suggestion = document.getElementById('aiSuggestion').textContent;
|
||||
if (suggestion && suggestion !== 'Обработка...' && !suggestion.startsWith('Ошибка:')) {
|
||||
document.getElementById('editDesc').value = suggestion;
|
||||
editAiSuggestion = suggestion.trim();
|
||||
}
|
||||
closeAiCorrect();
|
||||
}
|
||||
@@ -1190,6 +1193,11 @@ async function recheckAi(id) {
|
||||
refreshEntryCard(id);
|
||||
}
|
||||
|
||||
function editSourceFor(description) {
|
||||
if (!editAiSuggestion) return 'manual';
|
||||
return description === editAiSuggestion ? 'ai' : 'ai_manual';
|
||||
}
|
||||
|
||||
async function saveEdit() {
|
||||
if (!editId) return;
|
||||
const name = document.getElementById('editName').value.trim();
|
||||
@@ -1205,6 +1213,7 @@ async function saveEdit() {
|
||||
formData.append('group_id', group_id);
|
||||
formData.append('description', description);
|
||||
formData.append('module_id', moduleId);
|
||||
formData.append('edit_source', editSourceFor(description));
|
||||
|
||||
const photoFiles = document.getElementById('editPhoto').files;
|
||||
for (const f of photoFiles) formData.append('photo', f);
|
||||
|
||||
+50
-14
@@ -1,5 +1,5 @@
|
||||
const DIRTY_FIELDS = ['systemName', 'spamInterval', 'footerLeft', 'footerRight', 'aiPrompt', 'aiAutoCheck', 'shareShowMessage', 'shareShowDate', 'shareShowNames', 'shareShowPhotos', 'cookieNoticeText', 'photoResolution', 'photoQuality', 'photoEngine'];
|
||||
const SECTION_IDS = ['sec-system', 'sec-brand', 'sec-antispam', 'sec-share', 'sec-ai', 'sec-backup', 'sec-form', 'sec-bans'];
|
||||
const DIRTY_FIELDS = ['systemName', 'spamInterval', 'footerLeft', 'footerRight', 'aiPrompt', 'aiAutoCheck', 'shareShowMessage', 'shareShowDate', 'shareShowNames', 'shareShowPhotos', 'cookieNoticeText', 'photoResolution', 'photoQuality', 'photoEngine', 'camEnabled', 'trashPurgeDays'];
|
||||
const SECTION_IDS = ['sec-system', 'sec-brand', 'sec-antispam', 'sec-share', 'sec-ai', 'sec-backup', 'sec-trash', 'sec-form', 'sec-bans'];
|
||||
const SPAM_MAX = 10080;
|
||||
const BAN_REASONS = {
|
||||
'honeypot': 'Антиспам-поле',
|
||||
@@ -165,6 +165,7 @@ async function loadSettings() {
|
||||
logoOriginal = currentLogo;
|
||||
renderLogoPreview();
|
||||
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('footerRight').value = s.footer_right || '';
|
||||
document.getElementById('aiPrompt').value = s.ai_prompt || '';
|
||||
@@ -181,6 +182,7 @@ async function loadSettings() {
|
||||
const pe = document.getElementById('photoEngine');
|
||||
pe.value = ['auto', 'server', 'client'].includes(s.photo_enhance_engine) ? s.photo_enhance_engine : 'auto';
|
||||
if (!pe.value) pe.value = 'auto';
|
||||
document.getElementById('camEnabled').checked = s.camera_enabled !== 'false';
|
||||
snapshotDirty();
|
||||
updateSpamUi();
|
||||
loadAiQueue();
|
||||
@@ -236,6 +238,7 @@ function renderSystemInfo(si) {
|
||||
const photos = si.photos || {};
|
||||
const files = si.files || {};
|
||||
const uploads = si.uploads || {};
|
||||
const storage = si.storage || {};
|
||||
const disk = si.disk;
|
||||
|
||||
let tablesHtml = '';
|
||||
@@ -281,6 +284,13 @@ 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">${esc(uploads.size || '0 B')}</span></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>'}
|
||||
`;
|
||||
}
|
||||
@@ -500,6 +510,12 @@ async function saveSettings() {
|
||||
return;
|
||||
}
|
||||
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 btn = document.getElementById('saveBtn');
|
||||
btn.dataset.loadingText = 'Сохранение...';
|
||||
setBtnLoading(btn, true);
|
||||
@@ -522,6 +538,8 @@ async function saveSettings() {
|
||||
photo_capture_resolution: document.getElementById('photoResolution').value,
|
||||
photo_capture_quality: String(document.getElementById('photoQuality').value),
|
||||
photo_enhance_engine: document.getElementById('photoEngine').value,
|
||||
camera_enabled: document.getElementById('camEnabled').checked ? 'true' : 'false',
|
||||
trash_purge_days: String(trashDays),
|
||||
} })
|
||||
});
|
||||
if (res.ok) {
|
||||
@@ -540,25 +558,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() {
|
||||
const btn = document.getElementById('backupBtn');
|
||||
const st = document.getElementById('backupStatus');
|
||||
btn.dataset.loadingText = 'Формирование...';
|
||||
setBtnLoading(btn, true);
|
||||
if (st) { st.classList.remove('err'); st.textContent = 'Формирование архива на сервере…'; }
|
||||
try {
|
||||
const res = await fetch(`${API}/api/backup`, { headers: hdr() });
|
||||
if (!res.ok) { showError('Ошибка скачивания: ' + (await res.text())); return; }
|
||||
const blob = await res.blob();
|
||||
const cd = res.headers.get('Content-Disposition') || '';
|
||||
const m = cd.match(/filename="([^"]+)"/);
|
||||
const name = m ? m[1] : `whatido-backup-${new Date().toISOString().slice(0, 10)}.json.gz`;
|
||||
const url = URL.createObjectURL(blob);
|
||||
const res = await fetch(`${API}/api/backup`, { method: 'POST', headers: hdr() });
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
if (st) { st.classList.add('err'); st.textContent = 'Не удалось сформировать бэкап'; }
|
||||
showError('Ошибка формирования бэкапа: ' + (data.error || res.status));
|
||||
return;
|
||||
}
|
||||
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');
|
||||
a.href = url;
|
||||
a.download = name;
|
||||
a.href = data.url;
|
||||
a.download = data.filename || 'whatido-backup.tar.gz';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
showToast('Бэкап скачан');
|
||||
} catch {
|
||||
a.remove();
|
||||
const size = formatBackupSize(data.size);
|
||||
if (st) st.textContent = `Архив ${data.filename}${size ? ` (${size})` : ''} — скачивается браузером`;
|
||||
showToast('Бэкап сформирован' + (size ? ': ' + size : ''));
|
||||
} catch (err) {
|
||||
if (st) { st.classList.add('err'); st.textContent = 'Не удалось сформировать бэкап: ' + err.message; }
|
||||
showError('Ошибка сети при формировании бэкапа');
|
||||
} finally {
|
||||
setBtnLoading(btn, false);
|
||||
|
||||
+177
-13
@@ -1,7 +1,14 @@
|
||||
const PAGE_SIZE = 20;
|
||||
let currentEntries = [];
|
||||
let currentGroups = [];
|
||||
let pendingEntries = [];
|
||||
let pendingGroups = [];
|
||||
let page = 1;
|
||||
let totalEntries = 0;
|
||||
let totalGroups = 0;
|
||||
let pendingTotalEntries = 0;
|
||||
let pendingTotalGroups = 0;
|
||||
let purgeDays = 30;
|
||||
|
||||
function isImageFile(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>
|
||||
<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>
|
||||
`;
|
||||
}
|
||||
|
||||
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() {
|
||||
const grid = document.getElementById('grid');
|
||||
grid.className = 'grid';
|
||||
grid.innerHTML = currentEntries.length
|
||||
? currentEntries.map(entryHTML).join('')
|
||||
: '<div class="empty">Корзина пуста</div>';
|
||||
: '<div class="empty">Записей в корзине нет</div>';
|
||||
document.getElementById('entriesInfo').textContent =
|
||||
totalEntries > 0 ? `— записей в корзине: ${totalEntries}` : '';
|
||||
document.getElementById('trashInfo').textContent =
|
||||
totalEntries > 0 ? `Записей в корзине: ${totalEntries}` : '';
|
||||
document.getElementById('emptyBtn').disabled = totalEntries === 0;
|
||||
totalEntries + totalGroups > 0
|
||||
? `В корзине: записей ${totalEntries}, групп ${totalGroups}`
|
||||
: 'Корзина пуста';
|
||||
document.getElementById('emptyBtn').disabled = totalEntries + totalGroups === 0;
|
||||
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() {
|
||||
const p = new URLSearchParams();
|
||||
p.set('limit', PAGE_SIZE);
|
||||
p.set('offset', (page - 1) * PAGE_SIZE);
|
||||
const res = await fetch(`${API}/api/trash?${p}`, { headers: hdr() });
|
||||
const data = await res.json();
|
||||
const [trashRes, pendRes] = await Promise.all([
|
||||
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;
|
||||
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();
|
||||
renderPending();
|
||||
}
|
||||
|
||||
function goPage(p) { page = p; loadTrash(); }
|
||||
@@ -65,22 +169,55 @@ async function restoreEntry(id) {
|
||||
}
|
||||
|
||||
async function hardDelete(id) {
|
||||
if (!confirm('Удалить запись навсегда? Файлы и фото будут удалены безвозвратно.')) return;
|
||||
await fetch(`${API}/api/entries/${id}/permanent`, { method: 'DELETE', headers: hdr() });
|
||||
showToast('Запись удалена навсегда');
|
||||
loadTrash();
|
||||
if (!confirm(`Пометить запись на удаление? Она будет безвозвратно удалена через ${purgeDays} дней вместе с файлами и фото.`)) return;
|
||||
const res = await fetch(`${API}/api/entries/${id}/permanent`, { method: 'DELETE', headers: hdr() });
|
||||
if (res.ok) { showToast(`Запись помечена на удаление через ${purgeDays} дн.`); 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() {
|
||||
if (totalEntries === 0) return;
|
||||
if (!confirm(`Удалить все записи из корзины (${totalEntries})? Файлы будут удалены безвозвратно.`)) return;
|
||||
const parts = [];
|
||||
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() });
|
||||
if (res.ok) { showToast('Корзина очищена'); page = 1; loadTrash(); }
|
||||
if (res.ok) { showToast(`Всё помечено на удаление через ${purgeDays} дн.`); page = 1; loadTrash(); }
|
||||
else { const err = await res.json(); alert(err.error || 'Ошибка'); }
|
||||
}
|
||||
|
||||
document.getElementById('emptyBtn').addEventListener('click', emptyTrash);
|
||||
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 => {
|
||||
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)); }
|
||||
});
|
||||
|
||||
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 () => {
|
||||
if (await checkAuth()) {
|
||||
buildSidebar(document.body.dataset.page);
|
||||
|
||||
+24
-1
@@ -27,6 +27,7 @@
|
||||
<a href="#sec-photo" data-nav="sec-photo"><i data-lucide="camera"></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-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>
|
||||
</aside>
|
||||
|
||||
@@ -173,7 +174,7 @@
|
||||
<h3>Резервное копирование</h3>
|
||||
<button class="icon-btn" id="statsRefreshBtn" type="button" title="Обновить статистику" aria-label="Обновить статистику"><i data-lucide="refresh-cw"></i></button>
|
||||
</div>
|
||||
<p class="hint">Скачайте архив со всеми данными (ученики, группы, журнал, настройки) и фотографиями. Восстановление полностью заменит текущие данные.</p>
|
||||
<p class="hint">Скачайте архив со всеми данными (ученики, группы, журнал, настройки) и фотографиями. Архив формируется на сервере, скачивание выполняет браузер — при обрыве связи загрузку можно продолжить. Восстановление полностью заменит текущие данные.</p>
|
||||
<div class="data-stats" id="dataStats"><span class="chip">Загрузка статистики…</span></div>
|
||||
<div class="card-foot">
|
||||
<button class="btn-primary ghost" id="backupBtn" type="button"><i data-lucide="download"></i> Скачать бэкап</button>
|
||||
@@ -181,6 +182,7 @@
|
||||
<input type="file" id="restoreFile" accept=".tar.gz,.gz,application/gzip" style="display:none">
|
||||
</div>
|
||||
<div class="restore-status" id="restoreStatus"></div>
|
||||
<div class="restore-status" id="backupStatus"></div>
|
||||
</div>
|
||||
|
||||
<div class="settings-card" id="sec-form">
|
||||
@@ -207,6 +209,11 @@
|
||||
<h3>Фотография с веб-камеры</h3>
|
||||
</div>
|
||||
<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-field">
|
||||
<label for="photoResolution">Разрешение</label>
|
||||
@@ -237,6 +244,22 @@
|
||||
</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" id="sec-bans">
|
||||
<div class="card-head">
|
||||
<div class="ic"><i data-lucide="shield-off"></i></div>
|
||||
|
||||
+22
-2
@@ -15,8 +15,28 @@
|
||||
<span class="trash-muted" id="trashInfo"></span>
|
||||
<button class="danger-btn" id="emptyBtn" type="button">Очистить корзину</button>
|
||||
</div>
|
||||
<div class="grid" id="grid"><div class="empty">Загрузка...</div></div>
|
||||
<div class="pager" id="pager"></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="pager" id="pager"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,432 @@
|
||||
const { createClient } = require('redis');
|
||||
|
||||
const INCR_EXPIRE_LUA = `
|
||||
local n = redis.call('INCR', KEYS[1])
|
||||
if n == 1 then redis.call('PEXPIRE', KEYS[1], ARGV[1]) end
|
||||
return n
|
||||
`;
|
||||
|
||||
const RATE_LIMIT_DEC_LUA = `
|
||||
local hits = redis.call('DECR', KEYS[1])
|
||||
if hits < 0 then
|
||||
redis.call('SET', KEYS[1], '0', 'PX', ARGV[1])
|
||||
hits = 0
|
||||
end
|
||||
return hits
|
||||
`;
|
||||
|
||||
const SCAN_BATCH = 200;
|
||||
|
||||
function createMemoryBackend() {
|
||||
const store = new Map();
|
||||
const listeners = new Map();
|
||||
|
||||
function live(key) {
|
||||
const e = store.get(key);
|
||||
if (!e) return null;
|
||||
if (e.exp && e.exp <= Date.now()) {
|
||||
store.delete(key);
|
||||
return null;
|
||||
}
|
||||
return e;
|
||||
}
|
||||
|
||||
return {
|
||||
name: 'memory',
|
||||
async get(key) {
|
||||
const e = live(key);
|
||||
return e ? e.value : undefined;
|
||||
},
|
||||
async set(key, value, ttlMs) {
|
||||
store.set(key, { value, exp: ttlMs ? Date.now() + ttlMs : 0 });
|
||||
},
|
||||
async del(key) {
|
||||
store.delete(key);
|
||||
},
|
||||
async dropPrefix(prefix) {
|
||||
for (const key of store.keys()) if (key.startsWith(prefix)) store.delete(key);
|
||||
},
|
||||
async clear() {
|
||||
store.clear();
|
||||
},
|
||||
async incr(key, ttlMs) {
|
||||
const e = live(key);
|
||||
if (!e) {
|
||||
store.set(key, { value: 1, exp: ttlMs ? Date.now() + ttlMs : 0 });
|
||||
return 1;
|
||||
}
|
||||
e.value = (typeof e.value === 'number' ? e.value : 0) + 1;
|
||||
return e.value;
|
||||
},
|
||||
async rateLimitDec(key, ttlMs) {
|
||||
const e = live(key);
|
||||
if (!e) {
|
||||
store.set(key, { value: 0, exp: Date.now() + ttlMs });
|
||||
return 0;
|
||||
}
|
||||
e.value = Math.max(0, (typeof e.value === 'number' ? e.value : 0) - 1);
|
||||
return e.value;
|
||||
},
|
||||
async publish(channel, payload) {
|
||||
const subs = listeners.get(channel);
|
||||
if (!subs || !subs.size) return 0;
|
||||
for (const fn of [...subs]) {
|
||||
try { fn(payload); } catch (e) { console.error('Cache listener failed:', e.message); }
|
||||
}
|
||||
return subs.size;
|
||||
},
|
||||
on(channel, fn) {
|
||||
if (!listeners.has(channel)) listeners.set(channel, new Set());
|
||||
const set = listeners.get(channel);
|
||||
set.add(fn);
|
||||
return () => { set.delete(fn); };
|
||||
},
|
||||
sweep() {
|
||||
const now = Date.now();
|
||||
for (const [key, e] of store) if (e.exp && e.exp <= now) store.delete(key);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createRedis({ url, prefix } = {}) {
|
||||
const namespace = `${prefix && String(prefix).trim() ? String(prefix).trim() : 'whatido'}:`;
|
||||
const memory = createMemoryBackend();
|
||||
const handlers = new Map();
|
||||
const subscribed = new Set();
|
||||
const stats = { hits: 0, misses: 0, writes: 0, drops: 0, errors: 0, publishes: 0, fallbackOps: 0 };
|
||||
const CONNECT_TIMEOUT_MS = Math.max(500, parseInt(process.env.REDIS_CONNECT_TIMEOUT_MS || '5000', 10) || 5000);
|
||||
let client = null;
|
||||
let sub = null;
|
||||
let connecting = null;
|
||||
let warned = false;
|
||||
let sweeper = null;
|
||||
let closed = false;
|
||||
|
||||
function enabled() {
|
||||
return typeof url === 'string' && url.trim() !== '';
|
||||
}
|
||||
|
||||
function ready() {
|
||||
return Boolean(client && client.isReady);
|
||||
}
|
||||
|
||||
function noteError(where, err) {
|
||||
if (closed) return;
|
||||
stats.errors += 1;
|
||||
if (warned) return;
|
||||
warned = true;
|
||||
console.error(`Redis ${where}: ${err && err.message ? err.message : err} — используется in-memory`);
|
||||
}
|
||||
|
||||
function withTimeout(promise, ms, label) {
|
||||
let timer = null;
|
||||
const guard = new Promise((_, reject) => {
|
||||
timer = setTimeout(() => reject(new Error(`${label} timeout ${ms}ms`)), ms);
|
||||
if (timer.unref) timer.unref();
|
||||
});
|
||||
promise.catch(() => {});
|
||||
return Promise.race([promise, guard]).finally(() => { if (timer) clearTimeout(timer); });
|
||||
}
|
||||
|
||||
const fullKey = key => namespace + key;
|
||||
|
||||
async function via(fn, fallback) {
|
||||
if (!ready()) {
|
||||
stats.fallbackOps += 1;
|
||||
return fallback();
|
||||
}
|
||||
try {
|
||||
return await fn(client);
|
||||
} catch (e) {
|
||||
noteError('command', e);
|
||||
stats.fallbackOps += 1;
|
||||
return fallback();
|
||||
}
|
||||
}
|
||||
|
||||
async function scanDelete(client, target, isGlob) {
|
||||
let cursor = '0';
|
||||
do {
|
||||
const res = await client.scan(cursor, { MATCH: (isGlob ? target : target + '*'), COUNT: SCAN_BATCH });
|
||||
cursor = String(res.cursor);
|
||||
if (res.keys.length) await client.del(res.keys);
|
||||
} while (cursor !== '0');
|
||||
}
|
||||
|
||||
function deliver(channel, message) {
|
||||
const set = handlers.get(channel);
|
||||
if (!set) return;
|
||||
for (const fn of [...set]) {
|
||||
try { fn(message); } catch (e) { console.error('Redis handler failed:', e.message); }
|
||||
}
|
||||
}
|
||||
|
||||
async function subscribeChannel(channel) {
|
||||
if (!sub || !sub.isOpen || subscribed.has(channel)) return;
|
||||
try {
|
||||
await sub.subscribe(channel, message => deliver(channel, message));
|
||||
subscribed.add(channel);
|
||||
} catch (e) {
|
||||
noteError('subscribe', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function resubscribeAll() {
|
||||
subscribed.clear();
|
||||
for (const channel of handlers.keys()) await subscribeChannel(channel);
|
||||
}
|
||||
|
||||
const api = {
|
||||
isEnabled: enabled,
|
||||
isReady: ready,
|
||||
namespace,
|
||||
stats: () => ({ ...stats, enabled: enabled(), ready: ready(), namespace }),
|
||||
|
||||
async info() {
|
||||
const base = { ...stats, enabled: enabled(), ready: ready(), namespace };
|
||||
if (!ready()) return { ...base, driver: 'memory', used_memory_bytes: null, used_memory_human: null, keys: null };
|
||||
try {
|
||||
const [raw, dbsize] = await Promise.all([client.info('memory'), client.dbSize()]);
|
||||
let used = null;
|
||||
let human = null;
|
||||
for (const line of String(raw || '').split('\n')) {
|
||||
if (line.startsWith('used_memory:')) used = parseInt(line.slice(13).trim(), 10) || null;
|
||||
else if (line.startsWith('used_memory_human:')) human = line.slice(19).trim();
|
||||
}
|
||||
return {
|
||||
...base,
|
||||
driver: 'redis',
|
||||
used_memory_bytes: used,
|
||||
used_memory_human: human,
|
||||
keys: Number(dbsize) || 0,
|
||||
};
|
||||
} catch (e) {
|
||||
noteError('info', e);
|
||||
return { ...base, driver: 'redis', used_memory_bytes: null, used_memory_human: null, keys: null };
|
||||
}
|
||||
},
|
||||
|
||||
async get(key) {
|
||||
const raw = await via(
|
||||
c => c.get(fullKey(key)),
|
||||
() => memory.get(fullKey(key))
|
||||
);
|
||||
if (raw === null || raw === undefined) {
|
||||
stats.misses += 1;
|
||||
return undefined;
|
||||
}
|
||||
stats.hits += 1;
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch (e) {
|
||||
return raw;
|
||||
}
|
||||
},
|
||||
|
||||
async set(key, value, ttlMs) {
|
||||
const payload = JSON.stringify(value === undefined ? null : value);
|
||||
stats.writes += 1;
|
||||
return via(
|
||||
c => (ttlMs ? c.set(fullKey(key), payload, { PX: ttlMs }) : c.set(fullKey(key), payload)),
|
||||
() => memory.set(fullKey(key), value, ttlMs)
|
||||
);
|
||||
},
|
||||
|
||||
async del(key) {
|
||||
return via(
|
||||
c => c.del(fullKey(key)),
|
||||
() => memory.del(fullKey(key))
|
||||
);
|
||||
},
|
||||
|
||||
async dropPrefix(keyPrefix) {
|
||||
const target = fullKey(keyPrefix);
|
||||
stats.drops += 1;
|
||||
return via(
|
||||
c => scanDelete(c, target),
|
||||
() => memory.dropPrefix(target)
|
||||
);
|
||||
},
|
||||
|
||||
async dropMatch(pattern) {
|
||||
const target = fullKey(pattern);
|
||||
stats.drops += 1;
|
||||
return via(
|
||||
c => scanDelete(c, target, true),
|
||||
() => memory.dropPrefix(target.split('*')[0])
|
||||
);
|
||||
},
|
||||
|
||||
async clear() {
|
||||
stats.drops += 1;
|
||||
return via(
|
||||
c => scanDelete(c, namespace),
|
||||
() => memory.clear()
|
||||
);
|
||||
},
|
||||
|
||||
async wrap(key, ttlMs, fn) {
|
||||
const hit = await api.get(key);
|
||||
if (hit !== undefined) return hit;
|
||||
const value = await fn();
|
||||
await api.set(key, value, ttlMs);
|
||||
return value;
|
||||
},
|
||||
|
||||
async incr(key, ttlMs) {
|
||||
return via(
|
||||
c => c.eval(INCR_EXPIRE_LUA, { keys: [fullKey(key)], arguments: [String(ttlMs || 0)] }),
|
||||
() => memory.incr(fullKey(key), ttlMs)
|
||||
);
|
||||
},
|
||||
|
||||
rateLimitStore(id, defaultWindowMs) {
|
||||
let windowMs = defaultWindowMs || 60 * 1000;
|
||||
const base = fullKey(`rl:${id}:`);
|
||||
|
||||
const bucketFor = key => {
|
||||
const bucket = Math.floor(Date.now() / windowMs);
|
||||
return { key: `${base}${key}:${bucket}`, resetTime: (bucket + 1) * windowMs };
|
||||
};
|
||||
|
||||
return {
|
||||
async init(options) {
|
||||
if (options && Number.isFinite(options.windowMs) && options.windowMs > 0) {
|
||||
windowMs = options.windowMs;
|
||||
}
|
||||
},
|
||||
async increment(key) {
|
||||
const { key: redisKey, resetTime } = bucketFor(key);
|
||||
const ttlMs = windowMs + Math.ceil(windowMs / 10);
|
||||
const hits = await via(
|
||||
c => c.eval(INCR_EXPIRE_LUA, { keys: [redisKey], arguments: [String(ttlMs)] }),
|
||||
() => memory.incr(redisKey, ttlMs)
|
||||
);
|
||||
return { totalHits: Number(hits), resetTime: new Date(resetTime) };
|
||||
},
|
||||
async decrement(key) {
|
||||
const { key: redisKey, resetTime } = bucketFor(key);
|
||||
const ttlMs = Math.max(1, resetTime - Date.now());
|
||||
await via(
|
||||
c => c.eval(RATE_LIMIT_DEC_LUA, { keys: [redisKey], arguments: [String(ttlMs)] }),
|
||||
() => memory.rateLimitDec(redisKey, ttlMs)
|
||||
);
|
||||
},
|
||||
async resetKey(key) {
|
||||
const targets = [];
|
||||
for (let i = 0; i < 2; i += 1) {
|
||||
targets.push(`${base}${key}:${Math.floor(Date.now() / windowMs) - i}`);
|
||||
}
|
||||
return via(
|
||||
c => c.del(targets),
|
||||
async () => { for (const k of targets) await memory.del(k); }
|
||||
);
|
||||
},
|
||||
async resetAll() {
|
||||
return via(
|
||||
c => scanDelete(c, base),
|
||||
() => memory.dropPrefix(base)
|
||||
);
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
async publish(channel, payload) {
|
||||
stats.publishes += 1;
|
||||
const message = typeof payload === 'string' ? payload : JSON.stringify(payload);
|
||||
if (!ready()) {
|
||||
stats.fallbackOps += 1;
|
||||
return memory.publish(channel, message);
|
||||
}
|
||||
try {
|
||||
await client.publish(channel, message);
|
||||
return 1;
|
||||
} catch (e) {
|
||||
noteError('publish', e);
|
||||
stats.fallbackOps += 1;
|
||||
return memory.publish(channel, message);
|
||||
}
|
||||
},
|
||||
|
||||
on(channel, fn) {
|
||||
if (!handlers.has(channel)) handlers.set(channel, new Set());
|
||||
handlers.get(channel).add(fn);
|
||||
memory.on(channel, fn);
|
||||
if (sub && sub.isOpen) subscribeChannel(channel);
|
||||
return () => {
|
||||
const set = handlers.get(channel);
|
||||
if (set) set.delete(fn);
|
||||
};
|
||||
},
|
||||
|
||||
async connect() {
|
||||
if (!enabled()) {
|
||||
console.log('Redis: REDIS_URL не задан, используется in-memory кэш');
|
||||
return false;
|
||||
}
|
||||
if (connecting) return connecting;
|
||||
if (ready()) return true;
|
||||
closed = false;
|
||||
connecting = (async () => {
|
||||
if (!sweeper) {
|
||||
sweeper = setInterval(() => memory.sweep(), 60 * 1000);
|
||||
sweeper.unref();
|
||||
}
|
||||
try {
|
||||
client = createClient({
|
||||
url,
|
||||
socket: { reconnectStrategy: retries => Math.min(200 + retries * 200, 5000) },
|
||||
});
|
||||
client.on('error', e => noteError('connection', e));
|
||||
client.on('ready', () => {
|
||||
warned = false;
|
||||
ensureSubscriber().catch(e => noteError('subscriber', e));
|
||||
});
|
||||
await withTimeout(client.connect(), CONNECT_TIMEOUT_MS, 'connect');
|
||||
await ensureSubscriber();
|
||||
const pong = await withTimeout(client.ping(), CONNECT_TIMEOUT_MS, 'ping');
|
||||
console.log(`Redis connected (${pong}), namespace=${namespace}`);
|
||||
return true;
|
||||
} catch (e) {
|
||||
noteError('connect', e);
|
||||
return false;
|
||||
} finally {
|
||||
connecting = null;
|
||||
}
|
||||
})();
|
||||
return connecting;
|
||||
},
|
||||
|
||||
async close() {
|
||||
closed = true;
|
||||
if (sweeper) {
|
||||
clearInterval(sweeper);
|
||||
sweeper = null;
|
||||
}
|
||||
const pending = [sub, client].filter(Boolean);
|
||||
sub = null;
|
||||
client = null;
|
||||
subscribed.clear();
|
||||
await Promise.all(pending.map(async c => {
|
||||
try { await withTimeout(c.quit(), 2000, 'quit'); } catch (e) {
|
||||
try { c.destroy(); } catch (err) {}
|
||||
}
|
||||
}));
|
||||
},
|
||||
};
|
||||
|
||||
async function ensureSubscriber() {
|
||||
if (closed || !ready()) return;
|
||||
if (!sub) {
|
||||
sub = client.duplicate();
|
||||
sub.on('error', e => noteError('subscriber', e));
|
||||
sub.on('ready', () => { resubscribeAll().catch(e => noteError('subscribe', e)); });
|
||||
}
|
||||
if (!sub.isOpen) await withTimeout(sub.connect(), CONNECT_TIMEOUT_MS, 'subscriber connect');
|
||||
if (sub.isReady) await resubscribeAll();
|
||||
}
|
||||
|
||||
return api;
|
||||
}
|
||||
|
||||
module.exports = { createRedis, createMemoryBackend };
|
||||
@@ -0,0 +1,148 @@
|
||||
const assert = require('assert');
|
||||
const { createRedis } = require('./redis');
|
||||
|
||||
function loadEnv() {
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const file = path.join(__dirname, '.env');
|
||||
if (!fs.existsSync(file)) return;
|
||||
for (const line of fs.readFileSync(file, 'utf8').split('\n')) {
|
||||
const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/);
|
||||
if (m && !(m[1] in process.env)) process.env[m[1]] = m[2];
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
loadEnv();
|
||||
const url = process.env.REDIS_URL || `redis://:${process.env.REDIS_PASSWORD || ''}@127.0.0.1:6379`;
|
||||
const prefix = 'test-' + Date.now();
|
||||
const cache = createRedis({ url, prefix });
|
||||
|
||||
const connected = await cache.connect();
|
||||
console.log('connect:', connected, 'ready:', cache.isReady());
|
||||
assert.strictEqual(connected, true, 'must connect');
|
||||
assert.strictEqual(cache.isReady(), true, 'must be ready');
|
||||
|
||||
await cache.set('setting:foo', 'bar', 60000);
|
||||
assert.strictEqual(await cache.get('setting:foo'), 'bar', 'string roundtrip');
|
||||
|
||||
await cache.set('obj', { a: 1, b: [2, 3], c: null }, 60000);
|
||||
assert.deepStrictEqual(await cache.get('obj'), { a: 1, b: [2, 3], c: null }, 'json roundtrip');
|
||||
|
||||
assert.strictEqual(await cache.get('missing'), undefined, 'miss returns undefined');
|
||||
await cache.set('setting:ttl', 'x', 120);
|
||||
await new Promise(r => setTimeout(r, 250));
|
||||
assert.strictEqual(await cache.get('setting:ttl'), undefined, 'ttl expiry');
|
||||
|
||||
await cache.set('groups:1', 'g1', 60000);
|
||||
await cache.set('groups:2', 'g2', 60000);
|
||||
await cache.set('students:1', 's1', 60000);
|
||||
await cache.dropPrefix('groups:');
|
||||
assert.strictEqual(await cache.get('groups:1'), undefined, 'dropPrefix removes groups:1');
|
||||
assert.strictEqual(await cache.get('groups:2'), undefined, 'dropPrefix removes groups:2');
|
||||
assert.strictEqual(await cache.get('students:1'), 's1', 'dropPrefix keeps students:1');
|
||||
|
||||
await cache.set('fail:login:1.2.3.4', 3, 60000);
|
||||
await cache.set('fail:login:5.6.7.8', 9, 60000);
|
||||
await cache.dropMatch('fail:*:1.2.3.4');
|
||||
assert.strictEqual(await cache.get('fail:login:1.2.3.4'), undefined, 'dropMatch suffix removes target');
|
||||
assert.strictEqual(await cache.get('fail:login:5.6.7.8'), 9, 'dropMatch keeps other ip');
|
||||
|
||||
const c1 = await cache.incr('counter:a', 60000);
|
||||
const c2 = await cache.incr('counter:a', 60000);
|
||||
assert.strictEqual(c1, 1, 'incr first = 1');
|
||||
assert.strictEqual(c2, 2, 'incr second = 2');
|
||||
await cache.incr('counter:short', 120);
|
||||
await new Promise(r => setTimeout(r, 250));
|
||||
assert.strictEqual(await cache.incr('counter:short', 60000), 1, 'incr ttl set on first call');
|
||||
|
||||
const hits = [];
|
||||
const received = new Promise(resolve => {
|
||||
cache.on('test:chan', msg => { hits.push(msg); resolve(msg); });
|
||||
});
|
||||
await new Promise(r => setTimeout(r, 200));
|
||||
await cache.publish('test:chan', { hello: 'world' });
|
||||
const got = await Promise.race([received, new Promise(r => setTimeout(() => r('TIMEOUT'), 3000))]);
|
||||
assert.notStrictEqual(got, 'TIMEOUT', 'pubsub must deliver');
|
||||
assert.deepStrictEqual(JSON.parse(got), { hello: 'world' }, 'pubsub payload');
|
||||
assert.strictEqual(hits.length, 1, 'pubsub delivered exactly once (no double delivery)');
|
||||
|
||||
let wraps = 0;
|
||||
const v1 = await cache.wrap('wrap:key', 60000, async () => { wraps += 1; return 'computed'; });
|
||||
const v2 = await cache.wrap('wrap:key', 60000, async () => { wraps += 1; return 'other'; });
|
||||
assert.strictEqual(v1, 'computed', 'wrap returns computed');
|
||||
assert.strictEqual(v2, 'computed', 'wrap returns cached');
|
||||
assert.strictEqual(wraps, 1, 'wrap computed once');
|
||||
|
||||
const store = cache.rateLimitStore('test', 60000);
|
||||
await store.init({ windowMs: 60000 });
|
||||
const r1 = await store.increment('ip1');
|
||||
const r2 = await store.increment('ip1');
|
||||
const r3 = await store.increment('ip2');
|
||||
assert.strictEqual(r1.totalHits, 1, 'rl first');
|
||||
assert.strictEqual(r2.totalHits, 2, 'rl second');
|
||||
assert.strictEqual(r3.totalHits, 1, 'rl separate key');
|
||||
assert.ok(r2.resetTime > Date.now(), 'rl resetTime in future');
|
||||
assert.ok(r2.resetTime instanceof Date, 'rl resetTime is a Date');
|
||||
assert.strictEqual(r1.resetTime.getTime(), r2.resetTime.getTime(), 'rl same window');
|
||||
const ttl = await cache.info();
|
||||
await store.decrement('ip1');
|
||||
assert.strictEqual((await store.increment('ip1')).totalHits, 2, 'decrement then increment');
|
||||
await store.resetKey('ip1');
|
||||
assert.strictEqual((await store.increment('ip1')).totalHits, 1, 'resetKey clears');
|
||||
await store.resetAll();
|
||||
assert.strictEqual((await store.increment('ip1')).totalHits, 1, 'resetAll clears');
|
||||
|
||||
await cache.del('obj');
|
||||
assert.strictEqual(await cache.get('obj'), undefined, 'del');
|
||||
|
||||
const info = await cache.info();
|
||||
console.log('info:', JSON.stringify(info));
|
||||
assert.strictEqual(info.driver, 'redis', 'info driver is redis');
|
||||
assert.ok(info.used_memory_bytes > 0, 'info has memory');
|
||||
|
||||
await cache.clear();
|
||||
assert.strictEqual(await cache.get('students:1'), undefined, 'clear removes all');
|
||||
|
||||
const other = createRedis({ url, prefix: prefix + '-other' });
|
||||
await other.connect();
|
||||
await other.set('iso', 'yes', 60000);
|
||||
assert.strictEqual(await cache.get('iso'), undefined, 'namespaces are isolated');
|
||||
await other.close();
|
||||
|
||||
await cache.close();
|
||||
assert.strictEqual(cache.isReady(), false, 'closed');
|
||||
|
||||
const offline = createRedis({ url: 'redis://127.0.0.1:1/', prefix: 'off-' + Date.now() });
|
||||
const okConn = await offline.connect();
|
||||
assert.strictEqual(okConn, false, 'bad url must not throw');
|
||||
await offline.set('k', 'v', 1000);
|
||||
assert.strictEqual(await offline.get('k'), 'v', 'fallback set/get works');
|
||||
await offline.set('groups:a', 1, 1000);
|
||||
await offline.set('students:a', 1, 1000);
|
||||
await offline.dropPrefix('groups:');
|
||||
assert.strictEqual(await offline.get('groups:a'), undefined, 'fallback dropPrefix');
|
||||
assert.strictEqual(await offline.get('students:a'), 1, 'fallback dropPrefix isolation');
|
||||
assert.strictEqual(await offline.incr('c', 1000), 1, 'fallback incr');
|
||||
assert.strictEqual(await offline.incr('c', 1000), 2, 'fallback incr 2');
|
||||
const offStore = offline.rateLimitStore('t', 60000);
|
||||
assert.strictEqual((await offStore.increment('k1')).totalHits, 1, 'fallback rl');
|
||||
assert.strictEqual((await offStore.increment('k1')).totalHits, 2, 'fallback rl 2');
|
||||
let localHit = null;
|
||||
const localRecv = new Promise(r => { offline.on('c', m => { localHit = m; r(m); }); });
|
||||
await offline.publish('c', { x: 1 });
|
||||
assert.deepStrictEqual(JSON.parse(await Promise.race([localRecv, new Promise(r => setTimeout(() => r('T'), 2000))])), { x: 1 }, 'fallback local pubsub');
|
||||
assert.deepStrictEqual(JSON.parse(localHit), { x: 1 }, 'fallback local payload');
|
||||
await offline.close();
|
||||
|
||||
const disabled = createRedis({});
|
||||
assert.strictEqual(await disabled.connect(), false, 'disabled connect false');
|
||||
await disabled.set('a', 1, 1000);
|
||||
assert.strictEqual(await disabled.get('a'), 1, 'disabled memory works');
|
||||
await disabled.close();
|
||||
|
||||
console.log('\nALL REDIS TESTS PASSED');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main().catch(e => { console.error('FAILED:', e.message); console.error(e.stack); process.exit(1); });
|
||||
+5
-2
@@ -10,10 +10,13 @@ mkdir -p backups
|
||||
echo ">> Экспорт БД"
|
||||
docker compose exec -T db pg_dump -U app -d whereldo | gzip > backups/db.sql.gz
|
||||
|
||||
echo ">> Копирование фотографий"
|
||||
echo ">> Экспорт файлов из хранилища"
|
||||
rm -rf 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
|
||||
|
||||
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
|
||||
gzip -dc "$TMP/db.sql.gz" | docker compose exec -T db psql -U app -d whereldo
|
||||
|
||||
echo ">> Восстановление фотографий"
|
||||
docker cp "$TMP/_uploads/." "whatido-app-1:/app/uploads/"
|
||||
echo ">> Загрузка файлов в хранилище"
|
||||
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 ">> Запуск приложения"
|
||||
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,10 +6,11 @@ const MIN_TEXT_CHARS = 4;
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const crypto = require('crypto');
|
||||
const { textDiff, FIELD_LABELS } = require('./diff');
|
||||
const PHOTO_MAX_ATTEMPTS = 3;
|
||||
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 }) {
|
||||
const AI_URL = photoAiUrl || process.env.PHOTO_AI_URL || '';
|
||||
const IDLE_MIN = 2000;
|
||||
const IDLE_MAX = 30000;
|
||||
@@ -37,10 +38,17 @@ function createPhotoEnhanceWorker({ pool, getSetting, logAudit, invalidateEntrie
|
||||
});
|
||||
}
|
||||
|
||||
function notify() {
|
||||
function wakeLocal() {
|
||||
if (wake) wake();
|
||||
}
|
||||
|
||||
function notify() {
|
||||
wakeLocal();
|
||||
if (bus) bus.publish();
|
||||
}
|
||||
|
||||
if (bus) bus.subscribe(wakeLocal);
|
||||
|
||||
async function isEnabled() {
|
||||
const v = await getSetting('photo_worker_enabled', 'true');
|
||||
return String(v) !== 'false';
|
||||
@@ -71,30 +79,7 @@ function createPhotoEnhanceWorker({ pool, getSetting, logAudit, invalidateEntrie
|
||||
}
|
||||
}
|
||||
|
||||
function safeUnlinkPath(p) {
|
||||
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) {
|
||||
async function enhanceWithSharp(srcKey, params) {
|
||||
if (!sharp) throw new Error('sharp недоступен на сервере');
|
||||
const p = params || {};
|
||||
const clamp = (v, min, max, def) => {
|
||||
@@ -106,7 +91,9 @@ function createPhotoEnhanceWorker({ pool, getSetting, logAudit, invalidateEntrie
|
||||
const saturate = clamp(p.saturate, 0, 300, 100);
|
||||
const sharpAmt = clamp(p.sharp, 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);
|
||||
pipeline = pipeline.modulate({ brightness: brightness / 100, saturation: saturate / 100 });
|
||||
if (contrast !== 100) {
|
||||
@@ -117,13 +104,16 @@ function createPhotoEnhanceWorker({ pool, getSetting, logAudit, invalidateEntrie
|
||||
pipeline = pipeline.sharpen({ sigma: 0.5 + (sharpAmt / 100) * 1.5, m1: 0, m2: 1 + sharpAmt / 50 });
|
||||
}
|
||||
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}`;
|
||||
}
|
||||
|
||||
async function runAiEnhance(srcAbs) {
|
||||
async function runAiEnhance(srcKey) {
|
||||
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();
|
||||
fd.append('image', new Blob([buf], { type: 'image/jpeg' }), 'photo.jpg');
|
||||
fd.append('scale', '2');
|
||||
@@ -135,7 +125,9 @@ function createPhotoEnhanceWorker({ pool, getSetting, logAudit, invalidateEntrie
|
||||
if (!resp.ok) throw new Error('AI service error: ' + resp.status);
|
||||
const out = Buffer.from(await resp.arrayBuffer());
|
||||
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}`;
|
||||
}
|
||||
async function applyResult(job, newPath) {
|
||||
@@ -159,9 +151,8 @@ function createPhotoEnhanceWorker({ pool, getSetting, logAudit, invalidateEntrie
|
||||
return true;
|
||||
}
|
||||
const photoPath = rows[0].photo_path;
|
||||
const srcAbs = path.join(uploadsDir, String(photoPath).replace(/^\/+/, '').replace(/^uploads\//, ''));
|
||||
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);
|
||||
stats.done++;
|
||||
stats.last_at = new Date().toISOString();
|
||||
@@ -250,7 +241,7 @@ function createPhotoEnhanceWorker({ pool, getSetting, logAudit, invalidateEntrie
|
||||
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 MODEL = model || process.env.AI_MODEL || 'qwen2.5-1.5b-instruct-q4_k_m.gguf';
|
||||
const DEFAULT_PROMPT = defaultPrompt || process.env.AI_PROMPT || 'Ты — редактор текстов. Исправь ТОЛЬКО грамматические, орфографические и пунктуационные ошибки в тексте. Приведи к правильному регистру буквы. НЕ меняй слова, структуру предложений, стиль или смысл текста. Верни ТОЛЬКО исправленный текст без пояснений.';
|
||||
@@ -283,10 +274,17 @@ function createEntryAutoChecker({ pool, getSetting, logAudit, aiUrl, defaultProm
|
||||
});
|
||||
}
|
||||
|
||||
function notify() {
|
||||
function wakeLocal() {
|
||||
if (wake) wake();
|
||||
}
|
||||
|
||||
function notify() {
|
||||
wakeLocal();
|
||||
if (bus) bus.publish();
|
||||
}
|
||||
|
||||
if (bus) bus.subscribe(wakeLocal);
|
||||
|
||||
async function isEnabled() {
|
||||
const v = await getSetting('ai_autocheck_enabled', 'true');
|
||||
return String(v) !== 'false';
|
||||
@@ -414,7 +412,18 @@ function createEntryAutoChecker({ pool, getSetting, logAudit, aiUrl, defaultProm
|
||||
if (changed) stats.corrected++; else stats.unchanged++;
|
||||
stats.last_at = new Date().toISOString();
|
||||
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;
|
||||
} catch (e) {
|
||||
const message = (e && e.message ? e.message : 'error').slice(0, 500);
|
||||
|
||||
Reference in New Issue
Block a user