feat: infinite scroll, HEIC support, likes fix, security & backup

- Fix liked hearts not showing after page reload (sync useEffect in CatCard)
- Replace pagination with infinite scroll (useInfiniteQuery + IntersectionObserver)
- Add HEIC/HEIF/AVIF image support with sharp conversion to JPEG on upload
- Validate MIME type alongside file extension to prevent spoofing
- Limit caption to 500 chars; increase upload size limit to 20MB
- Delete physical file from uploads/ when post is removed (cats + admin routes)
- Add WAL journal mode and graceful shutdown (SIGTERM/SIGINT) to db
- Add rate limiter for upload endpoint (10 req / 15 min)
- Add pre-deploy backup of data.db and uploads/ to deploy.sh (keeps last 10)
- Add plan_modernization.md with ideas for future improvements

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-26 00:18:46 +03:00
parent 81c9bba56f
commit 94fff09a41
11 changed files with 290 additions and 65 deletions

View File

@@ -1,28 +1,43 @@
import { Router, Response } from 'express';
import multer from 'multer';
import path from 'path';
import fs from 'fs';
import { fileURLToPath } from 'url';
import sharp from 'sharp';
import { queryOne, queryAll, execute } from '../db';
import { authMiddleware, AuthRequest } from '../middleware/auth.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const uploadsDir = path.join(__dirname, '..', '..', 'uploads');
if (!fs.existsSync(uploadsDir)) {
fs.mkdirSync(uploadsDir, { recursive: true });
}
const ALLOWED_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.heic', '.heif', '.avif']);
const ALLOWED_MIMES = new Set([
'image/jpeg', 'image/png', 'image/gif', 'image/webp',
'image/heic', 'image/heif', 'image/avif',
]);
const storage = multer.diskStorage({
destination: (_req, _file, cb) => cb(null, uploadsDir),
filename: (_req, file, cb) => {
const ext = path.extname(file.originalname);
const ext = path.extname(file.originalname).toLowerCase() || '.jpg';
cb(null, `${Date.now()}-${Math.random().toString(36).slice(2, 8)}${ext}`);
},
});
const upload = multer({
storage,
limits: { fileSize: 10 * 1024 * 1024 },
limits: { fileSize: 20 * 1024 * 1024 },
fileFilter: (_req, file, cb) => {
const allowed = ['.jpg', '.jpeg', '.png', '.gif', '.webp'];
const ext = path.extname(file.originalname).toLowerCase();
cb(null, allowed.includes(ext));
if (!ALLOWED_EXTENSIONS.has(ext) || !ALLOWED_MIMES.has(file.mimetype)) {
cb(new Error('Неподдерживаемый формат файла'));
return;
}
cb(null, true);
},
});
@@ -88,14 +103,40 @@ router.get('/:id', (req: AuthRequest, res: Response) => {
res.json({ cat });
});
router.post('/', authMiddleware, upload.single('image'), (req: AuthRequest, res: Response) => {
router.post('/', authMiddleware, upload.single('image'), async (req: AuthRequest, res: Response) => {
if (!req.file) {
res.status(400).json({ error: 'Нужно выбрать изображение (jpg, png, gif, webp, до 10MB)' });
res.status(400).json({ error: 'Нужно выбрать изображение (jpg, png, gif, webp, heic, avif, до 20MB)' });
return;
}
const caption = (req.body.caption || '').trim();
const image_url = `/uploads/${req.file.filename}`;
if (caption.length > 500) {
fs.unlinkSync(req.file.path);
res.status(400).json({ error: 'Подпись не может быть длиннее 500 символов' });
return;
}
const origPath = req.file.path;
const origExt = path.extname(req.file.filename).toLowerCase();
const isGif = origExt === '.gif';
let finalFilename = req.file.filename;
let finalPath = origPath;
if (!isGif) {
finalFilename = req.file.filename.replace(/\.[^.]+$/, '.jpg');
finalPath = path.join(uploadsDir, finalFilename);
try {
await sharp(origPath).jpeg({ quality: 85 }).toFile(finalPath);
if (origPath !== finalPath) fs.unlinkSync(origPath);
} catch {
try { fs.unlinkSync(origPath); } catch {}
res.status(400).json({ error: 'Не удалось обработать изображение. Попробуйте другой формат.' });
return;
}
}
const image_url = `/uploads/${finalFilename}`;
execute(
'INSERT INTO cats (user_id, image_url, caption) VALUES (?, ?, ?)',
@@ -119,7 +160,7 @@ router.post('/', authMiddleware, upload.single('image'), (req: AuthRequest, res:
});
router.delete('/:id', authMiddleware, (req: AuthRequest, res: Response) => {
const cat = queryOne('SELECT user_id FROM cats WHERE id = ?', [req.params.id]);
const cat = queryOne('SELECT user_id, image_url FROM cats WHERE id = ?', [req.params.id]);
if (!cat) {
res.status(404).json({ error: 'Cat not found' });
return;
@@ -132,6 +173,12 @@ router.delete('/:id', authMiddleware, (req: AuthRequest, res: Response) => {
execute('DELETE FROM comments WHERE cat_id = ?', [req.params.id]);
execute('DELETE FROM likes WHERE cat_id = ?', [req.params.id]);
execute('DELETE FROM cats WHERE id = ?', [req.params.id]);
if (cat.image_url) {
const filePath = path.join(uploadsDir, path.basename(cat.image_url));
try { fs.unlinkSync(filePath); } catch {}
}
res.json({ ok: true });
});