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:
@@ -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 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({
|
||||
storage: multer.diskStorage({
|
||||
@@ -987,7 +993,7 @@ const upload = multer({
|
||||
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) => {
|
||||
file.originalname = fixFilename(file.originalname);
|
||||
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}`);
|
||||
},
|
||||
}),
|
||||
limits: { fileSize: 10 * 1024 * 1024 },
|
||||
limits: { fileSize: MAX_FILE_UPLOAD_BYTES },
|
||||
fileFilter: (req, file, cb) => {
|
||||
file.originalname = fixFilename(file.originalname);
|
||||
const ext = path.extname(file.originalname).toLowerCase();
|
||||
@@ -1701,6 +1707,8 @@ app.get('/api/public-settings', apiLimiter, async (_, res) => {
|
||||
return result;
|
||||
});
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -1788,7 +1796,7 @@ const logoUpload = upload.single('logo');
|
||||
app.post('/api/settings/logo', requireAdmin, (req, res, next) => {
|
||||
logoUpload(req, res, (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 === 'Not allowed extension') 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) => {
|
||||
groupPhotoUpload(req, res, async (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 === 'Not allowed extension') return res.status(400).json({ error: 'Недопустимый тип файла (*.html, *.js, *.svg и т.п. запрещены)' });
|
||||
return res.status(400).json({ error: 'Недопустимый файл' });
|
||||
@@ -3527,7 +3535,7 @@ const modulePhotoUpload = upload.single('photo');
|
||||
app.post('/api/modules/:id/photo', requireAdmin, (req, res) => {
|
||||
modulePhotoUpload(req, res, async (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 === 'Not allowed extension') return res.status(400).json({ error: 'Недопустимый тип файла (*.html, *.js, *.svg и т.п. запрещены)' });
|
||||
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) => {
|
||||
studentPhotoUpload(req, res, (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 === 'Not allowed extension') return res.status(400).json({ error: 'Недопустимый тип файла (*.html, *.js, *.svg и т.п. запрещены)' });
|
||||
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) => {
|
||||
entryFilesUpload(req, res, (err) => {
|
||||
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: 'Недопустимый тип файла' });
|
||||
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);
|
||||
if (totalBytes > MAX_TOTAL_UPLOAD_BYTES) {
|
||||
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();
|
||||
@@ -4951,7 +4959,7 @@ const entryFields = upload.fields([{ name: 'photo', maxCount: 10 }, { name: 'fil
|
||||
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.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 === 'Not allowed extension') return res.status(400).json({ error: 'Недопустимый тип файла (*.html, *.js, *.svg и т.п. запрещены)' });
|
||||
return res.status(400).json({ error: 'Недопустимый файл' });
|
||||
@@ -4979,7 +4987,7 @@ app.post('/api/entries', entryLimiter, (req, res, next) => {
|
||||
if (totalBytes > MAX_TOTAL_UPLOAD_BYTES) {
|
||||
photos.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);
|
||||
if (!Number.isInteger(gid)) {
|
||||
@@ -6307,12 +6315,18 @@ for (const signal of ['SIGTERM', 'SIGINT']) {
|
||||
const certPath = path.join(__dirname, 'certs', 'cert.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)) {
|
||||
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}`));
|
||||
app.listen(PORT, '0.0.0.0', () => console.log(`HTTP : ${PORT}`));
|
||||
tuneServer(httpsServer).listen(HTTPS_PORT, '0.0.0.0', () => console.log(`HTTPS : ${HTTPS_PORT}`));
|
||||
tuneServer(app.listen(PORT, '0.0.0.0', () => console.log(`HTTP : ${PORT}`)));
|
||||
} 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 () => {
|
||||
|
||||
Reference in New Issue
Block a user