feat(uploads): лимиты загрузки в env, 50 МБ на файл и 200 МБ на запись
Лимиты были захардкожены в четырёх местах фронтенда и в константах multer, из-за чего расходились с текстами ошибок на сервере. - UPLOAD_FILE_LIMIT_MB (50) и UPLOAD_TOTAL_LIMIT_MB (200) читаются из env; оба multer-конфига (upload, adminUpload) берут fileSize из них, тексты ошибок собираются из тех же констант вместо литералов - UPLOAD_REQUEST_TIMEOUT_MS снимает дефолт Node в 5 минут: считается как UPLOAD_TOTAL_LIMIT_MB * 7500, иначе 200 МБ по мобильной сети не успевают - GET /api/public-settings отдаёт upload_file_limit_mb / upload_total_limit_mb, фронтенд читает их вместо собственных констант Проверено на живом стеке: 20 МБ и 180 МБ суммарно принимаются, 55 МБ и 225 МБ отклоняются с верными сообщениями, скачивание 45 МБ из S3 совпадает по sha256 с оригиналом, api.smoketest.js — 57 PASS / 0 FAIL.
This commit is contained in:
@@ -4,6 +4,18 @@ DB_PASSWORD=случайная-длинная-строка
|
|||||||
# Лимит размера загружаемого на восстановление бэкапа, МБ (по умолчанию 500)
|
# Лимит размера загружаемого на восстановление бэкапа, МБ (по умолчанию 500)
|
||||||
BACKUP_UPLOAD_LIMIT_MB=500
|
BACKUP_UPLOAD_LIMIT_MB=500
|
||||||
|
|
||||||
|
# === Лимиты загрузки файлов ===
|
||||||
|
# Максимальный размер одного файла, МБ (по умолчанию 50). Применяется ко всем
|
||||||
|
# загрузкам: файлы проекта и фото учеников, фото групп и учеников, логотип,
|
||||||
|
# фото модулей. Нельзя делать меньше UPLOAD_TOTAL_LIMIT_MB.
|
||||||
|
UPLOAD_FILE_LIMIT_MB=50
|
||||||
|
# Максимальный суммарный размер вложений одной записи, МБ (по умолчанию 200).
|
||||||
|
UPLOAD_TOTAL_LIMIT_MB=200
|
||||||
|
# Таймаут приёма запроса, мс. Пусто — считается автоматически
|
||||||
|
# (UPLOAD_TOTAL_LIMIT_MB * 7500, минимум 5 минут). Увеличьте, если ученики
|
||||||
|
# жалуются на обрыв загрузки на медленной мобильной связи.
|
||||||
|
UPLOAD_REQUEST_TIMEOUT_MS=
|
||||||
|
|
||||||
# === Redis (кэш, rate limit, баны IP, pub/sub) ===
|
# === Redis (кэш, rate limit, баны IP, pub/sub) ===
|
||||||
# Пароль Redis. Обязателен, если Redis включён в docker-compose.
|
# Пароль Redis. Обязателен, если Redis включён в docker-compose.
|
||||||
REDIS_PASSWORD=замените-на-длинный-секрет
|
REDIS_PASSWORD=замените-на-длинный-секрет
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ This document defines how AI agents should work with the WhatIDo codebase. Follo
|
|||||||
|
|
||||||
### 3. File Uploads
|
### 3. File Uploads
|
||||||
- **Multer configs**: `upload` (images only), `adminUpload` (wider allowed ext), `uploadBackup` (restore)
|
- **Multer configs**: `upload` (images only), `adminUpload` (wider allowed ext), `uploadBackup` (restore)
|
||||||
- **Limits**: 10 MB/file, 30 MB total per entry
|
- **Limits**: `UPLOAD_FILE_LIMIT_MB` per file (default 50), `UPLOAD_TOTAL_LIMIT_MB` per entry (default 200) — both env-driven; `UPLOAD_REQUEST_TIMEOUT_MS` overrides the auto-computed request timeout. The frontend reads the two MB values from `GET /api/public-settings` (`upload_file_limit_mb`, `upload_total_limit_mb`) — do not hardcode them again in `public/js/index.js`
|
||||||
- **Staging**: Multer always writes to `uploads/` (`timestamp-random.ext`); a global `res.on('finish')` hook persists each uploaded file through `storage.persist` on successful responses (only when `STORAGE_DRIVER=s3`)
|
- **Staging**: Multer always writes to `uploads/` (`timestamp-random.ext`); a global `res.on('finish')` hook persists each uploaded file through `storage.persist` on successful responses (only when `STORAGE_DRIVER=s3`)
|
||||||
- **HEIC**: Auto-converted to JPEG via `heic-convert`
|
- **HEIC**: Auto-converted to JPEG via `heic-convert`
|
||||||
- **Cleanup**: `safeUnlink` / `sweepOrphanedUploads` — never delete outside `uploads/` or the configured bucket
|
- **Cleanup**: `safeUnlink` / `sweepOrphanedUploads` — never delete outside `uploads/` or the configured bucket
|
||||||
|
|||||||
@@ -46,7 +46,7 @@
|
|||||||
| ENT-5 | Trash view: list deleted entries, restore, permanent delete | Must |
|
| ENT-5 | Trash view: list deleted entries, restore, permanent delete | Must |
|
||||||
| ENT-6 | Pagination (limit/offset) + total count | 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-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 |
|
| ENT-8 | Files attached to entry: upload (max 10 files, per-file and total size limits from `UPLOAD_FILE_LIMIT_MB`/`UPLOAD_TOTAL_LIMIT_MB`), download by token | Must |
|
||||||
|
|
||||||
### 2.4 Files Management (Centralized)
|
### 2.4 Files Management (Centralized)
|
||||||
| ID | Requirement | Priority |
|
| ID | Requirement | Priority |
|
||||||
|
|||||||
@@ -527,7 +527,7 @@ node api.smoketest.js # сквозная проверка API (нужен
|
|||||||
- **Пароль администратора** обязателен (`ADMIN_PASSWORD`) — он создаёт первого админа в пустой БД; фолбэка на `admin` нет. Самостоятельной роли в API не даёт: доступ только по сессиям.
|
- **Пароль администратора** обязателен (`ADMIN_PASSWORD`) — он создаёт первого админа в пустой БД; фолбэка на `admin` нет. Самостоятельной роли в API не даёт: доступ только по сессиям.
|
||||||
- **CORS отключён** — кросс-доменные запросы к API запрещены.
|
- **CORS отключён** — кросс-доменные запросы к API запрещены.
|
||||||
- **Rate limiting** по IP на публичные роуты: `POST /api/entries` — 10 запросов / 15 мин, загрузка файлов и share-ссылки — 300 / 15 мин.
|
- **Rate limiting** по IP на публичные роуты: `POST /api/entries` — 10 запросов / 15 мин, загрузка файлов и share-ссылки — 300 / 15 мин.
|
||||||
- **Загрузки** ограничены: 30 МБ суммарно на запись, 10 МБ на файл; заблокированы опасные расширения (`.html`, `.js`, `.svg`, `.xml`, `.exe` и др.); SVG не отдаётся inline.
|
- **Загрузки** ограничены: суммарно на запись и на файл — лимиты из `UPLOAD_TOTAL_LIMIT_MB` / `UPLOAD_FILE_LIMIT_MB` (по умолчанию 200 МБ и 50 МБ); заблокированы опасные расширения (`.html`, `.js`, `.svg`, `.xml`, `.exe` и др.); SVG не отдаётся inline.
|
||||||
- **Restore** проходит полную валидацию данных бэкапа; удаление файлов ограничено каталогом `uploads/`.
|
- **Restore** проходит полную валидацию данных бэкапа; удаление файлов ограничено каталогом `uploads/`.
|
||||||
- **Заголовки**: `helmet` — `X-Frame-Options`, `nosniff`, HSTS, `Referrer-Policy`.
|
- **Заголовки**: `helmet` — `X-Frame-Options`, `nosniff`, HSTS, `Referrer-Policy`.
|
||||||
- **Порт БД** 5432 наружу не публикуется (доступ только внутри docker-сети).
|
- **Порт БД** 5432 наружу не публикуется (доступ только внутри docker-сети).
|
||||||
|
|||||||
@@ -73,6 +73,9 @@ services:
|
|||||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
|
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
|
||||||
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
|
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
|
||||||
BACKUP_UPLOAD_LIMIT_MB: ${BACKUP_UPLOAD_LIMIT_MB:-500}
|
BACKUP_UPLOAD_LIMIT_MB: ${BACKUP_UPLOAD_LIMIT_MB:-500}
|
||||||
|
UPLOAD_FILE_LIMIT_MB: ${UPLOAD_FILE_LIMIT_MB:-50}
|
||||||
|
UPLOAD_TOTAL_LIMIT_MB: ${UPLOAD_TOTAL_LIMIT_MB:-200}
|
||||||
|
UPLOAD_REQUEST_TIMEOUT_MS: ${UPLOAD_REQUEST_TIMEOUT_MS:-}
|
||||||
AI_MODEL: ${AI_MODEL:-qwen2.5-1.5b-instruct-q4_k_m.gguf}
|
AI_MODEL: ${AI_MODEL:-qwen2.5-1.5b-instruct-q4_k_m.gguf}
|
||||||
AI_PROMPT: ${AI_PROMPT:-}
|
AI_PROMPT: ${AI_PROMPT:-}
|
||||||
AI_REQUEST_TIMEOUT_MS: ${AI_REQUEST_TIMEOUT_MS:-120000}
|
AI_REQUEST_TIMEOUT_MS: ${AI_REQUEST_TIMEOUT_MS:-120000}
|
||||||
|
|||||||
+19
-5
@@ -120,8 +120,10 @@ function clearFiles() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const MAX_FILES = 10;
|
const MAX_FILES = 10;
|
||||||
const MAX_FILE_BYTES = 10 * 1024 * 1024;
|
const DEFAULT_FILE_LIMIT_MB = 50;
|
||||||
const MAX_TOTAL_BYTES = 30 * 1024 * 1024;
|
const DEFAULT_TOTAL_LIMIT_MB = 200;
|
||||||
|
let MAX_FILE_BYTES = DEFAULT_FILE_LIMIT_MB * 1024 * 1024;
|
||||||
|
let MAX_TOTAL_BYTES = DEFAULT_TOTAL_LIMIT_MB * 1024 * 1024;
|
||||||
const BLOCKED_FILE_EXT = /\.(?:html?|js|mjs|cjs|svg|xml|json|map|wasm|php\d?|phtml|asp|aspx|jsp|sh|bat|cmd|cgi|exe|dll|com|msi|scr|hta|vbs|py|r|rb|htaccess)$/i;
|
const BLOCKED_FILE_EXT = /\.(?:html?|js|mjs|cjs|svg|xml|json|map|wasm|php\d?|phtml|asp|aspx|jsp|sh|bat|cmd|cgi|exe|dll|com|msi|scr|hta|vbs|py|r|rb|htaccess)$/i;
|
||||||
const MIME_EXT = {
|
const MIME_EXT = {
|
||||||
'image/png': '.png', 'image/jpeg': '.jpg', 'image/jpg': '.jpg', 'image/gif': '.gif', 'image/webp': '.webp',
|
'image/png': '.png', 'image/jpeg': '.jpg', 'image/jpg': '.jpg', 'image/gif': '.gif', 'image/webp': '.webp',
|
||||||
@@ -172,14 +174,25 @@ function totalUploadBytes() {
|
|||||||
return (capturedBlob ? capturedBlob.size : 0) + selectedFiles.reduce((sum, f) => sum + f.size, 0);
|
return (capturedBlob ? capturedBlob.size : 0) + selectedFiles.reduce((sum, f) => sum + f.size, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applyUploadLimits(fileMb, totalMb) {
|
||||||
|
const f = parseInt(fileMb, 10);
|
||||||
|
const t = parseInt(totalMb, 10);
|
||||||
|
const fileLimit = Number.isFinite(f) && f >= 1 ? f : DEFAULT_FILE_LIMIT_MB;
|
||||||
|
const totalLimit = Number.isFinite(t) && t >= fileLimit ? t : Math.max(DEFAULT_TOTAL_LIMIT_MB, fileLimit);
|
||||||
|
MAX_FILE_BYTES = fileLimit * 1024 * 1024;
|
||||||
|
MAX_TOTAL_BYTES = totalLimit * 1024 * 1024;
|
||||||
|
}
|
||||||
|
|
||||||
function addFiles(list) {
|
function addFiles(list) {
|
||||||
const added = [];
|
const added = [];
|
||||||
const skipped = [];
|
const skipped = [];
|
||||||
|
const fileLimitMb = Math.round(MAX_FILE_BYTES / 1024 / 1024);
|
||||||
|
const totalLimitMb = Math.round(MAX_TOTAL_BYTES / 1024 / 1024);
|
||||||
for (const file of list) {
|
for (const file of list) {
|
||||||
if (selectedFiles.length >= MAX_FILES) { skipped.push('Можно прикрепить не более 10 файлов'); break; }
|
if (selectedFiles.length >= MAX_FILES) { skipped.push(`Можно прикрепить не более ${MAX_FILES} файлов`); break; }
|
||||||
if (file.size > MAX_FILE_BYTES) { skipped.push(`«${file.name}» больше 10 МБ`); continue; }
|
if (file.size > MAX_FILE_BYTES) { skipped.push(`«${file.name}» больше ${fileLimitMb} МБ`); continue; }
|
||||||
if (BLOCKED_FILE_EXT.test(extOf(file.name))) { skipped.push(`«${file.name}»: недопустимый тип файла`); continue; }
|
if (BLOCKED_FILE_EXT.test(extOf(file.name))) { skipped.push(`«${file.name}»: недопустимый тип файла`); continue; }
|
||||||
if (totalUploadBytes() + file.size > MAX_TOTAL_BYTES) { skipped.push('Суммарный размер файлов — не более 30 МБ'); break; }
|
if (totalUploadBytes() + file.size > MAX_TOTAL_BYTES) { skipped.push(`Суммарный размер файлов — не более ${totalLimitMb} МБ`); break; }
|
||||||
selectedFiles.push(file);
|
selectedFiles.push(file);
|
||||||
added.push(file.name);
|
added.push(file.name);
|
||||||
}
|
}
|
||||||
@@ -319,6 +332,7 @@ async function resolveModuleId(name) {
|
|||||||
if (Number.isFinite(h) && h >= 120 && h <= 4096) captureHeight = h;
|
if (Number.isFinite(h) && h >= 120 && h <= 4096) captureHeight = h;
|
||||||
const q = parseFloat(f.photo_capture_quality);
|
const q = parseFloat(f.photo_capture_quality);
|
||||||
if (Number.isFinite(q) && q >= 0.5 && q <= 1) captureQuality = q;
|
if (Number.isFinite(q) && q >= 0.5 && q <= 1) captureQuality = q;
|
||||||
|
applyUploadLimits(f.upload_file_limit_mb, f.upload_total_limit_mb);
|
||||||
if (f.camera_enabled === 'false') {
|
if (f.camera_enabled === 'false') {
|
||||||
const camBtns = document.getElementById('camBtns');
|
const camBtns = document.getElementById('camBtns');
|
||||||
if (camBtns) camBtns.style.display = 'none';
|
if (camBtns) camBtns.style.display = 'none';
|
||||||
|
|||||||
+12
-1
@@ -3,6 +3,11 @@ let mPage = 1;
|
|||||||
const M_PAGE_SIZE = 25;
|
const M_PAGE_SIZE = 25;
|
||||||
let mSearchTimer = null;
|
let mSearchTimer = null;
|
||||||
let modulePhotoDeleteFlag = false;
|
let modulePhotoDeleteFlag = false;
|
||||||
|
let modulePhotoLimitMb = 50;
|
||||||
|
|
||||||
|
function modulePhotoHint() {
|
||||||
|
return `jpg, png, gif, webp, avif, heic и др., до ${modulePhotoLimitMb} МБ · или вставьте из буфера (Ctrl+V)`;
|
||||||
|
}
|
||||||
|
|
||||||
function mPlural(n, one, few, many) {
|
function mPlural(n, one, few, many) {
|
||||||
n = Math.abs(n) % 100; const n1 = n % 10;
|
n = Math.abs(n) % 100; const n1 = n % 10;
|
||||||
@@ -68,7 +73,7 @@ function resetModulePhoto(m) {
|
|||||||
preview.removeAttribute('src');
|
preview.removeAttribute('src');
|
||||||
preview.style.display = 'none';
|
preview.style.display = 'none';
|
||||||
delBtn.style.display = 'none';
|
delBtn.style.display = 'none';
|
||||||
note.textContent = 'jpg, png, gif, webp, avif, heic и др., до 10 МБ · или вставьте из буфера (Ctrl+V)';
|
note.textContent = modulePhotoHint();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -313,6 +318,12 @@ document.getElementById('moduleList').addEventListener('click', e => {
|
|||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
if (await requireAdminPage()) {
|
if (await requireAdminPage()) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API}/api/public-settings`);
|
||||||
|
const f = await res.json();
|
||||||
|
const n = parseInt(f.upload_file_limit_mb, 10);
|
||||||
|
if (Number.isFinite(n) && n >= 1) modulePhotoLimitMb = n;
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
buildSidebar(document.body.dataset.page);
|
buildSidebar(document.body.dataset.page);
|
||||||
loadModules();
|
loadModules();
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -52,7 +52,7 @@
|
|||||||
<input type="file" id="modulePhotoFile" accept="image/*" style="display:none">
|
<input type="file" id="modulePhotoFile" accept="image/*" style="display:none">
|
||||||
<button type="button" id="modulePhotoDeleteBtn" class="btn-link danger" style="display:none">Удалить</button>
|
<button type="button" id="modulePhotoDeleteBtn" class="btn-link danger" style="display:none">Удалить</button>
|
||||||
</div>
|
</div>
|
||||||
<span id="modulePhotoNote" class="hint">jpg, png, gif, webp, avif, heic и др., до 10 МБ · или вставьте из буфера (Ctrl+V)</span>
|
<span id="modulePhotoNote" class="hint">jpg, png, gif, webp, avif, heic и др. · или вставьте из буфера (Ctrl+V)</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-foot" style="justify-content:flex-end;gap:8px">
|
<div class="card-foot" style="justify-content:flex-end;gap:8px">
|
||||||
|
|||||||
@@ -973,7 +973,13 @@ 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 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', '.heic', '.heif', '.jfif']);
|
const ALLOWED_IMAGE_EXT = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.avif', '.ico', '.heic', '.heif', '.jfif']);
|
||||||
const MAX_TOTAL_UPLOAD_BYTES = 30 * 1024 * 1024;
|
const UPLOAD_FILE_LIMIT_MB = Math.max(1, parseInt(process.env.UPLOAD_FILE_LIMIT_MB || '50', 10) || 50);
|
||||||
|
const UPLOAD_TOTAL_LIMIT_MB = Math.max(UPLOAD_FILE_LIMIT_MB, parseInt(process.env.UPLOAD_TOTAL_LIMIT_MB || '200', 10) || 200);
|
||||||
|
const MAX_FILE_UPLOAD_BYTES = UPLOAD_FILE_LIMIT_MB * 1024 * 1024;
|
||||||
|
const MAX_TOTAL_UPLOAD_BYTES = UPLOAD_TOTAL_LIMIT_MB * 1024 * 1024;
|
||||||
|
const FILE_TOO_LARGE_ERROR = `Файл слишком большой (макс. ${UPLOAD_FILE_LIMIT_MB} МБ)`;
|
||||||
|
const TOTAL_TOO_LARGE_ERROR = `Суммарный размер файлов слишком велик (макс. ${UPLOAD_TOTAL_LIMIT_MB} МБ)`;
|
||||||
|
const UPLOAD_REQUEST_TIMEOUT_MS = Math.max(300000, parseInt(process.env.UPLOAD_REQUEST_TIMEOUT_MS || '0', 10) || UPLOAD_TOTAL_LIMIT_MB * 7500);
|
||||||
|
|
||||||
const upload = multer({
|
const upload = multer({
|
||||||
storage: multer.diskStorage({
|
storage: multer.diskStorage({
|
||||||
@@ -987,7 +993,7 @@ const upload = multer({
|
|||||||
cb(null, `${Date.now()}-${Math.random().toString(36).slice(2, 8)}${ext}`);
|
cb(null, `${Date.now()}-${Math.random().toString(36).slice(2, 8)}${ext}`);
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
limits: { fileSize: 10 * 1024 * 1024 },
|
limits: { fileSize: MAX_FILE_UPLOAD_BYTES },
|
||||||
fileFilter: (req, file, cb) => {
|
fileFilter: (req, file, cb) => {
|
||||||
file.originalname = fixFilename(file.originalname);
|
file.originalname = fixFilename(file.originalname);
|
||||||
const ext = path.extname(file.originalname).toLowerCase();
|
const ext = path.extname(file.originalname).toLowerCase();
|
||||||
@@ -1015,7 +1021,7 @@ const adminUpload = multer({
|
|||||||
cb(null, `${Date.now()}-${Math.random().toString(36).slice(2, 8)}${ext}`);
|
cb(null, `${Date.now()}-${Math.random().toString(36).slice(2, 8)}${ext}`);
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
limits: { fileSize: 10 * 1024 * 1024 },
|
limits: { fileSize: MAX_FILE_UPLOAD_BYTES },
|
||||||
fileFilter: (req, file, cb) => {
|
fileFilter: (req, file, cb) => {
|
||||||
file.originalname = fixFilename(file.originalname);
|
file.originalname = fixFilename(file.originalname);
|
||||||
const ext = path.extname(file.originalname).toLowerCase();
|
const ext = path.extname(file.originalname).toLowerCase();
|
||||||
@@ -1701,6 +1707,8 @@ app.get('/api/public-settings', apiLimiter, async (_, res) => {
|
|||||||
return result;
|
return result;
|
||||||
});
|
});
|
||||||
out.photo_ai_enabled = PHOTO_AI_URL ? 'true' : 'false';
|
out.photo_ai_enabled = PHOTO_AI_URL ? 'true' : 'false';
|
||||||
|
out.upload_file_limit_mb = String(UPLOAD_FILE_LIMIT_MB);
|
||||||
|
out.upload_total_limit_mb = String(UPLOAD_TOTAL_LIMIT_MB);
|
||||||
res.json(out);
|
res.json(out);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1788,7 +1796,7 @@ const logoUpload = upload.single('logo');
|
|||||||
app.post('/api/settings/logo', requireAdmin, (req, res, next) => {
|
app.post('/api/settings/logo', requireAdmin, (req, res, next) => {
|
||||||
logoUpload(req, res, (err) => {
|
logoUpload(req, res, (err) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
if (err.code === 'LIMIT_FILE_SIZE') return res.status(400).json({ error: 'Файл слишком большой (макс. 10 МБ)' });
|
if (err.code === 'LIMIT_FILE_SIZE') return res.status(400).json({ error: FILE_TOO_LARGE_ERROR });
|
||||||
if (err.message === 'Only images') return res.status(400).json({ error: 'Логотип: допустимы только изображения (jpg, png, gif, webp, bmp, avif, ico, heic, heif)' });
|
if (err.message === 'Only images') return res.status(400).json({ error: 'Логотип: допустимы только изображения (jpg, png, gif, webp, bmp, avif, ico, heic, heif)' });
|
||||||
if (err.message === 'Not allowed extension') return res.status(400).json({ error: 'Недопустимый тип файла' });
|
if (err.message === 'Not allowed extension') return res.status(400).json({ error: 'Недопустимый тип файла' });
|
||||||
return res.status(400).json({ error: 'Недопустимый файл' });
|
return res.status(400).json({ error: 'Недопустимый файл' });
|
||||||
@@ -3301,7 +3309,7 @@ const groupPhotoUpload = upload.single('photo');
|
|||||||
app.post('/api/groups/:id/photos', requireAuth, (req, res, next) => {
|
app.post('/api/groups/:id/photos', requireAuth, (req, res, next) => {
|
||||||
groupPhotoUpload(req, res, async (err) => {
|
groupPhotoUpload(req, res, async (err) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
if (err.code === 'LIMIT_FILE_SIZE') return res.status(400).json({ error: 'Файл слишком большой (макс. 10 МБ)' });
|
if (err.code === 'LIMIT_FILE_SIZE') return res.status(400).json({ error: FILE_TOO_LARGE_ERROR });
|
||||||
if (err.message === 'Only images') return res.status(400).json({ error: 'Фото: допустимы только изображения (jpg, png, gif, webp, bmp, avif, ico, heic, heif, jfif)' });
|
if (err.message === 'Only images') return res.status(400).json({ error: 'Фото: допустимы только изображения (jpg, png, gif, webp, bmp, avif, ico, heic, heif, jfif)' });
|
||||||
if (err.message === 'Not allowed extension') return res.status(400).json({ error: 'Недопустимый тип файла (*.html, *.js, *.svg и т.п. запрещены)' });
|
if (err.message === 'Not allowed extension') return res.status(400).json({ error: 'Недопустимый тип файла (*.html, *.js, *.svg и т.п. запрещены)' });
|
||||||
return res.status(400).json({ error: 'Недопустимый файл' });
|
return res.status(400).json({ error: 'Недопустимый файл' });
|
||||||
@@ -3527,7 +3535,7 @@ const modulePhotoUpload = upload.single('photo');
|
|||||||
app.post('/api/modules/:id/photo', requireAdmin, (req, res) => {
|
app.post('/api/modules/:id/photo', requireAdmin, (req, res) => {
|
||||||
modulePhotoUpload(req, res, async (err) => {
|
modulePhotoUpload(req, res, async (err) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
if (err.code === 'LIMIT_FILE_SIZE') return res.status(400).json({ error: 'Файл слишком большой (макс. 10 МБ)' });
|
if (err.code === 'LIMIT_FILE_SIZE') return res.status(400).json({ error: FILE_TOO_LARGE_ERROR });
|
||||||
if (err.message === 'Only images') return res.status(400).json({ error: 'Картинка: допустимы только изображения (jpg, png, gif, webp, bmp, avif, ico, heic, heif, jfif)' });
|
if (err.message === 'Only images') return res.status(400).json({ error: 'Картинка: допустимы только изображения (jpg, png, gif, webp, bmp, avif, ico, heic, heif, jfif)' });
|
||||||
if (err.message === 'Not allowed extension') return res.status(400).json({ error: 'Недопустимый тип файла (*.html, *.js, *.svg и т.п. запрещены)' });
|
if (err.message === 'Not allowed extension') return res.status(400).json({ error: 'Недопустимый тип файла (*.html, *.js, *.svg и т.п. запрещены)' });
|
||||||
return res.status(400).json({ error: 'Недопустимый файл' });
|
return res.status(400).json({ error: 'Недопустимый файл' });
|
||||||
@@ -3777,7 +3785,7 @@ app.get('/api/students/:id/photos', requireAuth, async (req, res) => {
|
|||||||
app.post('/api/students/:id/photos', requireAuth, (req, res, next) => {
|
app.post('/api/students/:id/photos', requireAuth, (req, res, next) => {
|
||||||
studentPhotoUpload(req, res, (err) => {
|
studentPhotoUpload(req, res, (err) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
if (err.code === 'LIMIT_FILE_SIZE') return res.status(400).json({ error: 'Файл слишком большой (макс. 10 МБ)' });
|
if (err.code === 'LIMIT_FILE_SIZE') return res.status(400).json({ error: FILE_TOO_LARGE_ERROR });
|
||||||
if (err.message === 'Only images') return res.status(400).json({ error: 'Фото: допустимы только изображения (jpg, png, gif, webp, bmp, avif, ico, heic, heif, jfif)' });
|
if (err.message === 'Only images') return res.status(400).json({ error: 'Фото: допустимы только изображения (jpg, png, gif, webp, bmp, avif, ico, heic, heif, jfif)' });
|
||||||
if (err.message === 'Not allowed extension') return res.status(400).json({ error: 'Недопустимый тип файла (*.html, *.js, *.svg и т.п. запрещены)' });
|
if (err.message === 'Not allowed extension') return res.status(400).json({ error: 'Недопустимый тип файла (*.html, *.js, *.svg и т.п. запрещены)' });
|
||||||
return res.status(400).json({ error: 'Недопустимый файл' });
|
return res.status(400).json({ error: 'Недопустимый файл' });
|
||||||
@@ -4444,7 +4452,7 @@ const entryFilesUpload = adminUpload.array('files', 10);
|
|||||||
app.post('/api/entries/:id/files', requireAuth, (req, res, next) => {
|
app.post('/api/entries/:id/files', requireAuth, (req, res, next) => {
|
||||||
entryFilesUpload(req, res, (err) => {
|
entryFilesUpload(req, res, (err) => {
|
||||||
if (!err) return next();
|
if (!err) return next();
|
||||||
if (err.code === 'LIMIT_FILE_SIZE') return res.status(400).json({ error: 'Файл слишком большой (макс. 10 МБ)' });
|
if (err.code === 'LIMIT_FILE_SIZE') return res.status(400).json({ error: FILE_TOO_LARGE_ERROR });
|
||||||
if (err.message === 'Not allowed extension') return res.status(400).json({ error: 'Недопустимый тип файла' });
|
if (err.message === 'Not allowed extension') return res.status(400).json({ error: 'Недопустимый тип файла' });
|
||||||
return res.status(400).json({ error: 'Недопустимый файл' });
|
return res.status(400).json({ error: 'Недопустимый файл' });
|
||||||
});
|
});
|
||||||
@@ -4474,7 +4482,7 @@ app.post('/api/entries/:id/files', requireAuth, (req, res, next) => {
|
|||||||
const totalBytes = files.reduce((s, f) => s + (f.size || 0), 0);
|
const totalBytes = files.reduce((s, f) => s + (f.size || 0), 0);
|
||||||
if (totalBytes > MAX_TOTAL_UPLOAD_BYTES) {
|
if (totalBytes > MAX_TOTAL_UPLOAD_BYTES) {
|
||||||
files.forEach(removeUpload);
|
files.forEach(removeUpload);
|
||||||
return res.status(400).json({ error: 'Суммарный размер файлов слишком велик (макс. 30 МБ)' });
|
return res.status(400).json({ error: TOTAL_TOO_LARGE_ERROR });
|
||||||
}
|
}
|
||||||
|
|
||||||
const client = await pool.connect();
|
const client = await pool.connect();
|
||||||
@@ -4951,7 +4959,7 @@ const entryFields = upload.fields([{ name: 'photo', maxCount: 10 }, { name: 'fil
|
|||||||
app.post('/api/entries', entryLimiter, (req, res, next) => {
|
app.post('/api/entries', entryLimiter, (req, res, next) => {
|
||||||
entryFields(req, res, (err) => {
|
entryFields(req, res, (err) => {
|
||||||
if (!err) return next();
|
if (!err) return next();
|
||||||
if (err.code === 'LIMIT_FILE_SIZE') return res.status(400).json({ error: 'Файл слишком большой (макс. 10 МБ)' });
|
if (err.code === 'LIMIT_FILE_SIZE') return res.status(400).json({ error: FILE_TOO_LARGE_ERROR });
|
||||||
if (err.message === 'Only images') return res.status(400).json({ error: 'Фото: допустимы только изображения (jpg, png, gif, webp, bmp, avif, ico, heic, heif, jfif)' });
|
if (err.message === 'Only images') return res.status(400).json({ error: 'Фото: допустимы только изображения (jpg, png, gif, webp, bmp, avif, ico, heic, heif, jfif)' });
|
||||||
if (err.message === 'Not allowed extension') return res.status(400).json({ error: 'Недопустимый тип файла (*.html, *.js, *.svg и т.п. запрещены)' });
|
if (err.message === 'Not allowed extension') return res.status(400).json({ error: 'Недопустимый тип файла (*.html, *.js, *.svg и т.п. запрещены)' });
|
||||||
return res.status(400).json({ error: 'Недопустимый файл' });
|
return res.status(400).json({ error: 'Недопустимый файл' });
|
||||||
@@ -4979,7 +4987,7 @@ app.post('/api/entries', entryLimiter, (req, res, next) => {
|
|||||||
if (totalBytes > MAX_TOTAL_UPLOAD_BYTES) {
|
if (totalBytes > MAX_TOTAL_UPLOAD_BYTES) {
|
||||||
photos.forEach(removeUpload);
|
photos.forEach(removeUpload);
|
||||||
projectFiles.forEach(removeUpload);
|
projectFiles.forEach(removeUpload);
|
||||||
return res.status(400).json({ error: 'Суммарный размер файлов слишком велик (макс. 30 МБ)' });
|
return res.status(400).json({ error: TOTAL_TOO_LARGE_ERROR });
|
||||||
}
|
}
|
||||||
const gid = Number.parseInt(group_id, 10);
|
const gid = Number.parseInt(group_id, 10);
|
||||||
if (!Number.isInteger(gid)) {
|
if (!Number.isInteger(gid)) {
|
||||||
@@ -6307,12 +6315,18 @@ for (const signal of ['SIGTERM', 'SIGINT']) {
|
|||||||
const certPath = path.join(__dirname, 'certs', 'cert.pem');
|
const certPath = path.join(__dirname, 'certs', 'cert.pem');
|
||||||
const keyPath = path.join(__dirname, 'certs', 'key.pem');
|
const keyPath = path.join(__dirname, 'certs', 'key.pem');
|
||||||
|
|
||||||
|
function tuneServer(srv) {
|
||||||
|
srv.requestTimeout = UPLOAD_REQUEST_TIMEOUT_MS;
|
||||||
|
srv.headersTimeout = UPLOAD_REQUEST_TIMEOUT_MS + 60000;
|
||||||
|
return srv;
|
||||||
|
}
|
||||||
|
|
||||||
if (fs.existsSync(certPath) && fs.existsSync(keyPath)) {
|
if (fs.existsSync(certPath) && fs.existsSync(keyPath)) {
|
||||||
const httpsServer = https.createServer({ key: fs.readFileSync(keyPath), cert: fs.readFileSync(certPath) }, app);
|
const httpsServer = https.createServer({ key: fs.readFileSync(keyPath), cert: fs.readFileSync(certPath) }, app);
|
||||||
httpsServer.listen(HTTPS_PORT, '0.0.0.0', () => console.log(`HTTPS : ${HTTPS_PORT}`));
|
tuneServer(httpsServer).listen(HTTPS_PORT, '0.0.0.0', () => console.log(`HTTPS : ${HTTPS_PORT}`));
|
||||||
app.listen(PORT, '0.0.0.0', () => console.log(`HTTP : ${PORT}`));
|
tuneServer(app.listen(PORT, '0.0.0.0', () => console.log(`HTTP : ${PORT}`)));
|
||||||
} else {
|
} else {
|
||||||
app.listen(PORT, '0.0.0.0', () => console.log(`HTTP : ${PORT} (no TLS certs)`));
|
tuneServer(app.listen(PORT, '0.0.0.0', () => console.log(`HTTP : ${PORT} (no TLS certs)`)));
|
||||||
}
|
}
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user