diff --git a/.env.example b/.env.example index 8ea2ba2..1809030 100644 --- a/.env.example +++ b/.env.example @@ -4,6 +4,18 @@ DB_PASSWORD=случайная-длинная-строка # Лимит размера загружаемого на восстановление бэкапа, МБ (по умолчанию 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. Обязателен, если Redis включён в docker-compose. REDIS_PASSWORD=замените-на-длинный-секрет diff --git a/AGENTS.md b/AGENTS.md index 1d4f2cc..d419e69 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,7 +33,7 @@ This document defines how AI agents should work with the WhatIDo codebase. Follo ### 3. File Uploads - **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`) - **HEIC**: Auto-converted to JPEG via `heic-convert` - **Cleanup**: `safeUnlink` / `sweepOrphanedUploads` — never delete outside `uploads/` or the configured bucket diff --git a/PRD.md b/PRD.md index 067a690..9468c64 100644 --- a/PRD.md +++ b/PRD.md @@ -46,7 +46,7 @@ | 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 | +| 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) | ID | Requirement | Priority | diff --git a/README.md b/README.md index 25db089..a496f4e 100644 --- a/README.md +++ b/README.md @@ -527,7 +527,7 @@ node api.smoketest.js # сквозная проверка API (нужен - **Пароль администратора** обязателен (`ADMIN_PASSWORD`) — он создаёт первого админа в пустой БД; фолбэка на `admin` нет. Самостоятельной роли в API не даёт: доступ только по сессиям. - **CORS отключён** — кросс-доменные запросы к API запрещены. - **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/`. - **Заголовки**: `helmet` — `X-Frame-Options`, `nosniff`, HSTS, `Referrer-Policy`. - **Порт БД** 5432 наружу не публикуется (доступ только внутри docker-сети). diff --git a/docker-compose.yml b/docker-compose.yml index 6d2cefd..dd3dc78 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -73,6 +73,9 @@ services: ADMIN_PASSWORD: ${ADMIN_PASSWORD} ADMIN_USERNAME: ${ADMIN_USERNAME:-admin} 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_PROMPT: ${AI_PROMPT:-} AI_REQUEST_TIMEOUT_MS: ${AI_REQUEST_TIMEOUT_MS:-120000} diff --git a/public/js/index.js b/public/js/index.js index 9d87ddc..4f62e1a 100644 --- a/public/js/index.js +++ b/public/js/index.js @@ -120,8 +120,10 @@ function clearFiles() { } const MAX_FILES = 10; -const MAX_FILE_BYTES = 10 * 1024 * 1024; -const MAX_TOTAL_BYTES = 30 * 1024 * 1024; +const DEFAULT_FILE_LIMIT_MB = 50; +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 MIME_EXT = { '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); } +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) { const added = []; 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) { - if (selectedFiles.length >= MAX_FILES) { skipped.push('Можно прикрепить не более 10 файлов'); break; } - if (file.size > MAX_FILE_BYTES) { skipped.push(`«${file.name}» больше 10 МБ`); continue; } + if (selectedFiles.length >= MAX_FILES) { skipped.push(`Можно прикрепить не более ${MAX_FILES} файлов`); break; } + 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 (totalUploadBytes() + file.size > MAX_TOTAL_BYTES) { skipped.push('Суммарный размер файлов — не более 30 МБ'); break; } + if (totalUploadBytes() + file.size > MAX_TOTAL_BYTES) { skipped.push(`Суммарный размер файлов — не более ${totalLimitMb} МБ`); break; } selectedFiles.push(file); added.push(file.name); } @@ -319,6 +332,7 @@ async function resolveModuleId(name) { if (Number.isFinite(h) && h >= 120 && h <= 4096) captureHeight = h; const q = parseFloat(f.photo_capture_quality); 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') { const camBtns = document.getElementById('camBtns'); if (camBtns) camBtns.style.display = 'none'; diff --git a/public/js/modules.js b/public/js/modules.js index 98aedc0..60a9f3e 100644 --- a/public/js/modules.js +++ b/public/js/modules.js @@ -3,6 +3,11 @@ let mPage = 1; const M_PAGE_SIZE = 25; let mSearchTimer = null; let modulePhotoDeleteFlag = false; +let modulePhotoLimitMb = 50; + +function modulePhotoHint() { + return `jpg, png, gif, webp, avif, heic и др., до ${modulePhotoLimitMb} МБ · или вставьте из буфера (Ctrl+V)`; +} function mPlural(n, one, few, many) { n = Math.abs(n) % 100; const n1 = n % 10; @@ -68,7 +73,7 @@ function resetModulePhoto(m) { preview.removeAttribute('src'); preview.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 () => { 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); loadModules(); } diff --git a/public/modules.html b/public/modules.html index 6118952..35e2ca1 100644 --- a/public/modules.html +++ b/public/modules.html @@ -52,7 +52,7 @@ - jpg, png, gif, webp, avif, heic и др., до 10 МБ · или вставьте из буфера (Ctrl+V) + jpg, png, gif, webp, avif, heic и др. · или вставьте из буфера (Ctrl+V)