Files
cats/server/src/routes/cats.ts
heagbokat 94fff09a41 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>
2026-06-26 00:18:46 +03:00

186 lines
6.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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).toLowerCase() || '.jpg';
cb(null, `${Date.now()}-${Math.random().toString(36).slice(2, 8)}${ext}`);
},
});
const upload = multer({
storage,
limits: { fileSize: 20 * 1024 * 1024 },
fileFilter: (_req, file, cb) => {
const ext = path.extname(file.originalname).toLowerCase();
if (!ALLOWED_EXTENSIONS.has(ext) || !ALLOWED_MIMES.has(file.mimetype)) {
cb(new Error('Неподдерживаемый формат файла'));
return;
}
cb(null, true);
},
});
const router = Router();
router.get('/', (req: AuthRequest, res: Response) => {
const page = Math.max(1, parseInt(req.query.page as string) || 1);
const limit = 12;
const offset = (page - 1) * limit;
const cats = queryAll(
`SELECT c.id, c.image_url, c.caption, c.created_at, c.user_id,
u.username, u.points AS user_points, u.avatar_url AS user_avatar_url,
(SELECT COUNT(*) FROM likes WHERE cat_id = c.id) AS likes_count,
(SELECT COUNT(*) FROM comments WHERE cat_id = c.id) AS comments_count
FROM cats c
JOIN users u ON u.id = c.user_id
ORDER BY c.created_at DESC
LIMIT ? OFFSET ?`,
[limit, offset]
);
const countResult = queryOne('SELECT COUNT(*) as count FROM cats');
const total = countResult?.count || 0;
res.json({ cats, total, page, totalPages: Math.ceil(total / limit) });
});
router.get('/user/:userId', (req: AuthRequest, res: Response) => {
const userId = parseInt(req.params.userId);
const cats = queryAll(
`SELECT c.id, c.image_url, c.caption, c.created_at, c.user_id,
u.username, u.points AS user_points, u.avatar_url AS user_avatar_url,
(SELECT COUNT(*) FROM likes WHERE cat_id = c.id) AS likes_count,
(SELECT COUNT(*) FROM comments WHERE cat_id = c.id) AS comments_count
FROM cats c
JOIN users u ON u.id = c.user_id
WHERE c.user_id = ?
ORDER BY c.created_at DESC`,
[userId]
);
res.json({ cats });
});
router.get('/:id', (req: AuthRequest, res: Response) => {
const cat = queryOne(
`SELECT c.id, c.image_url, c.caption, c.created_at, c.user_id,
u.username, u.points AS user_points, u.avatar_url AS user_avatar_url,
(SELECT COUNT(*) FROM likes WHERE cat_id = c.id) AS likes_count,
(SELECT COUNT(*) FROM comments WHERE cat_id = c.id) AS comments_count
FROM cats c
JOIN users u ON u.id = c.user_id
WHERE c.id = ?`,
[req.params.id]
);
if (!cat) {
res.status(404).json({ error: 'Cat not found' });
return;
}
res.json({ cat });
});
router.post('/', authMiddleware, upload.single('image'), async (req: AuthRequest, res: Response) => {
if (!req.file) {
res.status(400).json({ error: 'Нужно выбрать изображение (jpg, png, gif, webp, heic, avif, до 20MB)' });
return;
}
const caption = (req.body.caption || '').trim();
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 (?, ?, ?)',
[req.userId, image_url, caption]
);
execute('UPDATE users SET points = points + 1 WHERE id = ?', [req.userId]);
const cat = queryOne(
`SELECT c.id, c.image_url, c.caption, c.created_at, c.user_id,
u.username, u.points AS user_points, u.avatar_url AS user_avatar_url,
0 AS likes_count,
0 AS comments_count
FROM cats c
JOIN users u ON u.id = c.user_id
WHERE c.id = (SELECT MAX(id) FROM cats WHERE user_id = ?)`,
[req.userId]
);
res.status(201).json({ cat });
});
router.delete('/:id', authMiddleware, (req: AuthRequest, res: Response) => {
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;
}
if (cat.user_id !== req.userId && !req.isAdmin) {
res.status(403).json({ error: 'Not your cat' });
return;
}
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 });
});
export default router;