feat: add branches feature, security audit, and multi-branch support

This commit is contained in:
dev
2026-09-09 09:41:07 +03:00
parent 7a003e5df6
commit d6e589d2f5
19 changed files with 1377 additions and 82 deletions
@@ -0,0 +1,6 @@
- generic [ref=f1e2]:
- heading "Админ-панель" [level=1] [ref=f1e3]
- textbox "Пароль" [ref=f1e4]
- button "Войти" [ref=f1e5] [cursor=pointer]
- link "На главную" [ref=f1e7] [cursor=pointer]:
- /url: /
@@ -0,0 +1,6 @@
- generic [ref=f3e2]:
- heading "Админ-панель" [level=1] [ref=f3e3]
- textbox "Пароль" [active] [ref=f3e4]
- button "Войти" [ref=f3e5] [cursor=pointer]
- link "На главную" [ref=f3e7] [cursor=pointer]:
- /url: /
+185
View File
@@ -0,0 +1,185 @@
# 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, 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.
---
## 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.sql` for existing DBs — update both when changing schema
- **Connection**: Single `Pool` from `pg`, `DATABASE_URL` from env
- **Queries**: Parameterized only (`$1`, `$2`...), never string interpolation
- **Transactions**: Use `client.query('BEGIN')` / `COMMIT` / `ROLLBACK` for 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
- **Storage**: `uploads/` bind-mounted to host, filenames = `timestamp-random.ext`
- **HEIC**: Auto-converted to JPEG via `heic-convert`
- **Cleanup**: `safeUnlink` / `sweepOrphanedUploads` — never delete outside `uploads/`
### 4. API Patterns
- **Admin routes**: `requireAdmin` middleware (checks `X-Admin-Token`)
- **Public routes**: `apiLimiter` (300/15min), `entryLimiter` (10/15min), `fileLimiter` (300/15min)
- **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`
### 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`
- 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)
- `db`: postgres:16-alpine, healthcheck, init.sql mounted
- `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`
- **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
1. Add route in `server.js` (group with related routes)
2. Use `requireAdmin` for admin, `apiLimiter`/`fileLimiter` for public
3. Validate input with helper functions
4. Use parameterized queries, transactions if multi-table
5. Call `logAudit(req, 'action.name', { ... })` for mutations
6. Return JSON, handle errors with appropriate status codes
### Add a database column/table
1. Update `db/init.sql` (CREATE TABLE / ALTER TABLE)
2. Update `db/migration.sql` (idempotent ALTERs)
3. Update `server.js` queries that SELECT/INSERT the table
4. Test: `docker compose down && docker compose up -d --build`
### Add a frontend page
1. Create `public/newpage.html` (copy structure from existing)
2. Link in `public/admin.html` navigation if admin page
3. Use `admin.js` utilities: `api()`, `requireAuth()`, `formatDate()`, etc.
4. No build step — just refresh browser
### Modify file upload rules
- Edit `BLOCKED_EXT`, `ALLOWED_IMAGE_EXT`, `ADMIN_ALLOWED_EXT` constants
- Update Multer `fileFilter` functions
- Keep `MAX_TOTAL_UPLOAD_BYTES` and per-file limit in sync
---
## Testing & Verification
No automated test suite exists. Verify manually:
```bash
# Start stack
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
# Run backup/restore scripts
./scripts/backup.sh
./scripts/restore.sh backups/whatido-backup-<date>.tar.gz
```
---
## Security Checklist (before any change)
- [ ] No SQL interpolation — only `$1`, `$2`...
- [ ] Upload path validation via `isSafeUploadPath` / `safeUnlink`
- [ ] Rate limiter on new public routes
- [ ] Admin routes behind `requireAdmin`
- [ ] No secrets in code — only via env vars
- [ ] Helmet headers present (already global)
- [ ] CORS disabled (no `cors` middleware)
---
## File Map (key files)
| File | Purpose |
|------|---------|
| `server.js` | Entire backend (Express, routes, DB, uploads, backup) |
| `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) |
| `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 |
| `start-tailscale.sh` | Tailscale container entrypoint |
| `.env.example` | Env var template |
---
## Do Not
- ❌ Add dependencies without updating `package.json` and rebuilding
- ❌ Write files outside `uploads/` or `certs/`
- ❌ Commit `.env`, `certs/`, `uploads/`, `backups/`, `node_modules/`
- ❌ Expose DB port (5432) outside docker network
- ❌ Use `eval`, `Function` constructor, or dynamic code execution
- ❌ Add comments to code (this file excepted)
---
## Quick Commands
```bash
# Full rebuild
docker compose down && docker compose up -d --build
# App logs
docker compose logs -f app
# DB shell
docker compose exec db psql -U app -d whereldo
# 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
```
+1 -1
View File
@@ -9,5 +9,5 @@ RUN apk add --no-cache openssl && \
openssl req -x509 -nodes -newkey rsa:2048 -days 3650 \
-keyout certs/key.pem -out certs/cert.pem \
-subj "/CN=whatido.local" -addext "subjectAltName=DNS:localhost,IP:127.0.0.1"
EXPOSE 3000 3443
EXPOSE 3003 3443
CMD ["node", "server.js"]
+142
View File
@@ -0,0 +1,142 @@
# План: Мультифилиальность и роли пользователей
## Проблема
Сейчас приложение однофилиальное и одно-user-овое. Нужно поддержать:
- 5-20 филиалов
- Несколько тьюторов/админов
- Филиал студента определяется автоматически через его группу
---
## 1. База данных — схема
### Новые таблицы
```sql
-- Филиалы
CREATE TABLE branches (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL UNIQUE,
created_at TIMESTAMPTZ DEFAULT now()
);
-- Пользователи системы
CREATE TABLE users (
id SERIAL PRIMARY KEY,
name VARCHAR(150) NOT NULL,
password_hash VARCHAR(255) NOT NULL,
role VARCHAR(20) NOT NULL DEFAULT 'tutor', -- 'admin' или 'tutor'
branch_id INT REFERENCES branches(id), -- NULL для супер-админа
created_at TIMESTAMPTZ DEFAULT now()
);
```
### Изменения в существующих таблицах
| Таблица | Новое поле | Тип | Связь |
|---------|-----------|-----|-------|
| `groups` | `branch_id` | `INT` | `REFERENCES branches(id)` |
| `students` | `branch_id` | `INT` | `REFERENCES branches(id)` |
| `entries` | `branch_id` | `INT` | `REFERENCES branches(id)` |
| `group_photos` | `branch_id` | `INT` | `REFERENCES branches(id)` |
### Логика определения филиала
- Студент → в группе → группа привязана к филиалу → филиал определён
- При отправке записи `branch_id` берётся из группы студента
- Студенту не нужно выбирать филиал — он определяется автоматически
---
## 2. Аутентификация
- Вход по **имени + пароль** (без email)
- Пароли хранятся в `bcrypt` хеше
- Токен (JWT) выдаётся при логине, хранится в `sessionStorage`
- `requireAdmin` заменяется на `requireAuth` + проверку роли
---
## 3. Роли
| Роль | Возможности |
|------|-------------|
| **admin** | Всё: управление пользователями, филиалами, настройками, бэкапами + все данные |
| **tutor** | Управление записями, группами, студентами, файлами, ссылками — видит все группы |
---
## 4. Публичная форма
- Ссылка на форму одна (как сейчас)
- Студент вводит имя → система находит студента → определяет группу → определяет филиал
- Если студент новый (нет в базе) — варианты на обсуждение:
- Не пускать (только зарегистрированные студенты)
- Автоматически создать в группе "Не распределён"
---
## 5. Фронтенд — изменения
### Страница логина
- Поле "Имя" + "Пароль" вместо одного пароля
### Админ-панель
- В хедере/сайдбаре выпадающий список филиалов для фильтрации
- Группы — отображение филиала у каждой группы
- Студенты — отображение филиала у каждого студента
- Журнал — фильтр по филиалу
- Настройки — управление пользователями и филиалами (только для admin)
---
## 6. API — новые эндпоинты
| Метод | Путь | Описание |
|-------|------|----------|
| `POST` | `/api/auth/login` | Вход (имя + пароль → токен) |
| `GET` | `/api/branches` | Список филиалов |
| `POST` | `/api/branches` | Создать филиал (admin) |
| `DELETE` | `/api/branches/:id` | Удалить филиал (admin) |
| `GET` | `/api/users` | Список пользователей (admin) |
| `POST` | `/api/users` | Создать пользователя (admin) |
| `DELETE` | `/api/users/:id` | Удалить пользователя (admin) |
---
## 7. Миграция данных
- Существующие группы/студенты/записи автоматически попадут в филиал "Основной" (или первый созданный)
- `ADMIN_PASSWORD` из `.env` конвертируется в запись `users` с ролью `admin`
---
## 8. Структура файлов (рефакторинг)
```
server.js → рефакторинг на модули:
├── routes/
│ ├── auth.js (логин/аутентификация)
│ ├── branches.js (CRUD филиалов)
│ ├── users.js (CRUD пользователей)
│ ├── groups.js (существующие + branch_id)
│ ├── students.js (существующие + branch_id)
│ ├── entries.js (существующие + branch_id)
│ └── ...
├── middleware/
│ ├── auth.js (requireAuth, requireRole)
│ └── branch.js (branch scoping)
└── db.js (подключение к БД)
```
---
## Порядок реализации
1. **Миграция БД** — создать таблицы `branches`, `users`, добавить `branch_id` к существующим
2. **Миграция данных** — создать филиал "Основной", перенести существующие данные
3. **Бэкенд: аутентификация** — `auth.js` (логин, JWT, middleware)
4. **Бэкенд: CRUD филиалов и пользователей** — новые роуты
5. **Бэкенд: branch scoping** — добавить `branch_id` ко всем существующим запросам
6. **Фронтенд: логин** — новая страница логина
7. **Фронтенд: admin panel** — добавить фильтр филиалов, страницы управления
8. **Тестирование** — проверить все сценарии
+289
View File
@@ -0,0 +1,289 @@
# PRD.md — Product Requirements Document: WhatIDo
**Version**: 1.0
**Status**: Active
**Last Updated**: 2026-09-09
---
## 1. Product Summary
**WhatIDo** is a self-hosted accounting system for an educational center (youth club, coding school, art studio). It tracks attendance, student project works, group photo chronicles, and publishes public showcase pages via share links. Designed for zero-public-IP deployment using Tailscale Funnel.
**Target Users**:
- **Administrators/Teachers** — manage groups, students, entries, files, settings
- **Parents/Students** — view public showcase pages (read-only, no auth)
**Core Value**: Simple, secure, zero-infrastructure publishing. Runs on any Linux box with Docker.
---
## 2. Functional Requirements
### 2.1 Groups Management
| ID | Requirement | Priority |
|----|-------------|----------|
| GRP-1 | CRUD groups: name, schedule (day of week, start/end time), branch assignment | Must |
| GRP-2 | Group cover photo (auto-set from latest group photo or manual) | Must |
| GRP-3 | Photo chronicle per group: upload, caption, date taken, pagination | Must |
| GRP-4 | Active groups endpoint (filters by current day/time in Europe/Moscow) | Must |
| GRP-5 | Branches (locations): CRUD with address, phone; groups link to branch | Should |
### 2.2 Students Management
| ID | Requirement | Priority |
|----|-------------|----------|
| STU-1 | CRUD students: name, group assignment | Must |
| STU-2 | Batch assign students to group | Should |
| STU-3 | Unique name constraint per student | Must |
### 2.3 Journal Entries (Attendance + Project Works)
| ID | Requirement | Priority |
|----|-------------|----------|
| ENT-1 | Create entry: student name (free text), group, description, photo, multiple files | Must |
| ENT-2 | List entries with filters: group, date range, student name, search (name/description), deleted flag | Must |
| ENT-3 | Update entry: description, photo, files | Must |
| ENT-4 | Soft delete / restore (deleted_at timestamp) | Must |
| ENT-5 | Trash view: list deleted entries, restore, permanent delete | Must |
| ENT-6 | Pagination (limit/offset) + total count | Must |
| ENT-7 | Anti-spam: min interval between entries per student (configurable, default 30 min) | Must |
| ENT-8 | Files attached to entry: upload (max 10 files, 10 MB each, 30 MB total), download by token | Must |
### 2.4 Files Management (Centralized)
| ID | Requirement | Priority |
|----|-------------|----------|
| FIL-1 | List all files with filters: search, student, group, date range | Must |
| FIL-2 | Detached files tab: files with `entry_id = NULL` | Must |
| FIL-3 | Detach file from entry (sets `detached_at`) | Must |
| FIL-4 | Delete file (removes from disk + DB) | Must |
| FIL-5 | Public file access by token (image inline, others download) | Must |
| FIL-6 | File size display in list | Should |
### 2.5 Share Links (Public Showcase Pages)
| ID | Requirement | Priority |
|----|-------------|----------|
| SHR-1 | Create share link: name, optional group, student, date range, anonymize names, expiry (default 7 days), optional password | Must |
| SHR-2 | List/Edit/Delete share links (admin) | Must |
| SHR-3 | Public page (`/s/:token`): shows filtered entries + group photos | Must |
| SHR-4 | Password protection on share link (bcrypt) | Must |
| SHR-5 | Expiry enforcement (410 Gone after expires_at) | Must |
| SHR-6 | Anonymize student names on public page (Student 1, Student 2...) | Should |
| SHR-7 | Group photos on public page (latest 12) | Should |
| SHR-8 | File download from share page (validates link + password + filters) | Must |
### 2.6 Dashboard & Statistics
| ID | Requirement | Priority |
|----|-------------|----------|
| DSH-1 | Stats cards: total entries, trash count, groups, unique students | Must |
| DSH-2 | Active groups right now (schedule match) | Must |
| DSH-3 | Activity chart: entries per day (last 14 days) | Should |
| DSH-4 | Top students by entry count | Should |
| DSH-5 | Recent entries list (last 10) | Should |
### 2.7 Settings
| ID | Requirement | Priority |
|----|-------------|----------|
| SET-1 | Footer left/right text (displayed on public pages) | Must |
| SET-2 | Anti-spam interval (minutes) | Must |
| SET-3 | Admin-only access | Must |
### 2.8 Backup & Restore
| ID | Requirement | Priority |
|----|-------------|----------|
| BAK-1 | Download full backup: tar.gz with data.json (all tables) + uploads/ | Must |
| BAK-2 | Restore from backup file: validates format, replaces all data, resets sequences | Must |
| BAK-3 | Host-level scripts: `backup.sh`, `restore.sh` | Should |
| BAK-4 | Audit log entry for backup download/restore | Must |
### 2.9 Audit Log
| ID | Requirement | Priority |
|----|-------------|----------|
| AUD-1 | Log all mutating actions: action name, target JSON, IP, timestamp | Must |
| AUD-2 | Admin view: paginated list (default 100, max 1000) | Must |
### 2.10 Security & Infrastructure
| ID | Requirement | Priority |
|----|-------------|----------|
| SEC-1 | Admin auth via `X-Admin-Token` header (env `ADMIN_PASSWORD`, no default) | Must |
| SEC-2 | Rate limiting: entries 10/15min, files/share 300/15min | Must |
| SEC-3 | Upload validation: block dangerous extensions, MIME check, size limits | Must |
| SEC-4 | Path traversal protection on file delete/serve | Must |
| SEC-5 | Helmet headers (X-Frame-Options, nosniff, HSTS, Referrer-Policy) | Must |
| SEC-6 | No CORS (cross-origin blocked) | Must |
| SEC-7 | DB port not exposed publicly | Must |
| SEC-8 | TLS termination by Tailscale (Let's Encrypt), app uses self-signed cert internally | Must |
| SEC-9 | Tailscale Funnel publication (no public IP, no port forward) | Must |
---
## 3. Non-Functional Requirements
| Category | Requirement |
|----------|-------------|
| **Performance** | API responses < 500ms for typical queries; pagination for large lists |
| **Reliability** | DB healthcheck; app restarts on crash; uploads persisted on host |
| **Scalability** | Single-instance design; PostgreSQL connection pooling via `pg.Pool` |
| **Maintainability** | Single `server.js` file; vanilla frontend; no build step |
| **Portability** | Docker Compose; runs on any Linux/ARM64/AMD64 with Docker |
| **Backup/Recovery** | Full restore < 5 min for typical dataset (< 1 GB) |
| **Security** | No secrets in image; env vars only; regular dependency updates |
---
## 4. Data Model
```
groups
id PK, name UK, created_at, day_of_week (0-6), time_start, time_end, branch_id FK, cover_path
students
id PK, name UK, group_id FK, created_at
entries
id PK, student_name, group_id FK, description, photo_path, deleted_at, created_at
project_files
id PK, entry_id FK (nullable), token UK, path, name, created_at, detached_at
group_photos
id PK, group_id FK, photo_path, caption, taken_at, created_at
share_links
id PK, token UK, name, group_id FK, student_name, date_from, date_to,
anonymize_names, expires_at, access_password_hash, created_at
settings
key PK, value
audit_log
id PK, action, target JSONB, ip, created_at
branches
id PK, name UK, address, phone, created_at
```
---
## 5. API Surface (Key Endpoints)
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/api/entries` | Admin | List entries (filters, pagination) |
| POST | `/api/entries` | Admin | Create entry (photo + files) |
| PUT | `/api/entries/:id` | Admin | Update entry |
| DELETE | `/api/entries/:id` | Admin | Soft delete |
| POST | `/api/entries/:id/restore` | Admin | Restore from trash |
| GET | `/api/files` | Admin | All files (filters) |
| GET | `/api/files/detached` | Admin | Detached files |
| POST | `/api/files/:id/detach` | Admin | Detach file |
| GET | `/api/files/:token` | Public | Download file by token |
| GET | `/api/groups` | Public | List groups |
| POST/PUT/DELETE | `/api/groups` | Admin | CRUD groups |
| GET/POST | `/api/groups/:id/photos` | Admin | Group photo chronicle |
| 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 |
| POST | `/api/restore` | Admin | Upload & restore backup |
| GET | `/api/dashboard` | Admin | Dashboard data |
| GET | `/api/stats` | Admin | Stats cards |
| GET | `/api/audit` | Admin | Audit log |
---
## 6. User Flows
### 6.1 Teacher Creates Attendance Entry
1. Opens `/journal.html`
2. Fills form: student name (typeahead from existing), group, description
3. Adds photo (optional) + project files (optional)
4. Submits → entry appears in list, files accessible by token
### 6.2 Admin Publishes Showcase for Parents
1. Opens `/links.html`
2. Creates share link: selects group, date range, sets password
3. Copies link `https://whatido.tailnet.ts.net/s/abc123`
4. Sends to parents → they open, enter password, view entries + photos
### 6.3 Admin Restores from Backup
1. Opens `/settings.html` → Backups tab
2. Uploads `.tar.gz` backup file
3. Confirms → all data replaced, sequences reset, orphans cleaned
---
## 7. Deployment Architecture
```
Internet / Tailnet
│
▼
┌──────────────────┐
│ Tailscale │ (network_mode: host, port 443)
│ Funnel/Serve │ TLS: Let's Encrypt (*.ts.net)
└────────┬─────────┘
│ HTTPS (trusts app self-signed cert)
▼
┌──────────────────┐
│ App (Node.js) │ 127.0.0.1:3443 (HTTPS), :3003 (HTTP→HTTPS redirect)
│ Express + pg │
└────────┬─────────┘
│
▼
┌──────────────────┐
│ PostgreSQL 16 │ Internal docker network only
│ (named volume) │
└──────────────────┘
Host filesystem:
./uploads ──────► /app/uploads (bind mount)
./certs ──────► /etc/tailscale/app-certs (ro)
```
---
## 8. Configuration
| Variable | Required | Description |
|----------|----------|-------------|
| `ADMIN_PASSWORD` | Yes | Admin token value (no default, server refuses start) |
| `DB_PASSWORD` | Yes | Postgres `app` user password |
---
## 9. Release Criteria
- [ ] All Must-have requirements implemented and tested
- [ ] Docker Compose starts cleanly on fresh host (`docker compose up -d --build`)
- [ ] Tailscale Funnel publishes successfully (manual verification)
- [ ] Backup/restore roundtrip works (data + files intact)
- [ ] No critical security findings (rate limits, upload validation, auth)
- [ ] README.md updated with accurate setup instructions
---
## 10. Future Considerations (Not in Scope v1)
- Multi-user auth (teachers with own logins)
- Email/push notifications
- Mobile app / PWA
- Rich text editor for descriptions
- Bulk import students (CSV)
- Webhooks for external integrations
- Automated scheduled backups to S3/remote
- Role-based access (read-only vs admin)
---
## 11. Acceptance Test Scenarios
| Scenario | Steps | Expected |
|----------|-------|----------|
| Fresh deploy | `cp .env.example .env` → edit → `docker compose up -d --build` | App healthy, DB migrated, HTTPS on 3443 |
| Create entry | POST `/api/entries` with photo + 2 files | Entry created, files downloadable by token |
| Share link | Create link with password → open `/s/token` → enter password | Entries filtered, photos shown, files download |
| Backup/restore | Download backup → delete entry → restore → verify entry back | Full state restored, sequences correct |
| Tailscale publish | `tailscale up` → enable Funnel → `tailscale funnel` | Public URL accessible via HTTPS |
| Upload rejection | POST `.html` file → 400 error | Dangerous extensions blocked |
| Rate limit | 11 rapid POST `/api/entries` → 429 on 11th | Entry limiter enforced |
---
+2 -2
View File
@@ -43,7 +43,7 @@ docker compose up -d --build
После старта (без публикации через tailscale):
- **HTTP** `http://localhost:3000` — редирект на HTTPS
- **HTTP** `http://localhost:3003` — редирект на HTTPS
- **HTTPS** `https://localhost:3443` — приложение (самоподписанный сертификат, примите предупреждение браузера)
- **PostgreSQL** — доступен только внутри docker-сети (наружу не публикуется)
@@ -154,7 +154,7 @@ docker exec whatido-tailscale-1 tailscale funnel status
### Важные замечания
- **Порт 443 на хосте должен быть свободен** — tailscale слушает его напрямую (поэтому у сервиса `network_mode: host`, а приложение опубликовано на `127.0.0.1:3000/3443`).
- **Порт 443 на хосте должен быть свободен** — tailscale слушает его напрямую (поэтому у сервиса `network_mode: host`, а приложение опубликовано на `127.0.0.1:3003/3443`).
- **Сертификат приложения**: контейнер приложения генерирует self-signed `cert.pem` при сборке образа (это не секрет — публичный сертификат). Он монтируется в tailscale через `./certs/cert.pem`. Если образ приложения пересобирали впервые на новом хосте — скопируйте сертификат и перезапустите tailscale:
```bash
+249
View File
@@ -0,0 +1,249 @@
# Аудит безопасности и антиспама — WhatIDo
> Приложение предназначено для публичного развёртывания. Ниже — результаты аудита
> по уровню важности: 🔴 критично (исправить обязательно), 🟠 высокий приоритет,
> 🟡 средний приоритет, 🔵 замечания по антиспаму.
---
## 🔴 КРИТИЧНО (исправить обязательно перед публикацией)
### 1. Админ-токен в открытом виде + уязвимость по времени
**Файл:** `server.js:52-56`
```js
function requireAdmin(req, res, next) {
const token = req.headers['x-admin-token'];
if (token !== ADMIN_PASSWORD) return res.status(401).json({ error: 'Unauthorized' });
next();
}
```
- Токен сравнивается через `===` — **уязвим к атакам по времени** (timing attack).
- Нет хеширования (bcrypt/argon2) — при утечке `.env` или логов токен сразу компрометирован.
- Реальный пароль лежит в `.env` на диске.
**Рекомендация:** использовать `crypto.timingSafeEqual` и хранить bcrypt-хеш.
---
### 2. Файлы доступны анонимно по токену
**Файлы:** `server.js:877-885`, `server.js:49`
- `GET /api/files/:token` и статика `/uploads/*` отдают любой файл любому, кто знает токен.
- Токены криптостойкие (32 hex), но **фото детей и учебные проекты не должны быть публично доступны по угадываемому ключу**.
- Share-ссылки (`/api/share/:token`) также выдают все файлы записи.
**Рекомендация:** отдавать файлы только в контексте действующей share-ссылки (проверка принадлежности entry к ссылке) либо подписанные URL с TTL.
---
### 3. Публичные share-ссылки раскрывают ПИД (персональные данные)
**Файл:** `server.js:480-531`
- `/api/share/:token` возвращает: имена учеников, фото, описания, файлы проектов.
- Нет срока действия ссылки (`expires_at`).
- Юридический риск (152-ФЗ, GDPR) — данные детей в открытую.
**Рекомендация:** добавить `expires_at` в `share_links`, опцию анонимизации имён, требование пароля к ссылке.
---
## 🟠 ВЫСОКИЙ ПРИОРИТЕТ
### 4. Нет IP-based rate limit на публичный POST
**Файл:** `server.js:969`
- Только интервал по имени студента (`spam_interval_min`, дефолт 30 мин).
- Бот может менять имена — ограничение обходится.
- Нет лимита по IP — легко завалить сервер или перебрать `/api/files/:token`.
**Рекомендация:** добавить `express-rate-limit` по IP на `POST /api/entries`, `GET /api/share/:token`, `GET /api/files/:token`.
---
### 5. Нет honeypot / CAPTCHA
**Файл:** `public/index.html:305`
- Форма отправки полностью открыта для ботов.
- Скрытое поле-ловушка (honeypot) остановит 90% простых ботов.
**Рекомендация:** добавить `<input name="website" style="display:none" tabindex="-1" autocomplete="off">` и проверку на сервере.
---
### 6. Restore бэкапа загружает 300 МБ в память
**Файл:** `server.js:191-194`
```js
const uploadBackup = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 300 * 1024 * 1024 },
});
```
- `memoryStorage()` — под нагрузкой DoS (OOM killer).
- Архив не проверяется на содержимое до распаковки.
**Рекомендация:** использовать `diskStorage` во временную директорию, лимит 50 МБ.
---
### 7. CSP отключён
**Файл:** `server.js:47`
```js
app.use(helmet({ contentSecurityPolicy: false }));
```
- Весь фронтенд на inline-скриптах и атрибутных обработчиках — строгий CSP их заблокирует.
- Без CSP — риск XSS через инъекции в ошибки/настройки.
**Рекомендация:** рефакторинг фронтенда на внешние JS-файлы → включить CSP.
---
### 8. Сравнение токена без timing-safe
**Файл:** `server.js:54`
- `token !== ADMIN_PASSWORD` — уязвим к timing attack.
**Рекомендация:** `crypto.timingSafeEqual(Buffer.from(token), Buffer.from(ADMIN_PASSWORD))`.
---
### 9. Поле `files` принимает любые расширения
**Файл:** `server.js:83-93`
- `fileFilter` проверяет только `photo` (image MIME).
- Поле `files` принимает **что угодно** — `.html`, `.js`, `.svg` (SVG может содержать JS).
- Имена генерируются случайно, но при угадывании токена — вредоносный файл отдаётся как есть.
**Рекомендация:** добавить тот же блок-лист расширений для `files`, отдавать как `download` (не inline).
---
### 10. Нет лимита на размер JSON-body
**Файл:** `server.js:48`
```js
app.use(express.json());
```
- Нет `limit` — можно слать огромные JSON, забивать память.
**Рекомендация:** `express.json({ limit: '1mb' })`.
---
## 🟡 СРЕДНИЙ ПРИОРИТЕТ
| # | Проблема | Файл/Место |
|---|----------|------------|
| 11 | Share-ссылки никогда не истекают | `db/init.sql:32-41` — нет `expires_at` |
| 12 | Нет аудит-лога админ-действий | — |
| 13 | Стектрейсы утекают в non-production | `server.js:1155-1157` |
| 14 | Нет HSTS / secure cookies / принудительного HTTPS | `server.js:1169-1180` |
| 15 | Имена учеников в URL параметрах share | `public/share.html:105` |
| 16 | Отсутствует валидация `spam_interval_min` ≥ 1 | `server.js:1004` — можно поставить 0 и отключить антиспам |
---
## 🔵 АНТИСПАМ — ПРОБЕЛЫ
| Мера | Статус | Что нужно |
|------|--------|-----------|
| IP-based rate limit | ❌ | `rateLimit` по IP на `/api/entries` |
| Honeypot поле | ❌ | Скрытый input в форме + проверка сервером |
| CAPTCHA / Turnstile | ❌ | Опционально: Cloudflare Turnstile |
| Мин. интервал спама | ⚠️ Можно 0 | Валидация: минимум 1 минута |
| Квота суммарного объёма на IP/день | ❌ | Добавить (напр. 100 МБ/день) |
| Лимит файлов на запрос | ✅ 10 файлов | Оставить |
| Суммарный размер на запрос | ✅ 30 МБ | Оставить |
---
## ✅ УЖЕ ИСПРАВЛЕНО (подтверждено в текущем коде)
- ❌ Убран фолбэк-пароль `'admin'` — старт невозможен без `ADMIN_PASSWORD`
- ❌ CORS полностью удалён
- ❌ Порт БД 5432 не опубликован, креды из `.env`
- ❌ `escapeHtml` исправлен (`&`)
- ❌ `express-rate-limit` на API роутах
- ❌ Валидация restore-данных + безопасный `safeUnlink` (path traversal защита)
- ❌ `helmet` + security-заголовки (кроме CSP)
- ❌ Блок-лист расширений загрузки + лимит 30 МБ/запись
- ❌ Параметризованные запросы (нет SQL-инъекций)
- ❌ Токены файлов криптостойкие (16 байт hex)
- ❌ Файлы не перезаписываются (случайные имена)
- ❌ Multer-лимиты на размеры есть
---
## 📋 ПЛАН ДЕЙСТВИЙ (must-do перед публикацией)
### Фаза 1 — Критично (до публичного запуска)
1. **Хеш админ-токена** (bcrypt) + `crypto.timingSafeEqual` для сравнения
2. **Защита файлов** — отдача только в контексте валидной share-ссылки или подписанные URL
3. **IP rate limit** на `POST /api/entries` (10 req / 15 мин на IP)
4. **Honeypot** в публичной форме
5. **Backup restore на диск** (diskStorage, лимит 50 МБ)
6. **Мин. spam_interval_min = 1** (валидация в settings)
### Фаза 2 — Укрепление (высокий приоритет)
7. **CSP** — рефакторинг inline-скриптов → внешние файлы
8. **HSTS** через reverse-proxy (Caddy/nginx)
9. **Блок-лист расширений для `files`** + отдача как download
10. **JSON body limit** (`1mb`)
11. **`expires_at` для share_links** + опция анонимизации
12. **Аудит-лог** админ-действий (логин, CRUD, backup/restore)
### Фаза 3 — Приватность и соответствие
13. **Анонимизация share-ссылок** (опция скрыть имена)
14. **Политика хранения** — автоудаление старых записей
15. **Privacy notice** на публичной форме
---
## БЫСТРЫЕ ПОБЕДЫ (можно внедрить сегодня)
```javascript
// 1. Timing-safe админ-проверка (server.js:52-56)
const crypto = require('crypto');
function requireAdmin(req, res, next) {
const token = req.headers['x-admin-token'];
const expected = Buffer.from(ADMIN_PASSWORD);
const provided = Buffer.from(token || '');
if (provided.length !== expected.length || !crypto.timingSafeEqual(provided, expected)) {
return res.status(401).json({ error: 'Unauthorized' });
}
next();
}
// 2. IP rate limit на публичную запись (server.js:969)
const entryIpLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 10,
keyGenerator: req => req.ip,
message: { error: 'Too many submissions from this IP' }
});
app.post('/api/entries', entryIpLimiter, entryLimiter, ...);
// 3. Honeypot в форме (index.html) — скрытое поле
// <input type="text" name="website" tabindex="-1" autocomplete="off" style="display:none">
// В хендлере: if (req.body.website) return res.status(400).json({ error: 'Spam detected' });
// 4. JSON body limit (server.js:48)
app.use(express.json({ limit: '1mb' }));
// 5. Блок-лист для project files (server.js:83-93)
const BLOCKED_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;
if (file.fieldname === 'files' && ext && BLOCKED_EXT.test(ext)) return cb(new Error('Not allowed extension'));
```
---
## Файлы для доработки (приоритет)
| Файл | Что править |
|------|-------------|
| `server.js:52-56` | timing-safe compare, bcrypt-хеш |
| `server.js:969` | IP rate limiter + honeypot проверка |
| `server.js:191-194` | diskStorage для backup restore |
| `server.js:83-93` | блок-лист для `files` |
| `server.js:48` | `express.json({ limit: '1mb' })` |
| `server.js:1004` | валидация `spam_interval_min >= 1` |
| `public/index.html` | honeypot input |
| `db/init.sql` | добавить `expires_at` в `share_links` |
| `docker-compose.yml` | раскомментировать Caddy для TLS |
---
*Аудит выполнен: 2026-09-07*
*Статус: готово к внедрению Фазы 1*
+5 -10
View File
@@ -20,11 +20,11 @@ services:
app:
build: .
expose:
- "3000"
- "3003"
- "3443"
ports:
- "127.0.0.1:3000:3000"
- "127.0.0.1:3443:3443"
- "3003:3003"
- "3443:3443"
environment:
DATABASE_URL: postgres://app:${DB_PASSWORD}@db:5432/whereldo
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
@@ -43,7 +43,6 @@ services:
hostname: whatido
restart: unless-stopped
network_mode: host
entrypoint: ["/bin/sh", "-c"]
cap_add:
- NET_ADMIN
- SYS_MODULE
@@ -54,12 +53,8 @@ services:
- /dev/net/tun:/dev/net/tun
- /lib/modules:/lib/modules:ro
- ./certs:/etc/tailscale/app-certs:ro
command: >
"tailscaled &
sleep 6 &&
tailscale up --hostname=whatido --accept-dns=false &&
tailscale funnel --bg --yes https://127.0.0.1:3443 &&
sleep infinity"
- ./start-tailscale.sh:/start-tailscale.sh:ro
command: ["/bin/sh", "/start-tailscale.sh"]
depends_on:
- app
+3
View File
@@ -14,5 +14,8 @@
"tar": "^7.4.3",
"bcrypt": "^5.1.1",
"heic-convert": "^2.1.0"
},
"allowScripts": {
"bcrypt@5.1.1": true
}
}
+4
View File
@@ -196,6 +196,10 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
.photo-item .p-actions button{background:none;border:none;cursor:pointer;font-size:1rem;color:var(--muted);padding:4px}
.photo-item .p-actions .p-edit:hover{color:var(--accent)}
.photo-item .p-actions .p-del:hover{color:#ef4444}
.photo-item .p-actions .p-cover:hover{color:#8b5cf6}
.photo-item .p-actions .p-cover.active{color:#8b5cf6}
.photo-item .p-actions .p-cover:disabled{cursor:default;opacity:.75}
.photo-item .p-actions .p-cover:disabled:hover{color:var(--muted)}
.edit-modal input[type=file]{padding:8px;cursor:pointer}
.edit-modal input[type=file]::file-selector-button{background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:6px;padding:6px 12px;font-size:.8rem;font-weight:600;cursor:pointer;margin-right:10px;transition:border-color .15s}
.edit-modal input[type=file]::file-selector-button:hover{border-color:var(--accent)}
+1
View File
@@ -28,6 +28,7 @@ const NAV = [
{ page: 'links', label: 'Ссылки' },
{ page: 'students', label: 'Ученики' },
{ page: 'groups', label: 'Группы' },
{ page: 'branches', label: 'Филиалы' },
{ page: 'audit', label: 'Аудит' },
{ page: 'settings', label: 'Настройки' }
];
+3
View File
@@ -69,6 +69,9 @@ function fmtTime(t) {
function dt(t) {
if (!t) return '';
if (typeof t === 'object') {
return esc(JSON.stringify(t).slice(0, 120));
}
try {
const o = JSON.parse(t);
return esc(JSON.stringify(o).slice(0, 120));
+253
View File
@@ -0,0 +1,253 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Филиалы — Админ-панель</title>
<link rel="stylesheet" href="admin.css">
</head>
<body data-page="branches">
<div class="layout">
<div class="sidebar" id="sidebar"></div>
<div class="main">
<div class="page-head">
<h2>Филиалы</h2>
<p class="page-sub">Управление филиалами и привязка групп</p>
</div>
<div class="actions-row" style="margin-bottom:16px">
<button class="btn-primary" onclick="openBranchModal()" id="addBranchBtn">
<span style="font-size:1.2rem">+</span> Добавить филиал
</button>
</div>
<div class="branches-grid" id="branchesGrid">
<div class="empty">Загрузка…</div>
</div>
</div>
</div>
<!-- Branch Modal -->
<div class="modal-overlay" id="branchModal" onclick="if(event.target===this)closeBranchModal()">
<div class="edit-modal" style="max-width:480px">
<h3 id="branchModalTitle">Создать филиал</h3>
<form id="branchForm" class="settings-stack" style="gap:12px">
<input type="hidden" name="id" id="branchId">
<div class="settings-row">
<div class="settings-field" style="flex:1">
<label>Название <span style="color:#ef4444">*</span></label>
<input type="text" name="name" class="settings-input" required placeholder="Например: Центральный офис" autocomplete="off">
</div>
</div>
<div class="settings-row">
<div class="settings-field" style="flex:1">
<label>Адрес</label>
<input type="text" name="address" class="settings-input" placeholder="Улица, дом, офис">
</div>
</div>
<div class="settings-row">
<div class="settings-field" style="flex:1">
<label>Телефон</label>
<input type="text" name="phone" class="settings-input" placeholder="+7 (XXX) XXX-XX-XX">
</div>
</div>
<div class="card-foot" style="justify-content:flex-end;gap:8px">
<button type="button" class="btn-primary ghost" onclick="closeBranchModal()">Отмена</button>
<button type="submit" class="btn-primary" id="branchSubmitBtn">Создать</button>
</div>
</form>
</div>
</div>
<div class="modal-overlay" id="imgModal" onclick="this.classList.remove('open')">
<img id="imgModalSrc" alt="">
</div>
<div class="toast" id="toast"></div>
<script src="admin.js"></script>
<script>
let branches = [];
async function loadBranches() {
const res = await fetch(`${API}/api/branches`, { headers: hdr() });
if (!res.ok) return;
branches = await res.json();
renderBranches();
}
function renderBranches() {
const el = document.getElementById('branchesGrid');
if (!branches.length) {
el.innerHTML = '<div class="empty" style="grid-column:1/-1">Филиалов пока нет. Нажмите «Добавить филиал».</div>';
return;
}
el.innerHTML = branches.map(b => `
<div class="branch-card" data-id="${b.id}">
<div class="branch-header">
<span class="branch-name">${esc(b.name)}</span>
<span class="branch-badge">${b.groups_count} ${plural(b.groups_count, 'группа', 'группы', 'групп')}</span>
</div>
${b.address ? `<div class="branch-meta">📍 ${esc(b.address)}</div>` : ''}
${b.phone ? `<div class="branch-meta">📞 ${esc(b.phone)}</div>` : ''}
<div class="branch-actions">
<button class="branch-edit" onclick="openBranchModal(${b.id})" title="Редактировать">✎</button>
${b.groups_count === 0 ? `<button class="branch-delete" onclick="deleteBranch(${b.id})" title="Удалить">&times;</button>` : ''}
</div>
</div>
`).join('');
}
function plural(n, one, few, many) {
n = Math.abs(n) % 100; const n1 = n % 10;
if (n > 10 && n < 20) return many;
if (n1 > 1 && n1 < 5) return few;
if (n1 === 1) return one;
return many;
}
function openBranchModal(id = null) {
const modal = document.getElementById('branchModal');
const form = document.getElementById('branchForm');
const title = document.getElementById('branchModalTitle');
const submitBtn = document.getElementById('branchSubmitBtn');
form.reset();
document.getElementById('branchId').value = '';
if (id) {
const b = branches.find(x => x.id === id);
if (!b) return;
title.textContent = 'Редактировать филиал';
submitBtn.textContent = 'Сохранить';
form.name.value = b.name;
form.address.value = b.address || '';
form.phone.value = b.phone || '';
document.getElementById('branchId').value = b.id;
} else {
title.textContent = 'Создать филиал';
submitBtn.textContent = 'Создать';
}
modal.classList.add('open');
setTimeout(() => form.name.focus(), 100);
}
function closeBranchModal() {
document.getElementById('branchModal').classList.remove('open');
}
document.getElementById('branchForm').addEventListener('submit', async (e) => {
e.preventDefault();
const fd = new FormData(e.target);
const id = fd.get('id');
const name = fd.get('name').trim();
const address = fd.get('address').trim();
const phone = fd.get('phone').trim();
if (!name) { alert('Название обязательно'); return; }
const url = id ? `${API}/api/branches/${id}` : `${API}/api/branches`;
const method = id ? 'PUT' : 'POST';
const res = await fetch(url, {
method,
headers: hdrJson(),
body: JSON.stringify({ name, address: address || null, phone: phone || null })
});
if (res.ok) {
showToast(id ? 'Филиал обновлён' : 'Филиал создан');
closeBranchModal();
loadBranches();
} else { const err = await res.json(); alert(err.error || 'Ошибка'); }
});
async function deleteBranch(id) {
if (!confirm('Удалить этот филиал?')) return;
const res = await fetch(`${API}/api/branches/${id}`, { method: 'DELETE', headers: hdr() });
if (res.ok) {
showToast('Филиал удалён');
loadBranches();
} else { const err = await res.json(); alert(err.error || 'Ошибка'); }
}
(async () => {
if (await checkAuth()) {
buildSidebar(document.body.dataset.page);
loadBranches();
}
})();
</script>
<style>
.branches-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 12px;
}
.branch-card {
background: var(--card);
border: 1px solid var(--border);
border-radius: 12px;
padding: 16px;
display: flex;
flex-direction: column;
gap: 8px;
transition: box-shadow .15s, border-color .15s;
}
.branch-card:hover {
box-shadow: 0 4px 16px rgba(0,0,0,.08);
border-color: var(--accent);
}
.branch-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.branch-name {
font-weight: 600;
font-size: .95rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.branch-badge {
font-size: .7rem;
font-weight: 600;
color: var(--accent);
background: rgba(37,99,235,.1);
padding: 2px 8px;
border-radius: 999px;
white-space: nowrap;
flex-shrink: 0;
}
.branch-meta {
font-size: .78rem;
color: var(--muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.branch-actions {
display: flex;
justify-content: flex-end;
gap: 6px;
margin-top: 4px;
}
.branch-actions button {
background: none;
border: none;
color: var(--muted);
cursor: pointer;
font-size: 1rem;
padding: 6px 8px;
border-radius: 6px;
transition: background .15s, color .15s;
}
.branch-actions button:hover {
background: var(--bg);
color: var(--text);
}
.branch-actions .branch-delete:hover {
color: #ef4444;
background: rgba(239,68,68,.1);
}
@media (max-width: 640px) {
.branches-grid { grid-template-columns: 1fr; }
}
</style>
</body>
</html>
+63 -11
View File
@@ -35,25 +35,37 @@
</div>
<div class="modal-overlay" id="photoFormModal" onclick="if(event.target===this)closePhotoForm()">
<div class="edit-modal">
<div class="edit-modal" style="max-width:480px">
<h3 id="photoFormTitle">Добавить фото</h3>
<div>
<label>Выбрать файл (jpg/png и т.п.)</label>
<form id="photoForm" class="settings-stack" style="gap:12px">
<div class="settings-row">
<div class="settings-field" style="flex:1">
<label>Выбрать файл (jpg/png и т.п.) <span style="color:#ef4444">*</span></label>
<input type="file" id="photoFile" accept="image/*">
</div>
</div>
<div class="settings-row">
<div class="settings-field" style="flex:1">
<div id="photoFormPreview" class="photo-preview" style="display:none"><img id="photoPreviewImg" alt=""></div>
<div>
</div>
</div>
<div class="settings-row">
<div class="settings-field" style="flex:1">
<label>Подпись</label>
<textarea id="photoCaption" placeholder="Например: выступление, тренировка..."></textarea>
<textarea id="photoCaption" class="settings-input" placeholder="Например: выступление, тренировка..."></textarea>
</div>
<div>
</div>
<div class="settings-row">
<div class="settings-field" style="flex:1">
<label>Дата съёмки</label>
<input type="date" id="photoTakenAt">
<input type="date" id="photoTakenAt" class="settings-input">
</div>
<div class="actions">
<button class="cancel" onclick="closePhotoForm()">Отмена</button>
<button class="save" onclick="savePhotoForm()">Сохранить</button>
</div>
<div class="card-foot" style="justify-content:flex-end;gap:8px">
<button type="button" class="btn-primary ghost" onclick="closePhotoForm()">Отмена</button>
<button type="submit" class="btn-primary">Сохранить</button>
</div>
</form>
</div>
</div>
@@ -64,6 +76,8 @@
// --- Groups ---
const DAYS = ['Вс', 'Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб'];
let branchesCache = [];
let allGroups = [];
let currentCover = null;
async function loadBranchesCache() {
const res = await fetch(`${API}/api/branches`, { headers: hdr() });
@@ -79,6 +93,7 @@ async function loadGroups() {
await loadBranchesCache();
const res = await fetch(`${API}/api/groups`, { headers: hdr() });
const groups = await res.json();
allGroups = groups;
const list = document.getElementById('groupList');
if (!groups.length) { list.innerHTML = '<div style="color:var(--muted);text-align:center;padding:32px">Нет групп</div>'; return; }
list.innerHTML = `<div class="group-grid">` + groups.map(g => {
@@ -173,6 +188,8 @@ let editingPhotoId = null;
async function openPhotos(groupId, groupName) {
currentGroupId = groupId;
currentGroupName = groupName;
const g = allGroups.find(x => String(x.id) === String(groupId));
currentCover = g ? g.cover_path : null;
photoPage = 1;
editingPhotoId = null;
document.getElementById('photosTitle').textContent = `Фото — ${groupName}`;
@@ -207,12 +224,14 @@ function fmtPhotoDate(d) { return d ? d.split('-').reverse().join('.') : ''; }
function photoHTML(p) {
const taken = p.taken_at ? `Дата: ${fmtPhotoDate(p.taken_at)}` : '';
const uploaded = `Загружено: ${new Date(p.created_at).toLocaleDateString('ru')}`;
const isCover = currentCover && p.photo_path === currentCover;
return `
<div class="photo-item">
<div class="photo-item" data-photo-id="${p.id}" data-photo-path="${esc(p.photo_path)}">
<img src="${API}${p.photo_path}" onclick="showImg('${API}${p.photo_path}')" alt="">
<div class="p-cap">${esc(p.caption || '')}</div>
<div class="p-date">${taken ? taken + ' · ' : ''}${uploaded}</div>
<div class="p-actions">
<button class="p-cover${isCover ? ' active' : ''}" data-photo-id="${p.id}" data-photo-path="${esc(p.photo_path)}" title="${isCover ? 'Текущая обложка' : 'Сделать обложкой'}" ${isCover ? 'disabled' : ''}>🖼️</button>
<button class="p-edit" onclick="openEditPhoto(${p.id})" title="Редактировать">✎</button>
<button class="p-del" onclick="deletePhoto(${p.id})" title="Удалить">&times;</button>
</div>
@@ -335,6 +354,10 @@ async function exportGroupUrls(groupId, groupName) {
document.getElementById('newGroup').addEventListener('keydown', e => { if (e.key === 'Enter') addGroup(); });
document.getElementById('photoFile').addEventListener('change', previewPhotoFile);
document.getElementById('photoForm').addEventListener('submit', e => {
e.preventDefault();
savePhotoForm();
});
// Event delegation for group cover clicks
document.getElementById('groupList').addEventListener('click', e => {
@@ -346,6 +369,35 @@ document.getElementById('groupList').addEventListener('click', e => {
}
});
// Event delegation for photo actions in modal
document.getElementById('photoGrid').addEventListener('click', async e => {
const coverBtn = e.target.closest('.p-cover');
if (coverBtn) {
const photoId = parseInt(coverBtn.dataset.photoId, 10);
const photoPath = coverBtn.dataset.photoPath;
if (!isNaN(photoId)) {
try {
const res = await fetch(`${API}/api/groups/${currentGroupId}/photos/${photoId}/cover`, {
method: 'PUT',
headers: hdrJson(),
});
if (res.ok) {
const g = await res.json();
currentCover = g.cover_path || null;
showToast('Обложка обновлена');
loadGroupPhotos();
loadGroups();
} else {
const err = await res.json();
alert(err.error || 'Ошибка');
}
} catch (err) {
alert('Ошибка сети: ' + err.message);
}
}
}
});
async function populateNewGroupBranch() {
const res = await fetch(`${API}/api/branches`, { headers: hdr() });
if (res.ok) {
+71 -15
View File
@@ -36,25 +36,40 @@
</div>
<div class="modal-overlay" id="editModal" onclick="if(event.target===this)closeEdit()">
<div class="edit-modal">
<div class="edit-modal" style="max-width:480px">
<h3>Редактирование записи</h3>
<div>
<label>Фамилия и имя</label>
<input type="text" id="editName" list="studentEditList">
<form id="editForm" class="settings-stack" style="gap:12px">
<div class="settings-row">
<div class="settings-field" style="flex:1">
<label>Фамилия и имя <span style="color:#ef4444">*</span></label>
<input type="text" id="editName" class="settings-input" list="studentEditList" required autocomplete="off">
<datalist id="studentEditList"></datalist>
</div>
<div>
<label>Группа</label>
<select id="editGroup"></select>
</div>
<div>
<label>Что делала(а)</label>
<textarea id="editDesc"></textarea>
<div class="settings-row">
<div class="settings-field" style="flex:1">
<label>Группа <span style="color:#ef4444">*</span></label>
<select id="editGroup" class="settings-input" required></select>
</div>
<div class="actions">
<button class="cancel" onclick="closeEdit()">Отмена</button>
<button class="save" onclick="saveEdit()">Сохранить</button>
</div>
<div class="settings-row">
<div class="settings-field" style="flex:1">
<label>Что делала(а) <span style="color:#ef4444">*</span></label>
<textarea id="editDesc" class="settings-input" required></textarea>
</div>
</div>
<div class="settings-row">
<div class="settings-field" style="flex:1">
<label>Прикрепить файлы</label>
<input type="file" id="editFiles" multiple accept=".pdf,.doc,.docx,.txt,.md,.html,.htm,.zip,.rar,.7z,.jpg,.jpeg,.png,.gif,.webp">
<div class="files-preview" id="editFilesPreview"></div>
</div>
</div>
<div class="card-foot" style="justify-content:flex-end;gap:8px">
<button type="button" class="btn-primary ghost" onclick="closeEdit()">Отмена</button>
<button type="submit" class="btn-primary">Сохранить</button>
</div>
</form>
</div>
</div>
@@ -297,6 +312,8 @@ async function openEdit(id) {
function closeEdit() {
document.getElementById('editModal').classList.remove('open');
document.getElementById('editFiles').value = '';
document.getElementById('editFilesPreview').innerHTML = '';
editId = null;
}
@@ -311,11 +328,27 @@ async function saveEdit() {
headers: hdrJson(),
body: JSON.stringify({ student_name: name, group_id, description })
});
if (res.ok) {
if (!res.ok) { const err = await res.json(); alert(err.error); return; }
const files = document.getElementById('editFiles').files;
if (files.length) {
const formData = new FormData();
for (const f of files) formData.append('files', f);
const uploadRes = await fetch(`${API}/api/entries/${editId}/files`, {
method: 'POST',
headers: { 'X-Admin-Token': token },
body: formData
});
if (!uploadRes.ok) {
const err = await uploadRes.json().catch(() => ({}));
alert(err.error || 'Ошибка загрузки файлов');
return;
}
}
closeEdit();
loadEntries();
showToast('Запись обновлена');
} else { const err = await res.json(); alert(err.error); }
}
async function delEntry(id) {
@@ -333,6 +366,29 @@ document.getElementById('searchInput').addEventListener('input', () => {
clearTimeout(searchTimer);
searchTimer = setTimeout(() => { page = 1; loadEntries(); }, 300);
});
document.getElementById('editForm').addEventListener('submit', (e) => {
e.preventDefault();
saveEdit();
});
document.getElementById('editFiles').addEventListener('change', (e) => {
const preview = document.getElementById('editFilesPreview');
preview.innerHTML = '';
Array.from(e.target.files).forEach((f, i) => {
const div = document.createElement('div');
div.className = 'file-preview-item';
div.style.cssText = 'display:flex;align-items:center;gap:8px;padding:6px 8px;background:var(--bg);border:1px solid var(--border);border-radius:6px;font-size:.8rem';
div.innerHTML = `<span>${esc(f.name)}</span> <span style="color:var(--muted)">${(f.size/1024).toFixed(1)} KB</span> <button type="button" onclick="removeEditFile(${i})" style="background:none;border:none;color:var(--muted);cursor:pointer;font-size:1.1rem;line-height:1">✕</button>`;
preview.appendChild(div);
});
});
function removeEditFile(index) {
const input = document.getElementById('editFiles');
const dt = new DataTransfer();
Array.from(input.files).forEach((f, i) => { if (i !== index) dt.items.add(f); });
input.files = dt.files;
input.dispatchEvent(new Event('change'));
}
async function exportCSV() {
const p = new URLSearchParams();
+38 -18
View File
@@ -30,39 +30,51 @@
</div>
<div class="modal-overlay" id="batchModal" onclick="if(event.target===this)closeBatchAdd()">
<div class="edit-modal">
<div class="edit-modal" style="max-width:480px">
<h3>Пакетное добавление учеников</h3>
<div>
<label>Имена — каждое с новой строки</label>
<textarea id="batchText" placeholder="Иванов Иван&#10;Петров Пётр&#10;Сидоров Сидор"></textarea>
<form id="batchForm" class="settings-stack" style="gap:12px">
<div class="settings-row">
<div class="settings-field" style="flex:1">
<label>Имена — каждое с новой строки <span style="color:#ef4444">*</span></label>
<textarea id="batchText" class="settings-input" style="min-height:120px" required placeholder="Иванов Иван&#10;Петров Пётр&#10;Сидоров Сидор"></textarea>
</div>
<div>
</div>
<div class="settings-row">
<div class="settings-field" style="flex:1">
<label>Группа (необязательно)</label>
<select id="batchGroup"><option value="">— без группы —</option></select>
<select id="batchGroup" class="settings-input"><option value="">— без группы —</option></select>
</div>
<div class="actions">
<button class="cancel" onclick="closeBatchAdd()">Отмена</button>
<button class="save" onclick="batchAdd()">Добавить</button>
</div>
<div class="card-foot" style="justify-content:flex-end;gap:8px">
<button type="button" class="btn-primary ghost" onclick="closeBatchAdd()">Отмена</button>
<button type="submit" class="btn-primary">Добавить</button>
</div>
</form>
</div>
</div>
<div class="modal-overlay" id="attachModal" onclick="if(event.target===this)closeBatchAttach()">
<div class="edit-modal">
<div class="edit-modal" style="max-width:480px">
<h3>Прикрепить к группе</h3>
<div>
<label>Группа</label>
<select id="attachGroup"><option value="">— выберите группу —</option></select>
<form id="attachForm" class="settings-stack" style="gap:12px">
<div class="settings-row">
<div class="settings-field" style="flex:1">
<label>Группа <span style="color:#ef4444">*</span></label>
<select id="attachGroup" class="settings-input" required><option value="">— выберите группу —</option></select>
</div>
<div>
</div>
<div class="settings-row">
<div class="settings-field" style="flex:1">
<label>Ученики</label>
<input type="text" id="attachSearch" class="search-input" placeholder="Поиск по имени..." style="width:100%;margin-bottom:8px">
<input type="text" id="attachSearch" class="settings-input" placeholder="Поиск по имени..." style="margin-bottom:8px">
<div class="attach-list" id="attachList"></div>
</div>
<div class="actions">
<button class="cancel" onclick="closeBatchAttach()">Отмена</button>
<button class="save" onclick="batchAttach()">Прикрепить</button>
</div>
<div class="card-foot" style="justify-content:flex-end;gap:8px">
<button type="button" class="btn-primary ghost" onclick="closeBatchAttach()">Отмена</button>
<button type="submit" class="btn-primary">Прикрепить</button>
</div>
</form>
</div>
</div>
@@ -188,6 +200,10 @@ async function batchAdd() {
}
document.getElementById('newStudent').addEventListener('keydown', e => { if (e.key === 'Enter') addStudent(); });
document.getElementById('batchForm').addEventListener('submit', e => {
e.preventDefault();
batchAdd();
});
document.getElementById('studentSearch').addEventListener('input', () => {
clearTimeout(sSearchTimer);
sSearchTimer = setTimeout(() => { sPage = 1; renderStudents(); }, 300);
@@ -278,6 +294,10 @@ async function batchAttach() {
} else { const e = await res.json(); alert(e.error); }
}
document.getElementById('attachForm').addEventListener('submit', e => {
e.preventDefault();
batchAttach();
});
document.getElementById('attachSearch').addEventListener('input', () => {
clearTimeout(aSearchTimer);
aSearchTimer = setTimeout(renderAttachList, 300);
+12
View File
@@ -1002,6 +1002,18 @@ app.delete('/api/groups/:id/photos/:photoId', requireAdmin, async (req, res) =>
res.json({ ok: true });
});
app.put('/api/groups/:id/photos/:photoId/cover', requireAdmin, async (req, res) => {
const { rows } = await pool.query(
'SELECT photo_path FROM group_photos WHERE id = $1 AND group_id = $2',
[req.params.photoId, req.params.id]
);
if (!rows.length) return res.status(404).json({ error: 'Не найдено' });
await pool.query('UPDATE groups SET cover_path = $1 WHERE id = $2', [rows[0].photo_path, req.params.id]);
await logAudit(req, 'group.photo.set_cover', { group_id: req.params.id, photo_id: req.params.photoId });
const { rows: gRows } = await pool.query('SELECT * FROM groups WHERE id = $1', [req.params.id]);
res.json(gRows[0]);
});
// --- Students CRUD ---
app.get('/api/students', apiLimiter, async (_, res) => {
const { rows } = await pool.query(
+19
View File
@@ -0,0 +1,19 @@
#!/bin/sh
set -e
tailscaled &
echo "Waiting for tailscaled..."
sleep 5
until tailscale status > /dev/null 2>&1; do
sleep 1
done
tailscale up --hostname=whatido --accept-dns=false
echo "Tailscale is up, waiting for network to settle..."
sleep 5
tailscale funnel --bg --yes http://127.0.0.1:3003
echo "Funnel is ready!"
sleep infinity