Merge pull request 'feat: infinite scroll, HEIC support, likes fix, security & backup' (#1) from claude/sleepy-shirley-adfe57 into main
Reviewed-on: #1
This commit was merged in pull request #1.
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 { 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);
|
||||||
|
|||||||
@@ -13,14 +13,13 @@ 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())
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 ${
|
</div>
|
||||||
p === page ? 'bg-[var(--accent)] text-white shadow-sm' : 'text-[var(--fg-tertiary)] hover:bg-[var(--border-light)]'
|
)}
|
||||||
}`}>
|
|
||||||
{p}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</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>
|
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
34
deploy.sh
34
deploy.sh
@@ -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
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",
|
"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": {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user