From 6844d659fce9d5444017e2e57082dc0e31240f8c Mon Sep 17 00:00:00 2001 From: dev Date: Mon, 7 Sep 2026 11:27:04 +0300 Subject: [PATCH] harden security and add public TLS scaffold - require ADMIN_PASSWORD (no default), remove CORS - close public DB port, move DB credentials to .env (DB_PASSWORD) - fix HTML escaping, add helmet + sec headers (no CSP due to inline scripts) - rate limit public routes by IP (express-rate-limit) - validate restore data and confine file unlinking to uploads/ - block dangerous upload extensions, 30MB per-entry limit, SVG not served inline - return 400 on unknown group_id in POST /api/entries - add commented Caddy/Let's Encrypt reverse-proxy scaffold + Caddyfile.example - update README --- .gitignore | 3 + Caddyfile.example | 20 ++++ README.md | 46 +++++++-- docker-compose.yml | 38 +++++++- package.json | 3 +- server.js | 228 ++++++++++++++++++++++++++++++++++++++++----- 6 files changed, 301 insertions(+), 37 deletions(-) create mode 100644 Caddyfile.example diff --git a/.gitignore b/.gitignore index 028ad03..ce242a7 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,6 @@ node_modules/ tmp/ .DS_Store Thumbs.db + +# --- Internal audit (not for commit) --- +SECURITY_AUDIT.md diff --git a/Caddyfile.example b/Caddyfile.example new file mode 100644 index 0000000..ddc4af5 --- /dev/null +++ b/Caddyfile.example @@ -0,0 +1,20 @@ +# Пример конфигурации Caddy для публичного развёртывания WhatIDo. +# +# Порядок включения: +# 1. Скопируйте этот файл в ./Caddyfile (cp Caddyfile.example Caddyfile) +# 2. Замените yourdomain.example на реальный домен/WWW, указывающий на сервер +# 3. В docker-compose.yml расскомментируйте сервис caddy (и тома caddy_data/caddy_config) +# и переведите блок ports сервиса app в expose (см. комментарии в compose) +# 4. docker compose up -d --build +# +# Caddy автоматически получит Let's Encrypt сертификат на 80/443 портах. +# Запрос к app идёт по HTTPS на внутренний порт 3443 (самоподписанный серт +# приложения, поэтому tls_insecure_skip_verify). + +yourdomain.example { + reverse_proxy app:3443 { + transport http { + tls_insecure_skip_verify + } + } +} \ No newline at end of file diff --git a/README.md b/README.md index 4084e40..d779da4 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ - **Дашборд** — статистика, активные группы, активность за 14 дней, последние записи, топ воспитанников - **Резервное копирование** — экспорт/импорт полного дампа (БД + файлы) в `tar.gz` - **Настройки** — научные тексты футера, анти-спам интервал -- **HTTPS** — самоподписанный TLS-сертификат, автогенерация при сборке +- **HTTPS** — самоподписанный TLS-сертификат по умолчанию + готовая заготовка reverse-proxy (Caddy / Let's Encrypt) для публичного запуска ## Технологии @@ -30,7 +30,7 @@ ### Запуск ```bash -# создайте .env с паролем администратора (см. раздел «Конфигурация») +# создайте .env с паролем администратора и БД (см. раздел «Конфигурация») docker compose up -d --build ``` @@ -38,7 +38,7 @@ docker compose up -d --build - **HTTP** `http://localhost:3000` — редирект на HTTPS - **HTTPS** `https://localhost:3443` — приложение (самоподписанный сертификат, принимайте предупреждение браузера) -- **PostgreSQL** `localhost:5432` — `app:app`, база `whereldo` +- **PostgreSQL** — доступен только внутри docker-сети (порт 5432 наружу не публикуется) Управление: @@ -48,16 +48,26 @@ docker compose logs -f app docker compose down # остановка (данные сохраняются) ``` +> Приложение **не запустится** без `ADMIN_PASSWORD` (защита от пароля по умолчанию). +> `DB_PASSWORD` задаёт пароль пользователя `app` в PostgreSQL. + ## Конфигурация Переменные окружения (`.env`): | Переменная | По умолчанию | Назначение | |------------------|--------------|-------------------------------------| -| `ADMIN_PASSWORD` | `admin` | Пароль администратора (X-Admin-Token) | -| `DATABASE_URL` | см. compose | Строка подключения к PostgreSQL | +| `ADMIN_PASSWORD` | — (обязательно) | Пароль администратора (X-Admin-Token). Без него сервер не стартует | +| `DB_PASSWORD` | — (обязательно) | Пароль пользователя `app` в PostgreSQL | -Внутри контейнера `db` также задаются `POSTGRES_DB=whereldo`, `POSTGRES_USER=app`, `POSTGRES_PASSWORD=app`, `TZ=Europe/Moscow`. +Пример `.env`: + +``` +ADMIN_PASSWORD=сложный-пароль +DB_PASSWORD=случайная-строка +``` + +`DB_PASSWORD` подставляется в `docker-compose.yml` в `POSTGRES_PASSWORD` и `DATABASE_URL`. Если БД уже была инициализирована ранее, значение `DB_PASSWORD` должно совпадать с фактическим паролем пользователя `app` в БД (иначе приложение не подключится). ## Структура данных @@ -89,6 +99,27 @@ docker compose down # остановка (данные сохраняютс ./scripts/restore.sh # восстановление из архива ``` +## Безопасность + +- **Пароль администратора** обязателен (`ADMIN_PASSWORD`); фолбэка на `admin` нет. +- **CORS отключён** — кросс-доменные запросы к API запрещены. +- **Rate limiting** по IP на публичные роуты: `POST /api/entries` — 10 запросов / 15 мин, загрузка файлов и share-ссылки — 300 / 15 мин. +- **Загрузки** ограничены: 30 МБ суммарно на запись, 10 МБ на файл; заблокированы опасные расширения (`.html`, `.js`, `.svg`, `.xml`, `.exe` и др.); SVG иным способом не отдаётся inline. +- **Restore** проходит полную валидацию данных бэкапа; удаление файлов ограничено каталогом `uploads/`. +- **Заголовки**: `helmet` — `X-Frame-Options`, `nosniff`, HSTS, `Referrer-Policy`. +- **Порт БД** 5432 наружу не публикуется (доступ только внутри docker-сети). +- **TLS**: по умолчанию самоподписанный сертификат. Для публикации включите reverse-proxy Caddy с Let's Encrypt — заготовка закомментирована в `docker-compose.yml`, конфиг в `Caddyfile.example`. + +### Публичный запуск (TLS) + +```bash +cp Caddyfile.example Caddyfile # подставить реальный домен +# расскомментировать сервис caddy и перевести ports сервиса app в expose (следуйте комментариям в docker-compose.yml) +docker compose up -d --build +``` + +Caddy автоматически получит Let's Encrypt сертификат на 80/443. + ## Основные API | Метод | Путь | Назначение | @@ -113,8 +144,9 @@ docker compose down # остановка (данные сохраняютс ## Структура проекта ``` -├── docker-compose.yml # сервисы app + db +├── docker-compose.yml # сервисы app + db (+ закомментированный caddy) ├── Dockerfile # сборка образа (Node 20, генерация TLS-сертификата) +├── Caddyfile.example # шаблон reverse-proxy с Let's Encrypt (домен заменить) ├── server.js # Express-приложение ├── db/ │ ├── init.sql # схема при первом запуске diff --git a/docker-compose.yml b/docker-compose.yml index a155b38..431fdfb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,13 +6,11 @@ services: environment: POSTGRES_DB: whereldo POSTGRES_USER: app - POSTGRES_PASSWORD: app + POSTGRES_PASSWORD: ${DB_PASSWORD} TZ: Europe/Moscow volumes: - pgdata:/var/lib/postgresql/data - ./db/init.sql:/docker-entrypoint-initdb.d/init.sql - ports: - - "5432:5432" healthcheck: test: ["CMD-SHELL", "pg_isready -U app -d whereldo"] interval: 2s @@ -21,11 +19,12 @@ services: app: build: . + # --- Публикация портов через reverse-proxy (Caddy), см. сервис caddy ниже --- ports: - "3000:3000" - "3443:3443" environment: - DATABASE_URL: postgres://app:app@db:5432/whereldo + DATABASE_URL: postgres://app:${DB_PASSWORD}@db:5432/whereldo ADMIN_PASSWORD: ${ADMIN_PASSWORD} TZ: Europe/Moscow depends_on: @@ -34,5 +33,36 @@ services: volumes: - ./uploads:/app/uploads + # --- Настоящий TLS (Let's Encrypt) перед публичным запуском --- + # + # Расскомментируйте сервис caddy, скопируйте Caddyfile.example в Caddyfile, + # замените yourdomain.example на реальный домен и в app замените блок ports: + # + # expose: + # - "3000" + # - "3443" + # ports: + # - "127.0.0.1:3000:3000" + # - "127.0.0.1:3443:3443" (и удалить внешние "3000:3000" / "3443:3443") + # + # Затем: docker compose up -d --build + # + # caddy: + # image: caddy:2-alpine + # restart: unless-stopped + # ports: + # - "80:80" + # - "443:443" + # environment: + # DOMAIN: yourdomain.example # ЗАМЕНИТЕ на реальный домен + # volumes: + # - ./Caddyfile:/etc/caddy/Caddyfile:ro + # - caddy_data:/data + # - caddy_config:/config + # depends_on: + # - app + volumes: pgdata: + # caddy_data: + # caddy_config: diff --git a/package.json b/package.json index d41b4d2..a4c28e4 100644 --- a/package.json +++ b/package.json @@ -6,8 +6,9 @@ "start": "node server.js" }, "dependencies": { - "cors": "^2.8.5", "express": "^4.21.0", + "express-rate-limit": "^8.7.0", + "helmet": "^8.3.0", "multer": "^1.4.5-lts.1", "pg": "^8.13.0", "tar": "^7.4.3" diff --git a/server.js b/server.js index 9620508..4e666a4 100644 --- a/server.js +++ b/server.js @@ -1,7 +1,9 @@ const express = require('express'); const { Pool, types } = require('pg'); const multer = require('multer'); -const cors = require('cors'); +const rateLimit = require('express-rate-limit'); +const helmet = require('helmet'); + const https = require('https'); const path = require('path'); const fs = require('fs'); @@ -12,9 +14,37 @@ types.setTypeParser(1082, v => v); const app = express(); const pool = new Pool({ connectionString: process.env.DATABASE_URL }); -const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'admin'; +const apiLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, + max: 300, + standardHeaders: true, + legacyHeaders: false, + message: { error: 'Слишком много запросов. Попробуйте позже.' }, +}); -app.use(cors()); +const entryLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, + max: 10, + standardHeaders: true, + legacyHeaders: false, + message: { error: 'Слишком много запросов. Подождите немного.' }, +}); + +const fileLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, + max: 300, + standardHeaders: true, + legacyHeaders: false, + message: { error: 'Слишком много запросов. Попробуйте позже.' }, +}); + +const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD; +if (!ADMIN_PASSWORD) { + console.error('FATAL: ADMIN_PASSWORD environment variable is not set. Refusing to start.'); + process.exit(1); +} + +app.use(helmet({ contentSecurityPolicy: false })); app.use(express.json()); app.use('/uploads', express.static(path.join(__dirname, 'uploads'))); app.use(express.static(path.join(__dirname, 'public'))); @@ -33,6 +63,10 @@ function fixFilename(str) { } } +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; +const ALLOWED_IMAGE_EXT = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.avif', '.ico']); +const MAX_TOTAL_UPLOAD_BYTES = 30 * 1024 * 1024; + const upload = multer({ storage: multer.diskStorage({ destination: (_, __, cb) => { @@ -48,8 +82,14 @@ const upload = multer({ limits: { fileSize: 10 * 1024 * 1024 }, fileFilter: (req, file, cb) => { file.originalname = fixFilename(file.originalname); - if (file.fieldname === 'photo' && !file.mimetype.startsWith('image/')) cb(new Error('Only images')); - else cb(null, true); + const ext = path.extname(file.originalname).toLowerCase(); + if (file.fieldname === 'photo') { + if (!file.mimetype || !file.mimetype.startsWith('image/') || !ALLOWED_IMAGE_EXT.has(ext)) { + return cb(new Error('Only images')); + } + } + if (ext && BLOCKED_EXT.test(ext)) return cb(new Error('Not allowed extension')); + cb(null, true); }, }); @@ -58,9 +98,15 @@ async function getSetting(key, def) { return rows.length ? rows[0].value : def; } +const UPLOADS_DIR = path.join(__dirname, 'uploads'); + function safeUnlink(relPath) { - if (!relPath) return; - const fp = path.join(__dirname, String(relPath).replace(/^\/+/, '')); + if (!relPath || typeof relPath !== 'string') return; + const parts = String(relPath).replace(/\\/g, '/').replace(/^\/+/, '').split('/'); + if (parts[0] === 'uploads') parts.shift(); + if (!parts.length || parts.includes('..') || parts.includes('')) return; + const fp = path.resolve(UPLOADS_DIR, ...parts); + if (fp === UPLOADS_DIR || !fp.startsWith(UPLOADS_DIR + path.sep)) return; if (fs.existsSync(fp)) fs.unlinkSync(fp); } @@ -105,7 +151,7 @@ app.get('/api/settings', requireAdmin, async (_, res) => { res.json(out); }); -app.get('/api/public-settings', async (_, res) => { +app.get('/api/public-settings', apiLimiter, async (_, res) => { const keys = ['footer_left', 'footer_right']; const out = {}; for (const k of keys) out[k] = await getSetting(k, ''); @@ -149,6 +195,104 @@ const uploadBackup = multer({ const SAFE_NAME = /^[\w,.()-]+$/; +function isSafeUploadPath(p) { + if (typeof p !== 'string' || !p.startsWith('/uploads/')) return false; + const name = p.slice('/uploads/'.length); + return name !== '' && !name.includes('/') && !name.includes('..') && SAFE_NAME.test(name); +} + +function reqInt(v) { + const n = Number(v); + if (!Number.isInteger(n)) throw new Error('Invalid integer'); + return n; +} + +function optInt(v, lo = -Infinity, hi = Infinity) { + if (v === null || v === undefined || v === '') return null; + const n = Number(v); + if (!Number.isInteger(n) || n < lo || n > hi) throw new Error('Invalid integer'); + return n; +} + +function reqStr(v, max) { + if (typeof v !== 'string') throw new Error('Invalid string'); + const s = v.trim(); + if (!s || s.length > max) throw new Error('Invalid string length'); + return s; +} + +function optStr(v, max) { + if (v === null || v === undefined) return null; + return reqStr(v, max); +} + +function optTs(v) { + if (v === null || v === undefined) return null; + if (typeof v !== 'string' || !/^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}/.test(v)) throw new Error('Invalid timestamp'); + return v; +} + +function optTime(v) { + if (v === null || v === undefined) return null; + if (typeof v !== 'string' || !/^\d{2}:\d{2}(:\d{2})?$/.test(v)) throw new Error('Invalid time'); + return v; +} + +function reqToken(v) { + if (typeof v !== 'string' || !/^[0-9a-f]{16,64}$/.test(v)) throw new Error('Invalid token'); + return v; +} + +function reqUploadPath(v, max) { + if (typeof v !== 'string' || v.length > max) throw new Error('Invalid path'); + if (!isSafeUploadPath(v)) throw new Error('Invalid upload path'); + return v; +} + +function optUploadPath(v, max) { + if (v === null || v === undefined) return null; + return reqUploadPath(v, max); +} + +function normalizeRestoreData(data) { + const groups = (data.groups || []).map(x => ({ + id: reqInt(x.id), + name: reqStr(x.name, 100), + created_at: optTs(x.created_at), + day_of_week: optInt(x.day_of_week, 0, 6), + time_start: optTime(x.time_start), + time_end: optTime(x.time_end), + })); + const students = (data.students || []).map(x => ({ + id: reqInt(x.id), + name: reqStr(x.name, 150), + created_at: optTs(x.created_at), + group_id: optInt(x.group_id, 0, 2147483647), + })); + const entries = (data.entries || []).map(x => ({ + id: reqInt(x.id), + student_name: reqStr(x.student_name, 150), + group_id: reqInt(x.group_id), + description: reqStr(x.description, 100000), + photo_path: optUploadPath(x.photo_path, 255), + deleted_at: optTs(x.deleted_at), + created_at: optTs(x.created_at), + })); + const project_files = (data.project_files || []).map(x => ({ + id: reqInt(x.id), + entry_id: optInt(x.entry_id, 0, 2147483647), + token: reqToken(x.token), + path: reqUploadPath(x.path, 255), + name: reqStr(x.name, 255), + created_at: optTs(x.created_at), + })); + const settings = {}; + for (const [k, v] of Object.entries(data.settings || {})) { + settings[reqStr(k, 100)] = reqStr(String(v), 10000); + } + return { groups, students, entries, project_files, settings }; +} + app.get('/api/backup', requireAdmin, async (_, res) => { const staging = fs.mkdtempSync(path.join(os.tmpdir(), 'wido-bk-')); try { @@ -211,37 +355,44 @@ app.post('/api/restore', requireAdmin, uploadBackup.single('backup'), async (req fs.rmSync(staging, { recursive: true, force: true }); return res.status(400).json({ error: 'Неверный формат бэкапа' }); } + let ndata; + try { + ndata = normalizeRestoreData(data); + } catch (e) { + fs.rmSync(staging, { recursive: true, force: true }); + return res.status(400).json({ error: 'Неверный формат бэкапа: ' + e.message }); + } const client = await pool.connect(); try { await client.query('BEGIN'); await client.query('DELETE FROM entries'); await client.query('DELETE FROM students'); await client.query('DELETE FROM groups'); - for (const x of data.groups) { + for (const x of ndata.groups) { await client.query( 'INSERT INTO groups (id, name, created_at, day_of_week, time_start, time_end) VALUES ($1,$2,$3,$4,$5,$6)', - [x.id, x.name, x.created_at, x.day_of_week ?? null, x.time_start ?? null, x.time_end ?? null] + [x.id, x.name, x.created_at, x.day_of_week, x.time_start, x.time_end] ); } - for (const x of data.students) { + for (const x of ndata.students) { await client.query( 'INSERT INTO students (id, name, created_at, group_id) VALUES ($1,$2,$3,$4)', - [x.id, x.name, x.created_at, x.group_id ?? null] + [x.id, x.name, x.created_at, x.group_id] ); } - for (const x of data.entries) { + for (const x of ndata.entries) { await client.query( 'INSERT INTO entries (id, student_name, group_id, description, photo_path, deleted_at, created_at) VALUES ($1,$2,$3,$4,$5,$6,$7)', - [x.id, x.student_name, x.group_id, x.description, x.photo_path ?? null, x.deleted_at ?? null, x.created_at] + [x.id, x.student_name, x.group_id, x.description, x.photo_path, x.deleted_at, x.created_at] ); } - for (const x of data.project_files || []) { + for (const x of ndata.project_files) { await client.query( 'INSERT INTO project_files (id, entry_id, token, path, name, created_at) VALUES ($1,$2,$3,$4,$5,$6)', [x.id, x.entry_id, x.token, x.path, x.name, x.created_at] ); } - for (const [k, v] of Object.entries(data.settings || {})) { + for (const [k, v] of Object.entries(ndata.settings)) { await client.query( 'INSERT INTO settings (key, value) VALUES ($1,$2) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value', [k, String(v ?? '')] @@ -326,7 +477,7 @@ app.delete('/api/links/:id', requireAdmin, async (req, res) => { res.json({ ok: true }); }); -app.get('/api/share/:token', async (req, res) => { +app.get('/api/share/:token', fileLimiter, async (req, res) => { const { rows } = await pool.query( `SELECT l.*, g.name AS group_name FROM share_links l LEFT JOIN groups g ON g.id = l.group_id WHERE l.token = $1`, @@ -384,7 +535,7 @@ app.get('/s/:token', (req, res) => { }); // --- Groups CRUD --- -app.get('/api/groups', async (_, res) => { +app.get('/api/groups', apiLimiter, async (_, res) => { const { rows } = await pool.query( `SELECT g.*, gp.photo_path AS cover_path FROM groups g @@ -398,7 +549,7 @@ app.get('/api/groups', async (_, res) => { res.json(rows); }); -app.get('/api/groups/active', async (_, res) => { +app.get('/api/groups/active', apiLimiter, async (_, res) => { const { rows } = await pool.query(` SELECT * FROM groups WHERE day_of_week IS NOT NULL @@ -516,7 +667,7 @@ app.delete('/api/groups/:id/photos/:photoId', requireAdmin, async (req, res) => }); // --- Students CRUD --- -app.get('/api/students', async (_, res) => { +app.get('/api/students', apiLimiter, async (_, res) => { const { rows } = await pool.query( `SELECT s.*, g.name AS group_name FROM students s LEFT JOIN groups g ON g.id = s.group_id ORDER BY s.name` @@ -640,7 +791,7 @@ app.get('/api/entries/:id/files', requireAdmin, async (req, res) => { }); function isImageName(name) { - return /\.(jpe?g|png|gif|webp|bmp|avif|svg|ico)$/i.test(name || ''); + return /\.(jpe?g|png|gif|webp|bmp|avif|ico)$/i.test(name || ''); } app.get('/api/files', requireAdmin, async (req, res) => { @@ -723,7 +874,7 @@ app.delete('/api/files/:id', requireAdmin, async (req, res) => { res.json({ ok: true }); }); -app.get('/api/files/:token', async (req, res) => { +app.get('/api/files/:token', fileLimiter, async (req, res) => { const { rows } = await pool.query('SELECT path, name FROM project_files WHERE token = $1', [req.params.token]); if (!rows.length) return res.status(404).json({ error: 'Not found' }); const r = rows[0]; @@ -814,7 +965,16 @@ app.get('/api/dashboard', requireAdmin, async (req, res) => { }); }); -app.post('/api/entries', upload.fields([{ name: 'photo', maxCount: 1 }, { name: 'files', maxCount: 10 }]), async (req, res) => { +const entryFields = upload.fields([{ name: 'photo', maxCount: 1 }, { name: 'files', maxCount: 10 }]); +app.post('/api/entries', entryLimiter, (req, res, next) => { + entryFields(req, res, (err) => { + if (!err) return next(); + if (err.code === 'LIMIT_FILE_SIZE') return res.status(400).json({ error: 'Файл слишком большой (макс. 10 МБ)' }); + if (err.message === 'Only images') return res.status(400).json({ error: 'Фото: допустимы только изображения (jpg, png, gif, webp, bmp, avif, ico)' }); + if (err.message === 'Not allowed extension') return res.status(400).json({ error: 'Недопустимый тип файла (*.html, *.js, *.svg и т.п. запрещены)' }); + return res.status(400).json({ error: 'Недопустимый файл' }); + }); +}, async (req, res) => { const { student_name, group_id, description } = req.body; const photo = req.files?.photo?.[0] || null; const projectFiles = req.files?.files || []; @@ -823,6 +983,24 @@ app.post('/api/entries', upload.fields([{ name: 'photo', maxCount: 1 }, { name: projectFiles.forEach(removeUpload); return res.status(400).json({ error: 'All fields required' }); } + const totalBytes = (photo?.size || 0) + projectFiles.reduce((s, f) => s + (f.size || 0), 0); + if (totalBytes > MAX_TOTAL_UPLOAD_BYTES) { + removeUpload(photo); + projectFiles.forEach(removeUpload); + return res.status(400).json({ error: 'Суммарный размер файлов слишком велик (макс. 30 МБ)' }); + } + const gid = Number.parseInt(group_id, 10); + if (!Number.isInteger(gid)) { + removeUpload(photo); + projectFiles.forEach(removeUpload); + return res.status(400).json({ error: 'Группа не найдена' }); + } + const grpCheck = await pool.query('SELECT id FROM groups WHERE id = $1', [gid]); + if (!grpCheck.rows.length) { + removeUpload(photo); + projectFiles.forEach(removeUpload); + return res.status(400).json({ error: 'Группа не найдена' }); + } const intervalMin = parseInt(await getSetting('spam_interval_min', '30'), 10) || 0; if (intervalMin > 0) { const dup = await pool.query( @@ -846,7 +1024,7 @@ app.post('/api/entries', upload.fields([{ name: 'photo', maxCount: 1 }, { name: const { rows } = await client.query( `INSERT INTO entries (student_name, group_id, description, photo_path) VALUES ($1, $2, $3, $4) RETURNING *`, - [student_name.trim(), group_id, description.trim(), photo_path] + [student_name.trim(), gid, description.trim(), photo_path] ); for (const f of projectFiles) { const token = crypto.randomBytes(16).toString('hex'); @@ -950,7 +1128,7 @@ function isApiRoute(req) { } function escapeHtml(str) { - return String(str).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); + return String(str).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); } function renderErrorPage(code, title, message, details) {