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:
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Cat } from '@/api/endpoints';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
@@ -17,6 +17,10 @@ export default function CatCard({ cat, onOpen, likedIds }: CatCardProps) {
|
||||
const { user } = useAuth();
|
||||
const [liked, setLiked] = useState(() => likedIds?.has(cat.id) ?? false);
|
||||
const [likeCount, setLikeCount] = useState(cat.likes_count);
|
||||
|
||||
useEffect(() => {
|
||||
if (likedIds !== undefined) setLiked(likedIds.has(cat.id));
|
||||
}, [likedIds, cat.id]);
|
||||
const { toast } = useToast();
|
||||
const likeMutation = useLikeCat(cat.id);
|
||||
const unlikeMutation = useUnlikeCat(cat.id);
|
||||
|
||||
@@ -13,14 +13,13 @@ function RankBadge({ rank }: { rank: number }) {
|
||||
|
||||
export default function FeedSidebar() {
|
||||
const { user } = useAuth();
|
||||
const { data } = useCats(1);
|
||||
const { data } = useCats();
|
||||
|
||||
const usersMap = new Map<number, { username: string; points: number; avatar_url: string | null }>();
|
||||
if (data?.cats) {
|
||||
for (const cat of data.cats) {
|
||||
if (!usersMap.has(cat.user_id))
|
||||
usersMap.set(cat.user_id, { username: cat.username, points: cat.user_points, avatar_url: cat.user_avatar_url });
|
||||
}
|
||||
const firstPageCats = data?.pages[0]?.cats ?? [];
|
||||
for (const cat of firstPageCats) {
|
||||
if (!usersMap.has(cat.user_id))
|
||||
usersMap.set(cat.user_id, { username: cat.username, points: cat.user_points, avatar_url: cat.user_avatar_url });
|
||||
}
|
||||
|
||||
const topUsers = Array.from(usersMap.entries())
|
||||
|
||||
@@ -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';
|
||||
|
||||
export function useCats(page: number) {
|
||||
return useQuery({
|
||||
queryKey: ['cats', page],
|
||||
queryFn: () => getCats(page).then((r) => r.data),
|
||||
export function useCats() {
|
||||
return useInfiniteQuery({
|
||||
queryKey: ['cats'],
|
||||
queryFn: ({ pageParam }) => getCats(pageParam as number).then((r) => r.data),
|
||||
initialPageParam: 1,
|
||||
getNextPageParam: (lastPage) =>
|
||||
lastPage.page < lastPage.totalPages ? lastPage.page + 1 : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useCats } from '@/hooks/useCats';
|
||||
import CatCard from '@/components/CatCard';
|
||||
import FeedSidebar from '@/components/FeedSidebar';
|
||||
@@ -6,7 +6,7 @@ import CatModal from '@/components/CatModal';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { getBatchLikeStatus } from '@/api/endpoints';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { ChevronLeft, ChevronRight, RefreshCw, Cat } from 'lucide-react';
|
||||
import { RefreshCw, Cat, Loader2 } from 'lucide-react';
|
||||
|
||||
function FeedSkeleton() {
|
||||
return (
|
||||
@@ -32,26 +32,40 @@ function FeedSkeleton() {
|
||||
}
|
||||
|
||||
export default function FeedPage() {
|
||||
const [page, setPage] = useState(1);
|
||||
const [modalCatId, setModalCatId] = useState<number | null>(null);
|
||||
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 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(() => {
|
||||
if (!data?.cats?.length || !user) return;
|
||||
const ids = data.cats.map(c => c.id);
|
||||
getBatchLikeStatus(ids)
|
||||
.then(r => setLikedIds(new Set(r.data.likedIds)))
|
||||
.catch(() => {});
|
||||
}, [data?.cats, user]);
|
||||
|
||||
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; });
|
||||
};
|
||||
const el = sentinelRef.current;
|
||||
if (!el) return;
|
||||
const observer = new IntersectionObserver(onSentinelVisible, { rootMargin: '200px' });
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, [onSentinelVisible]);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-[900px] px-5 py-5">
|
||||
@@ -72,7 +86,7 @@ export default function FeedPage() {
|
||||
</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="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} />
|
||||
@@ -86,37 +100,25 @@ export default function FeedPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data && data.cats.length > 0 && (
|
||||
{allCats.length > 0 && (
|
||||
<>
|
||||
<div className="space-y-5">
|
||||
{data.cats.map(cat => (
|
||||
{allCats.map((cat) => (
|
||||
<CatCard key={cat.id} cat={cat} onOpen={setModalCatId} likedIds={likedIds} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{data.totalPages > 1 && (
|
||||
<nav className="flex items-center justify-center gap-2 mt-8 pb-6">
|
||||
<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>
|
||||
{/* Sentinel for infinite scroll */}
|
||||
<div ref={sentinelRef} className="h-4" />
|
||||
|
||||
<div className="flex gap-1">
|
||||
{Array.from({ length: Math.min(data.totalPages, 7) }, (_, i) => i + 1).map(p => (
|
||||
<button key={p} onClick={() => setPage(p)}
|
||||
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>
|
||||
{isFetchingNextPage && (
|
||||
<div className="flex justify-center py-6">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-[var(--fg-tertiary)]" strokeWidth={1.5} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button variant="outline" size="sm" onClick={() => setPage(p => p + 1)} disabled={page >= data.totalPages}
|
||||
className="rounded-xl text-xs h-9 px-3 gap-1">
|
||||
<ChevronRight className="h-4 w-4" strokeWidth={1.5} />
|
||||
</Button>
|
||||
</nav>
|
||||
{!hasNextPage && allCats.length > 0 && (
|
||||
<p className="text-center text-xs text-[var(--fg-tertiary)] py-6">Всё загружено ✓</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
@@ -130,4 +132,4 @@ export default function FeedPage() {
|
||||
{modalCatId && <CatModal catId={modalCatId} onClose={() => setModalCatId(null)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
34
deploy.sh
34
deploy.sh
@@ -1,6 +1,36 @@
|
||||
#!/bin/bash
|
||||
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..."
|
||||
git pull
|
||||
|
||||
@@ -11,10 +41,12 @@ echo "==> Installing client dependencies..."
|
||||
npm install --prefix client
|
||||
|
||||
echo "==> Building client..."
|
||||
npm run build
|
||||
npm run build --prefix client
|
||||
|
||||
echo "==> Restarting PM2 process..."
|
||||
sudo pm2 restart cats
|
||||
sudo pm2 save
|
||||
|
||||
echo ""
|
||||
echo "==> Done! Site updated at http://cats.heagbokat-server.ru"
|
||||
echo " Backup saved to: $BACKUP_DIR"
|
||||
|
||||
100
plan_modernization.md
Normal file
100
plan_modernization.md
Normal 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).
|
||||
@@ -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