- 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>
186 lines
6.1 KiB
TypeScript
186 lines
6.1 KiB
TypeScript
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;
|