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
This commit is contained in:
@@ -22,3 +22,6 @@ node_modules/
|
||||
tmp/
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# --- Internal audit (not for commit) ---
|
||||
SECURITY_AUDIT.md
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 # схема при первом запуске
|
||||
|
||||
+34
-4
@@ -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:
|
||||
|
||||
+2
-1
@@ -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"
|
||||
|
||||
@@ -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, '"').replace(/'/g, ''');
|
||||
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function renderErrorPage(code, title, message, details) {
|
||||
|
||||
Reference in New Issue
Block a user