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,4 +1,4 @@
import { useState } from 'react'; import { useState, useEffect } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { Cat } from '@/api/endpoints'; import { Cat } from '@/api/endpoints';
import { useAuth } from '@/hooks/useAuth'; import { useAuth } from '@/hooks/useAuth';
@@ -17,6 +17,10 @@ export default function CatCard({ cat, onOpen, likedIds }: CatCardProps) {
const { user } = useAuth(); const { user } = useAuth();
const [liked, setLiked] = useState(() => likedIds?.has(cat.id) ?? false); const [liked, setLiked] = useState(() => likedIds?.has(cat.id) ?? false);
const [likeCount, setLikeCount] = useState(cat.likes_count); const [likeCount, setLikeCount] = useState(cat.likes_count);
useEffect(() => {
if (likedIds !== undefined) setLiked(likedIds.has(cat.id));
}, [likedIds, cat.id]);
const { toast } = useToast(); const { toast } = useToast();
const likeMutation = useLikeCat(cat.id); const likeMutation = useLikeCat(cat.id);
const unlikeMutation = useUnlikeCat(cat.id); const unlikeMutation = useUnlikeCat(cat.id);

View File

@@ -13,15 +13,14 @@ function RankBadge({ rank }: { rank: number }) {
export default function FeedSidebar() { export default function FeedSidebar() {
const { user } = useAuth(); const { user } = useAuth();
const { data } = useCats(1); const { data } = useCats();
const usersMap = new Map<number, { username: string; points: number; avatar_url: string | null }>(); const usersMap = new Map<number, { username: string; points: number; avatar_url: string | null }>();
if (data?.cats) { const firstPageCats = data?.pages[0]?.cats ?? [];
for (const cat of data.cats) { for (const cat of firstPageCats) {
if (!usersMap.has(cat.user_id)) if (!usersMap.has(cat.user_id))
usersMap.set(cat.user_id, { username: cat.username, points: cat.user_points, avatar_url: cat.user_avatar_url }); usersMap.set(cat.user_id, { username: cat.username, points: cat.user_points, avatar_url: cat.user_avatar_url });
} }
}
const topUsers = Array.from(usersMap.entries()) const topUsers = Array.from(usersMap.entries())
.sort((a, b) => b[1].points - a[1].points) .sort((a, b) => b[1].points - a[1].points)

View File

@@ -1,10 +1,13 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useInfiniteQuery, useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { getCats, getUserCats, getCat, uploadCat, deleteCat } from '@/api/endpoints'; import { getCats, getUserCats, getCat, uploadCat, deleteCat } from '@/api/endpoints';
export function useCats(page: number) { export function useCats() {
return useQuery({ return useInfiniteQuery({
queryKey: ['cats', page], queryKey: ['cats'],
queryFn: () => getCats(page).then((r) => r.data), queryFn: ({ pageParam }) => getCats(pageParam as number).then((r) => r.data),
initialPageParam: 1,
getNextPageParam: (lastPage) =>
lastPage.page < lastPage.totalPages ? lastPage.page + 1 : undefined,
}); });
} }

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react'; import { useState, useEffect, useRef, useCallback } from 'react';
import { useCats } from '@/hooks/useCats'; import { useCats } from '@/hooks/useCats';
import CatCard from '@/components/CatCard'; import CatCard from '@/components/CatCard';
import FeedSidebar from '@/components/FeedSidebar'; import FeedSidebar from '@/components/FeedSidebar';
@@ -6,7 +6,7 @@ import CatModal from '@/components/CatModal';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { getBatchLikeStatus } from '@/api/endpoints'; import { getBatchLikeStatus } from '@/api/endpoints';
import { useAuth } from '@/hooks/useAuth'; import { useAuth } from '@/hooks/useAuth';
import { ChevronLeft, ChevronRight, RefreshCw, Cat } from 'lucide-react'; import { RefreshCw, Cat, Loader2 } from 'lucide-react';
function FeedSkeleton() { function FeedSkeleton() {
return ( return (
@@ -32,26 +32,40 @@ function FeedSkeleton() {
} }
export default function FeedPage() { export default function FeedPage() {
const [page, setPage] = useState(1);
const [modalCatId, setModalCatId] = useState<number | null>(null); const [modalCatId, setModalCatId] = useState<number | null>(null);
const [likedIds, setLikedIds] = useState<Set<number>>(new Set()); const [likedIds, setLikedIds] = useState<Set<number>>(new Set());
const { data, isLoading, isError, refetch } = useCats(page); const { data, isLoading, isError, refetch, fetchNextPage, hasNextPage, isFetchingNextPage } = useCats();
const { user } = useAuth(); const { user } = useAuth();
const sentinelRef = useRef<HTMLDivElement | null>(null);
const allCats = data?.pages.flatMap((p) => p.cats) ?? [];
// Load like statuses for newly fetched cats
useEffect(() => {
if (!allCats.length || !user) return;
const ids = allCats.map((c) => c.id);
getBatchLikeStatus(ids)
.then((r) => setLikedIds(new Set(r.data.likedIds)))
.catch(() => {});
}, [data?.pages.length, user]);
// Infinite scroll via IntersectionObserver
const onSentinelVisible = useCallback(
(entries: IntersectionObserverEntry[]) => {
if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
},
[hasNextPage, isFetchingNextPage, fetchNextPage]
);
useEffect(() => { useEffect(() => {
if (!data?.cats?.length || !user) return; const el = sentinelRef.current;
const ids = data.cats.map(c => c.id); if (!el) return;
getBatchLikeStatus(ids) const observer = new IntersectionObserver(onSentinelVisible, { rootMargin: '200px' });
.then(r => setLikedIds(new Set(r.data.likedIds))) observer.observe(el);
.catch(() => {}); return () => observer.disconnect();
}, [data?.cats, user]); }, [onSentinelVisible]);
const handleUnlike = (catId: number) => {
setLikedIds(prev => { const n = new Set(prev); n.delete(catId); return n; });
};
const handleLike = (catId: number) => {
setLikedIds(prev => { const n = new Set(prev); n.add(catId); return n; });
};
return ( return (
<div className="mx-auto max-w-[900px] px-5 py-5"> <div className="mx-auto max-w-[900px] px-5 py-5">
@@ -72,7 +86,7 @@ export default function FeedPage() {
</div> </div>
)} )}
{data && data.cats.length === 0 && ( {!isLoading && allCats.length === 0 && (
<div className="flex flex-col items-center justify-center py-20 text-center animate-fade-up"> <div className="flex flex-col items-center justify-center py-20 text-center animate-fade-up">
<div className="mb-4 h-14 w-14 rounded-2xl flex items-center justify-center bg-[var(--accent-light)]"> <div className="mb-4 h-14 w-14 rounded-2xl flex items-center justify-center bg-[var(--accent-light)]">
<Cat className="h-7 w-7 text-[var(--accent)]" strokeWidth={1.5} /> <Cat className="h-7 w-7 text-[var(--accent)]" strokeWidth={1.5} />
@@ -86,37 +100,25 @@ export default function FeedPage() {
</div> </div>
)} )}
{data && data.cats.length > 0 && ( {allCats.length > 0 && (
<> <>
<div className="space-y-5"> <div className="space-y-5">
{data.cats.map(cat => ( {allCats.map((cat) => (
<CatCard key={cat.id} cat={cat} onOpen={setModalCatId} likedIds={likedIds} /> <CatCard key={cat.id} cat={cat} onOpen={setModalCatId} likedIds={likedIds} />
))} ))}
</div> </div>
{data.totalPages > 1 && ( {/* Sentinel for infinite scroll */}
<nav className="flex items-center justify-center gap-2 mt-8 pb-6"> <div ref={sentinelRef} className="h-4" />
<Button variant="outline" size="sm" onClick={() => setPage(p => Math.max(1, p - 1))} disabled={page === 1}
className="rounded-xl text-xs h-9 px-3 gap-1">
<ChevronLeft className="h-4 w-4" strokeWidth={1.5} />
</Button>
<div className="flex gap-1"> {isFetchingNextPage && (
{Array.from({ length: Math.min(data.totalPages, 7) }, (_, i) => i + 1).map(p => ( <div className="flex justify-center py-6">
<button key={p} onClick={() => setPage(p)} <Loader2 className="h-6 w-6 animate-spin text-[var(--fg-tertiary)]" strokeWidth={1.5} />
className={`h-9 min-w-[36px] rounded-xl text-xs font-medium transition-all ${
p === page ? 'bg-[var(--accent)] text-white shadow-sm' : 'text-[var(--fg-tertiary)] hover:bg-[var(--border-light)]'
}`}>
{p}
</button>
))}
</div> </div>
)}
<Button variant="outline" size="sm" onClick={() => setPage(p => p + 1)} disabled={page >= data.totalPages} {!hasNextPage && allCats.length > 0 && (
className="rounded-xl text-xs h-9 px-3 gap-1"> <p className="text-center text-xs text-[var(--fg-tertiary)] py-6">Всё загружено </p>
<ChevronRight className="h-4 w-4" strokeWidth={1.5} />
</Button>
</nav>
)} )}
</> </>
)} )}

View File

@@ -1,6 +1,36 @@
#!/bin/bash #!/bin/bash
set -e set -e
BACKUP_DIR="$HOME/cats-backups/$(date +%Y-%m-%d_%H-%M-%S)"
echo "==> Creating backup at $BACKUP_DIR ..."
mkdir -p "$BACKUP_DIR"
# Backup database
if [ -f server/data.db ]; then
cp server/data.db "$BACKUP_DIR/data.db"
echo " ✓ data.db backed up"
else
echo " ⚠ data.db not found, skipping"
fi
# Backup uploads
if [ -d server/uploads ] && [ "$(ls -A server/uploads 2>/dev/null)" ]; then
cp -r server/uploads "$BACKUP_DIR/uploads"
echo " ✓ uploads/ backed up ($(ls server/uploads | wc -l) files)"
else
echo " ⚠ uploads/ empty or not found, skipping"
fi
# Keep only last 10 backups
BACKUP_ROOT="$HOME/cats-backups"
BACKUP_COUNT=$(ls -1 "$BACKUP_ROOT" | wc -l)
if [ "$BACKUP_COUNT" -gt 10 ]; then
ls -1t "$BACKUP_ROOT" | tail -n +11 | xargs -I{} rm -rf "$BACKUP_ROOT/{}"
echo " ✓ Old backups pruned (kept last 10)"
fi
echo ""
echo "==> Pulling latest changes..." echo "==> Pulling latest changes..."
git pull git pull
@@ -11,10 +41,12 @@ echo "==> Installing client dependencies..."
npm install --prefix client npm install --prefix client
echo "==> Building client..." echo "==> Building client..."
npm run build npm run build --prefix client
echo "==> Restarting PM2 process..." echo "==> Restarting PM2 process..."
sudo pm2 restart cats sudo pm2 restart cats
sudo pm2 save sudo pm2 save
echo ""
echo "==> Done! Site updated at http://cats.heagbokat-server.ru" echo "==> Done! Site updated at http://cats.heagbokat-server.ru"
echo " Backup saved to: $BACKUP_DIR"

100
plan_modernization.md Normal file
View File

@@ -0,0 +1,100 @@
# Котограм — план модернизации
Идеи для последующих доработок, сгруппированные по направлениям.
---
## Функционал
### Социальное взаимодействие
- **Подписки на пользователей** — лента только из постов тех, на кого подписан; отдельная вкладка «Все».
- **Реакции вместо лайков** — несколько эмодзи (❤️ 😂 😮 😢 🔥), каждый даёт разное кол-во очков.
- **Репосты / поделиться** — пользователь может поделиться ссылкой на пост или репостнуть к себе в профиль.
- **Упоминания @username** в комментариях с уведомлением (push или in-app badge).
- **Теги #хэштеги** на фотографиях и фильтрация ленты по тегу.
### Контент
- **Сторис (истории)** — публикация на 24 часа, круговые аватарки с индикатором непросмотренной истории.
- **Карусель фотографий** — несколько фото в одном посте (swipe).
- **Видео поддержка** — короткие видео до 30 сек (mp4, mov), хранение в `uploads/`.
- **Черновики** — сохранить пост не публикуя.
- **Редактирование подписи** к своему посту.
### Профиль
- **Bio / описание** — поле до 150 символов в профиле.
- **Верификация** — значок ✓ для доверенных аккаунтов, назначается администратором.
- **Статистика профиля** — охват, просмотры за 7/30 дней.
- **Закреплённые посты** — возможность закрепить 1 пост вверху профиля.
### Поиск и открытие
- **Страница «Обзор»** — сетка популярных постов (по лайкам за 7 дней) как на вкладке «Explore» в Instagram.
- **Поиск пользователей** по username.
- **Лента трендов** — ТОП-10 котов недели в sidebar.
### Уведомления
- **In-app уведомления** — новый лайк, комментарий, подписчик (real-time через WebSocket или polling).
- **Push-уведомления** (PWA) — с разрешения пользователя.
---
## Интерфейс и UX
### Навигация
- **Мобильный bottom navigation bar** — иконки Лента / Поиск / Загрузить / Уведомления / Профиль.
- **Жест swipe** между постами на мобильном (как в Instagram Stories).
- **Keyboard shortcuts** для десктопа: `L` — лайк, `Esc` — закрыть модал, стрелки — следующий/предыдущий пост.
### Карточка поста
- **Двойной тап — лайк** (как в Instagram), с анимацией большого сердца поверх фото.
- **Lazy loading** с blur placeholder пока грузится изображение.
- **Zoom** по клику/пинчу на фото.
### Профиль
- **Grid-режим** фотографий (3 колонки) как в Instagram по умолчанию, переключатель на список.
- **Анимированный аватар** — возможность загрузить gif.
### Тёмная / светлая тема
- Тема уже есть. Добавить **«Авто» по системной теме** (уже `prefers-color-scheme`, но сделать переключатель 3-позиционный).
### Доступность
- Описания (`alt` тексты) генерировать через API Claude Vision как подсказку при загрузке.
- Навигация с клавиатуры для всех интерактивных элементов.
---
## Производительность
- **CDN для изображений** — вынести `uploads/` на Cloudflare R2 / S3, раздавать через CDN.
- **WebP конвертация** — уже есть sharp; конвертировать в WebP вместо JPEG (меньше размер при том же качестве), отдавать JPEG как fallback.
- **Мигрировать с sql.js на better-sqlite3** — нативный SQLite, не держит весь файл БД в RAM, в 10-20x быстрее на записи.
- **Redis кэш** для топа пользователей и трендовых постов (sidebar), TTL 5 минут.
- **Виртуальный список** (react-virtual / @tanstack/virtual) в ленте при 100+ постов — не рендерить вне viewport.
---
## Безопасность (дополнительно)
- **2FA / TOTP** — опционально для аккаунтов.
- **Чёрный список слов** в комментариях (модерация).
- **Жалоба на пост** — кнопка «Пожаловаться», уведомление администратору.
- **Автоматическое удаление сессий** — логаут всех активных сессий при смене пароля.
- **Сканирование загружаемых файлов** на вирусы через ClamAV (опционально).
---
## Инфраструктура
- **Docker** — `docker-compose.yml` для локальной разработки и VPS деплоя.
- **GitHub Actions CI** — автоматический билд и тест при push.
- **Автоматические бэкапы БД** — cron на сервере каждые 6 часов копирует `data.db` в `/backups/`.
- **Health-check endpoint** уже есть (`/api/health`). Подключить к uptime-мониторингу (UptimeRobot / Betterstack).
- **Логирование** — структурированные JSON-логи через `pino` или `winston` вместо `console.log`.
---
## Геймификация и монетизация
- **Рейтинговые лиги** — Bronze / Silver / Gold / Diamond по кол-ву очков.
- **Достижения** — «Первый кот», «100 лайков», «Месяц на сайте» и т.д. с иконками-бейджами в профиле.
- **Ежедневные задания** — «Поставь 5 лайков сегодня» за бонусные очки.
- **Донаты / супер-лайки** — платный лайк с особым отображением (опционально через Stripe/YooKassa).

View File

@@ -15,6 +15,7 @@
"helmet": "^8.2.0", "helmet": "^8.2.0",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"multer": "^1.4.5-lts.1", "multer": "^1.4.5-lts.1",
"sharp": "^0.33.0",
"sql.js": "^1.14.1" "sql.js": "^1.14.1"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -20,6 +20,7 @@ export async function initDb(): Promise<SqlJsDatabase> {
} }
db.run('PRAGMA foreign_keys = ON'); db.run('PRAGMA foreign_keys = ON');
db.run('PRAGMA journal_mode = WAL');
db.run(` db.run(`
CREATE TABLE IF NOT EXISTS users ( CREATE TABLE IF NOT EXISTS users (
@@ -107,6 +108,15 @@ export function queryAll(sql: string, params: any[] = []): any[] {
return results; 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 } { export function execute(sql: string, params: any[] = []): { changes: number; lastInsertRowid: number } {
const stmt = db.prepare(sql); const stmt = db.prepare(sql);
stmt.bind(params); stmt.bind(params);

View File

@@ -5,7 +5,7 @@ import helmet from 'helmet';
import rateLimit from 'express-rate-limit'; import rateLimit from 'express-rate-limit';
import path from 'path'; import path from 'path';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import { initDb } from './db.js'; import { initDb, setupGracefulShutdown } from './db.js';
import authRoutes from './routes/auth.js'; import authRoutes from './routes/auth.js';
import catRoutes from './routes/cats.js'; import catRoutes from './routes/cats.js';
import likeRoutes from './routes/likes.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() { async function main() {
await initDb(); await initDb();
setupGracefulShutdown();
const app = express(); const app = express();
const PORT = parseInt(process.env.PORT || '3040'); const PORT = parseInt(process.env.PORT || '3040');
@@ -46,6 +47,15 @@ async function main() {
app.use('/api/auth/login', authLimiter); app.use('/api/auth/login', authLimiter);
app.use('/api/auth/register', 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('/uploads', express.static(path.join(__dirname, '..', 'uploads')));
app.use('/api/auth', authRoutes); app.use('/api/auth', authRoutes);

View File

@@ -1,7 +1,13 @@
import { Router, Response } from 'express'; import { Router, Response } from 'express';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { queryAll, queryOne, execute } from '../db'; import { queryAll, queryOne, execute } from '../db';
import { authMiddleware, adminOnly, AuthRequest } from '../middleware/auth.js'; 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(); const router = Router();
router.use(authMiddleware, adminOnly); 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]); 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) { res.status(404).json({ error: 'User not found' }); return; }
if (user.is_admin) { res.status(400).json({ error: 'Cannot delete admin' }); 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 likes WHERE cat_id IN (SELECT id FROM cats WHERE user_id = ?)', [userId]);
execute('DELETE FROM cats WHERE user_id = ?', [userId]); execute('DELETE FROM cats WHERE user_id = ?', [userId]);
execute('DELETE FROM users WHERE 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 }); 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) => { 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; } 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 likes WHERE cat_id = ?', [req.params.id]);
execute('DELETE FROM cats WHERE 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 }); res.json({ ok: true });
}); });

View File

@@ -1,28 +1,43 @@
import { Router, Response } from 'express'; import { Router, Response } from 'express';
import multer from 'multer'; import multer from 'multer';
import path from 'path'; import path from 'path';
import fs from 'fs';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import sharp from 'sharp';
import { queryOne, queryAll, execute } from '../db'; import { queryOne, queryAll, execute } from '../db';
import { authMiddleware, AuthRequest } from '../middleware/auth.js'; import { authMiddleware, AuthRequest } from '../middleware/auth.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url)); const __dirname = path.dirname(fileURLToPath(import.meta.url));
const uploadsDir = path.join(__dirname, '..', '..', 'uploads'); 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({ const storage = multer.diskStorage({
destination: (_req, _file, cb) => cb(null, uploadsDir), destination: (_req, _file, cb) => cb(null, uploadsDir),
filename: (_req, file, cb) => { 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}`); cb(null, `${Date.now()}-${Math.random().toString(36).slice(2, 8)}${ext}`);
}, },
}); });
const upload = multer({ const upload = multer({
storage, storage,
limits: { fileSize: 10 * 1024 * 1024 }, limits: { fileSize: 20 * 1024 * 1024 },
fileFilter: (_req, file, cb) => { fileFilter: (_req, file, cb) => {
const allowed = ['.jpg', '.jpeg', '.png', '.gif', '.webp'];
const ext = path.extname(file.originalname).toLowerCase(); 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 }); 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) { 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; return;
} }
const caption = (req.body.caption || '').trim(); 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( execute(
'INSERT INTO cats (user_id, image_url, caption) VALUES (?, ?, ?)', '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) => { 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) { if (!cat) {
res.status(404).json({ error: 'Cat not found' }); res.status(404).json({ error: 'Cat not found' });
return; 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 comments WHERE cat_id = ?', [req.params.id]);
execute('DELETE FROM likes 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]); 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 }); res.json({ ok: true });
}); });