При сохранении записи журнала (PUT /api/entries/:id) сравнивается состояние до и после, и в audit_log пишется не только факт правки, но и сами изменения: пословный дифф текста, статистика добавленных и удалённых слов, а также смена ФИО, группы и темы модуля. - diff.js: пословный LCS-дифф без зависимостей, обрезка больших текстов, сборка изменений по полям записи, облегчённый target для списка аудита - source правки: manual / ai / ai_manual / ai_revert; журнал шлёт edit_source, сервер доверяет явному значению и определяет источник по description_ai как запасной вариант - те же диффы пишутся для автопроверки ИИ (entry.ai.auto-check) и отката к оригиналу (entry.ai.revert) - GET /api/audit отдаёт список без diff, GET /api/audit/:id — полный target, чтобы не грузить килобайты текста на каждую строку - Аудит: колонка «Кто», сводка в таблице, модалка с подсветкой удалённого и добавленного текста, «было/стало» для полей - auth.login теперь пишет user_id, иначе колонка «Кто» показывала «система» - diff.selftest.js: 16 тестов диффа; README и AGENTS обновлены
18 KiB
AGENT.md — Developer Agent Guidelines for WhatIDo
This document defines how AI agents should work with the WhatIDo codebase. Follow these rules strictly.
Project Overview
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, 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 inpublic/ - 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
Development Rules
1. Code Style
- No comments unless explicitly requested
- ES modules not used — CommonJS (
require) throughout - Error handling: try/catch with explicit status codes, no global error handler
- Validation: Inline helper functions (
reqInt,reqStr,optInt, etc.) — use them - Security first: All uploads validated, path traversal blocked, rate limits on public routes
2. Database
- Schema: Defined in
db/init.sql(runs on first container start) - Migrations:
db/migration.sqlfor existing DBs — update both when changing schema - Connection: Single
Poolfrompg,DATABASE_URLfrom env - Queries: Parameterized only (
$1,$2...), never string interpolation - Transactions: Use
client.query('BEGIN')/COMMIT/ROLLBACKfor multi-statement ops
3. File Uploads
- Multer configs:
upload(images only),adminUpload(wider allowed ext),uploadBackup(restore) - Limits: 10 MB/file, 30 MB total per entry
- Staging: Multer always writes to
uploads/(timestamp-random.ext); a globalres.on('finish')hook persists each uploaded file throughstorage.persiston successful responses (only whenSTORAGE_DRIVER=s3) - HEIC: Auto-converted to JPEG via
heic-convert - Cleanup:
safeUnlink/sweepOrphanedUploads— never delete outsideuploads/or the configured bucket
3a. Storage (storage.js)
- Drivers:
local(default, files inuploads/) ands3(S3-compatible: SeaweedFS by default, MinIO viadocker-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.*onuploads/directly in request/worker code — usestorage.*.safeUnlinkis the only deletion helper (local + remote, idempotent) - Read path:
STORAGE_LOCAL_FALLBACK=1prefers 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 insideuploads/and are pruned hourly (STORAGE_CACHE_MAX_AGE_HOURS) - Never publish the S3 API port: only
127.0.0.1on 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
- Middleware:
requireAuth— читаетX-Auth-Token, 401 без валидной активной сессии.requireAdmin— самодостаточный (внутри вызываетrequireAuth, еслиreq.userещё нет), 403 приrole !== 'admin'.optionalAuth— для публичных страниц с персонализацией - Филиалы:
branchScope(user)/branchWhere(user, alias)— для не-adminuser.branch_ids(изuser_branches) ограничивают выборку; уadminids = 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/offsetquery params, return{ items, total }or{ entries, total } - Filters:
group_id,date_from,date_to,student_name,search,deleted
5. Frontend (public/)
- Vanilla HTML/CSS/JS, no build step
- Each page = single HTML file + shared
admin.js/admin.css - API calls via
fetchwithX-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: сервисы
db,app,redis,s3(+ опциональноtailscale,cloudflared,text-corrector,photo-ai)db: postgres:16-alpine, healthcheck, init.sql mountedredis: redis:7-alpine,--requirepass, AOF,maxmemory+allkeys-lru, healthcheck, томredis-data, порт только на127.0.0.1app: builds from Dockerfile, exposes 3003/3443, mounts uploadstailscale: host network, NET_ADMIN, runsstart-tailscale.sh(funnel to 127.0.0.1:3443)
- Env vars (required):
ADMIN_PASSWORD,DB_PASSWORD,REDIS_PASSWORD - Env vars (optional):
REDIS_PREFIX(defaultwhatido),REDIS_MAXMEMORY(default256mb),REDIS_CONNECT_TIMEOUT_MS(default5000) - Port 443 on host must be free (tailscale listens directly)
7. Tailscale Publication
- No external IP / port forwarding needed
- Access:
https://whatido.<tailnet>.ts.net(inside tailnet + internet via Funnel) - First run:
docker exec -it whatido-tailscale-1 tailscale up --hostname=whatido→ authorize in browser - Enable Serve/Funnel in Tailscale admin console for the node
- Cert: app generates self-signed cert at build (
certs/cert.pem), mounted into tailscale container
8. Backup / Restore
- Admin UI:
/api/backup(download tar.gz),/api/restore(upload tar.gz) - Scripts:
scripts/backup.sh,scripts/restore.sh(host-level) - Backup format:
data.json(all tables) +uploads/directory - Restore validates all data, resets sequences, sweeps orphans
Common Tasks
Add a new API endpoint
- Add route in
server.js(group with related routes) - Use
requireAdminfor admin,apiLimiter/fileLimiterfor public - Validate input with helper functions
- Use parameterized queries, transactions if multi-table
- Call
logAudit(req, 'action.name', { ... })for mutations - Return JSON, handle errors with appropriate status codes
Add a database column/table
- Update
db/init.sql(CREATE TABLE / ALTER TABLE) - Update
db/migration.sql(idempotent ALTERs) - Update
server.jsqueries that SELECT/INSERT the table - Test:
docker compose down && docker compose up -d --build
Add a frontend page
- Create
public/newpage.html(copy structure from existing) - Link in
public/admin.htmlnavigation if admin page - Use
admin.jsutilities:api(),requireAuth(),formatDate(), etc. - No build step — just refresh browser
Modify file upload rules
- Edit
BLOCKED_EXT,ALLOWED_IMAGE_EXT,ADMIN_ALLOWED_EXTconstants - Update Multer
fileFilterfunctions - Keep
MAX_TOTAL_UPLOAD_BYTESand per-file limit in sync
Migrate files to S3 / switch storage driver
docker compose up -d s3docker compose exec -T app node scripts/migrate-to-s3.js --dry-runthen without the flag (idempotent, size-checked, keeps local files)docker compose exec -T app node scripts/migrate-to-s3.js --verify-only- Set
STORAGE_DRIVER=s3in.env,docker compose up -d app - 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-localbefore the app serves reads from S3 and the verification passes
Testing & Verification
No automated test suite exists. Verify manually:
# Start stack
docker compose up -d --build
# Check logs
docker compose logs -f app
# 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
corsmiddleware)
File Map (key files)
| File | Purpose |
|---|---|
server.js |
Entire backend (Express, routes, DB, uploads, backup) |
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, 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 (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 |
Do Not
- ❌ Add dependencies without updating
package.jsonand rebuilding - ❌ Write files outside
uploads/orcerts/ - ❌ Touch
uploads/withfs.*in request/worker code — usestorage.*(files may live only in S3) - ❌ Run
migrate-to-s3.js --delete-localbefore verification and cutover - ❌ Expose the S3 API port publicly (only
127.0.0.1in compose) - ❌ Expose the Redis port publicly (only
127.0.0.1in compose) - ❌ Call
fs.*/pgdirectly for cache, counters or pub/sub — useredis.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,Functionconstructor, or dynamic code execution - ❌ Add comments to code (this file excepted)
Quick Commands
# 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
# Manual funnel restart
docker exec whatido-tailscale-1 tailscale funnel --bg --yes https://127.0.0.1:3443