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:
@@ -15,6 +15,7 @@
|
||||
"helmet": "^8.2.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"sharp": "^0.33.0",
|
||||
"sql.js": "^1.14.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -20,6 +20,7 @@ export async function initDb(): Promise<SqlJsDatabase> {
|
||||
}
|
||||
|
||||
db.run('PRAGMA foreign_keys = ON');
|
||||
db.run('PRAGMA journal_mode = WAL');
|
||||
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
@@ -107,6 +108,15 @@ export function queryAll(sql: string, params: any[] = []): any[] {
|
||||
return results;
|
||||
}
|
||||
|
||||
export function setupGracefulShutdown(): void {
|
||||
const handler = () => {
|
||||
saveDb();
|
||||
process.exit(0);
|
||||
};
|
||||
process.on('SIGTERM', handler);
|
||||
process.on('SIGINT', handler);
|
||||
}
|
||||
|
||||
export function execute(sql: string, params: any[] = []): { changes: number; lastInsertRowid: number } {
|
||||
const stmt = db.prepare(sql);
|
||||
stmt.bind(params);
|
||||
|
||||
@@ -5,7 +5,7 @@ import helmet from 'helmet';
|
||||
import rateLimit from 'express-rate-limit';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { initDb } from './db.js';
|
||||
import { initDb, setupGracefulShutdown } from './db.js';
|
||||
import authRoutes from './routes/auth.js';
|
||||
import catRoutes from './routes/cats.js';
|
||||
import likeRoutes from './routes/likes.js';
|
||||
@@ -22,6 +22,7 @@ if (!process.env.JWT_SECRET || process.env.JWT_SECRET === DEV_JWT_SECRET) {
|
||||
|
||||
async function main() {
|
||||
await initDb();
|
||||
setupGracefulShutdown();
|
||||
|
||||
const app = express();
|
||||
const PORT = parseInt(process.env.PORT || '3040');
|
||||
@@ -46,6 +47,15 @@ async function main() {
|
||||
app.use('/api/auth/login', authLimiter);
|
||||
app.use('/api/auth/register', authLimiter);
|
||||
|
||||
const uploadLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: 10,
|
||||
message: { error: 'Слишком много загрузок, подождите 15 минут' },
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
});
|
||||
app.use('/api/cats', uploadLimiter);
|
||||
|
||||
app.use('/uploads', express.static(path.join(__dirname, '..', 'uploads')));
|
||||
|
||||
app.use('/api/auth', authRoutes);
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { Router, Response } from 'express';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { queryAll, queryOne, execute } from '../db';
|
||||
import { authMiddleware, adminOnly, AuthRequest } from '../middleware/auth.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const uploadsDir = path.join(__dirname, '..', '..', 'uploads');
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.use(authMiddleware, adminOnly);
|
||||
@@ -34,9 +40,16 @@ router.delete('/users/:id', (req: AuthRequest, res: Response) => {
|
||||
const user = queryOne('SELECT id, username, is_admin FROM users WHERE id = ?', [userId]);
|
||||
if (!user) { res.status(404).json({ error: 'User not found' }); return; }
|
||||
if (user.is_admin) { res.status(400).json({ error: 'Cannot delete admin' }); return; }
|
||||
const userCats = queryAll('SELECT image_url FROM cats WHERE user_id = ?', [userId]);
|
||||
execute('DELETE FROM comments WHERE cat_id IN (SELECT id FROM cats WHERE user_id = ?)', [userId]);
|
||||
execute('DELETE FROM likes WHERE cat_id IN (SELECT id FROM cats WHERE user_id = ?)', [userId]);
|
||||
execute('DELETE FROM cats WHERE user_id = ?', [userId]);
|
||||
execute('DELETE FROM users WHERE id = ?', [userId]);
|
||||
for (const cat of userCats) {
|
||||
if (cat.image_url) {
|
||||
try { fs.unlinkSync(path.join(uploadsDir, path.basename(cat.image_url))); } catch {}
|
||||
}
|
||||
}
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
@@ -63,10 +76,14 @@ router.put('/cats/:id/caption', (req: AuthRequest, res: Response) => {
|
||||
});
|
||||
|
||||
router.delete('/cats/:id', (req: AuthRequest, res: Response) => {
|
||||
const cat = queryOne('SELECT id FROM cats WHERE id = ?', [req.params.id]);
|
||||
const cat = queryOne('SELECT id, image_url FROM cats WHERE id = ?', [req.params.id]);
|
||||
if (!cat) { res.status(404).json({ error: 'Cat not found' }); 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) {
|
||||
try { fs.unlinkSync(path.join(uploadsDir, path.basename(cat.image_url))); } catch {}
|
||||
}
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user