support HEIC/HEIF uploads: convert to JPEG via heic-convert; graceful multer errors for group photos

This commit is contained in:
dev
2026-09-08 11:54:38 +03:00
parent ea14fd654f
commit eef33e457f
3 changed files with 1963 additions and 4 deletions
+1928
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -12,6 +12,7 @@
"multer": "^1.4.5-lts.1",
"pg": "^8.13.0",
"tar": "^7.4.3",
"bcrypt": "^5.1.1"
"bcrypt": "^5.1.1",
"heic-convert": "^2.1.0"
}
}
+33 -3
View File
@@ -4,6 +4,7 @@ const multer = require('multer');
const rateLimit = require('express-rate-limit');
const helmet = require('helmet');
const bcrypt = require('bcrypt');
const heicConvert = require('heic-convert');
const https = require('https');
const path = require('path');
@@ -65,7 +66,7 @@ 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 ALLOWED_IMAGE_EXT = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.avif', '.ico', '.heic', '.heif']);
const MAX_TOTAL_UPLOAD_BYTES = 30 * 1024 * 1024;
const upload = multer({
@@ -115,6 +116,24 @@ function removeUpload(file) {
safeUnlink(file && file.path);
}
async function convertHeicPhoto(file) {
if (!file || !file.path) return;
const ext = (path.extname(file.originalname || '') || '').toLowerCase();
if (ext !== '.heic' && ext !== '.heif') return;
try {
const outName = `${path.basename(file.path, path.extname(file.path))}.jpg`;
const outPath = path.join(path.dirname(file.path), outName);
const jpeg = await heicConvert({ buffer: fs.readFileSync(file.path), format: 'JPEG', quality: 0.85 });
fs.writeFileSync(outPath, jpeg);
safeUnlink(file.path);
file.path = outPath;
file.filename = outName;
file.originalname = outName;
} catch (e) {
console.error('HEIC convert failed:', e);
}
}
async function removeEntryFiles(entryId) {
const { rows } = await pool.query(
`SELECT photo_path AS p FROM entries WHERE id = $1
@@ -785,10 +804,20 @@ app.get('/api/groups/:id/photos', requireAdmin, async (req, res) => {
res.json({ photos: rows, total });
});
app.post('/api/groups/:id/photos', requireAdmin, upload.single('photo'), async (req, res) => {
const groupPhotoUpload = upload.single('photo');
app.post('/api/groups/:id/photos', requireAdmin, (req, res, next) => {
groupPhotoUpload(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, heic, heif)' });
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 { caption, taken_at } = req.body;
if (!req.file) return res.status(400).json({ error: 'Файл обязателен' });
try {
await convertHeicPhoto(req.file);
const { rows } = await pool.query(
`INSERT INTO group_photos (group_id, photo_path, caption, taken_at)
VALUES ($1, $2, $3, $4) RETURNING *`,
@@ -1129,7 +1158,7 @@ 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 === '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: 'Недопустимый тип файла (*.html, *.js, *.svg и т.п. запрещены)' });
return res.status(400).json({ error: 'Недопустимый файл' });
});
@@ -1177,6 +1206,7 @@ app.post('/api/entries', entryLimiter, (req, res, next) => {
return res.status(429).json({ error: `Уже ответили: подождите ${intervalMin} минут` });
}
}
await convertHeicPhoto(photo);
const photo_path = photo ? `/uploads/${photo.filename}` : null;
const client = await pool.connect();
try {