Баллы +1 за загрузку, убраны из лайков. Админ-панель (admin/admin123)

This commit is contained in:
2026-05-29 12:13:42 +03:00
parent f8f220469c
commit 12701ffbc2
19 changed files with 250 additions and 62 deletions

View File

@@ -10,6 +10,7 @@ import FeedPage from '@/pages/FeedPage';
import UploadPage from '@/pages/UploadPage'; import UploadPage from '@/pages/UploadPage';
import CatPage from '@/pages/CatPage'; import CatPage from '@/pages/CatPage';
import ProfilePage from '@/pages/ProfilePage'; import ProfilePage from '@/pages/ProfilePage';
import AdminPage from '@/pages/AdminPage';
import { useEffect, useRef } from 'react'; import { useEffect, useRef } from 'react';
const queryClient = new QueryClient({ const queryClient = new QueryClient({
@@ -82,6 +83,14 @@ function AnimatedRoutes() {
</ProtectedRoute> </ProtectedRoute>
} }
/> />
<Route
path="/admin"
element={
<ProtectedRoute>
<AdminPage />
</ProtectedRoute>
}
/>
<Route path="/" element={<Navigate to="/feed" replace />} /> <Route path="/" element={<Navigate to="/feed" replace />} />
<Route path="*" element={<Navigate to="/feed" replace />} /> <Route path="*" element={<Navigate to="/feed" replace />} />
</Routes> </Routes>

View File

@@ -52,13 +52,26 @@ export function deleteCat(id: number) {
} }
export function likeCat(catId: number) { export function likeCat(catId: number) {
return api.post<{ liked: boolean; likesCount: number; ownerPoints: number }>(`/likes/${catId}`); return api.post<{ liked: boolean; likesCount: number }>(`/likes/${catId}`);
} }
export function unlikeCat(catId: number) { export function unlikeCat(catId: number) {
return api.delete<{ liked: boolean; likesCount: number; ownerPoints: number }>(`/likes/${catId}`); return api.delete<{ liked: boolean; likesCount: number }>(`/likes/${catId}`);
} }
export function getLikeStatus(catId: number) { export function getLikeStatus(catId: number) {
return api.get<{ liked: boolean }>(`/likes/${catId}/status`); return api.get<{ liked: boolean }>(`/likes/${catId}/status`);
} }
// Admin
export function getAdminUsers() {
return api.get<{ users: User[] }>('/admin/users');
}
export function adminDeleteCat(id: number) {
return api.delete(`/admin/cats/${id}`);
}
export function adminDeleteUser(id: number) {
return api.delete(`/admin/users/${id}`);
}

View File

@@ -12,7 +12,7 @@ interface CatCardProps {
} }
export default function CatCard({ cat, onOpen }: CatCardProps) { export default function CatCard({ cat, onOpen }: CatCardProps) {
const { user, updateUser } = useAuth(); const { user } = useAuth();
const [liked, setLiked] = useState(false); const [liked, setLiked] = useState(false);
const [likeCount, setLikeCount] = useState(cat.likes_count); const [likeCount, setLikeCount] = useState(cat.likes_count);
const { toast } = useToast(); const { toast } = useToast();
@@ -25,11 +25,8 @@ export default function CatCard({ cat, onOpen }: CatCardProps) {
if (liked) { if (liked) {
setLiked(false); setLiked(false);
setLikeCount((c) => c - 1); setLikeCount((c) => c - 1);
try { try { await unlikeMutation.mutateAsync(); }
const res = await unlikeMutation.mutateAsync(); catch { setLiked(true); setLikeCount((c) => c + 1); }
if (res.ownerPoints !== undefined && cat.user_id === user.id)
updateUser({ ...user, points: res.ownerPoints });
} catch { setLiked(true); setLikeCount((c) => c + 1); }
} else { } else {
setLiked(true); setLiked(true);
setLikeCount((c) => c + 1); setLikeCount((c) => c + 1);

View File

@@ -12,7 +12,7 @@ interface CatModalProps {
} }
export default function CatModal({ catId, onClose }: CatModalProps) { export default function CatModal({ catId, onClose }: CatModalProps) {
const { user, updateUser } = useAuth(); const { user } = useAuth();
const { data, isLoading } = useCat(catId); const { data, isLoading } = useCat(catId);
const { data: likeData, refetch: refetchLike } = useLikeStatus(catId); const { data: likeData, refetch: refetchLike } = useLikeStatus(catId);
const likeMutation = useLikeCat(catId); const likeMutation = useLikeCat(catId);
@@ -40,11 +40,8 @@ export default function CatModal({ catId, onClose }: CatModalProps) {
if (isOwner || !user || !cat) return; if (isOwner || !user || !cat) return;
if (liked) { if (liked) {
setLiked(false); setLikeCount((c) => c - 1); setLiked(false); setLikeCount((c) => c - 1);
try { try { await unlikeMutation.mutateAsync(); refetchLike(); }
const res = await unlikeMutation.mutateAsync(); catch { setLiked(true); setLikeCount((c) => c + 1); }
if (res.ownerPoints !== undefined && cat.user_id === user.id) updateUser({ ...user, points: res.ownerPoints });
refetchLike();
} catch { setLiked(true); setLikeCount((c) => c + 1); }
} else { } else {
setLiked(true); setLikeCount((c) => c + 1); setLiked(true); setLikeCount((c) => c + 1);
toast('Нравится ❤️'); toast('Нравится ❤️');

View File

@@ -1,6 +1,7 @@
import { Link, useLocation } from 'react-router-dom'; import { Link, useLocation } from 'react-router-dom';
import { useAuth } from '@/hooks/useAuth'; import { useAuth } from '@/hooks/useAuth';
import { Avatar, AvatarFallback } from '@/components/ui/avatar'; import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { Shield } from 'lucide-react';
export default function Navbar() { export default function Navbar() {
const { user } = useAuth(); const { user } = useAuth();
@@ -19,12 +20,17 @@ export default function Navbar() {
<NavLink to="/feed" active={location.pathname === '/feed'} emoji="🏠" label="Лента" /> <NavLink to="/feed" active={location.pathname === '/feed'} emoji="🏠" label="Лента" />
<NavLink to="/upload" active={location.pathname === '/upload'} emoji="" label="Добавить" short /> <NavLink to="/upload" active={location.pathname === '/upload'} emoji="" label="Добавить" short />
<Link to="/profile" className="ml-2"> <Link to="/profile" className="ml-2 relative">
<Avatar className="h-8 w-8 ring-1 ring-[var(--border)] hover:ring-[var(--primary)] transition-all"> <Avatar className="h-8 w-8 ring-1 ring-[var(--border)] hover:ring-[var(--primary)] transition-all">
<AvatarFallback className="text-[11px] avatar-initials"> <AvatarFallback className="text-[11px] avatar-initials">
{user.username.charAt(0).toUpperCase()} {user.username.charAt(0).toUpperCase()}
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
{user.is_admin && (
<span className="absolute -top-1 -right-1 h-3.5 w-3.5 rounded-full bg-[var(--primary)] flex items-center justify-center">
<Shield className="h-2 w-2 text-white" strokeWidth={3} />
</span>
)}
</Link> </Link>
</div> </div>
</div> </div>

View File

@@ -13,34 +13,26 @@ export function useLikeStatus(catId: number) {
export function useLikeCat(catId: number) { export function useLikeCat(catId: number) {
const qc = useQueryClient(); const qc = useQueryClient();
const { updateUser, user } = useAuth();
return useMutation({ return useMutation({
mutationFn: () => likeCat(catId).then((r) => r.data), mutationFn: () => likeCat(catId).then((r) => r.data),
onSuccess: (data) => { onSuccess: () => {
qc.invalidateQueries({ queryKey: ['likeStatus', catId] }); qc.invalidateQueries({ queryKey: ['likeStatus', catId] });
qc.invalidateQueries({ queryKey: ['cat', catId] }); qc.invalidateQueries({ queryKey: ['cat', catId] });
qc.invalidateQueries({ queryKey: ['cats'] }); qc.invalidateQueries({ queryKey: ['cats'] });
if (user && data.ownerPoints !== undefined) {
updateUser({ ...user, points: data.ownerPoints });
}
}, },
}); });
} }
export function useUnlikeCat(catId: number) { export function useUnlikeCat(catId: number) {
const qc = useQueryClient(); const qc = useQueryClient();
const { updateUser, user } = useAuth();
return useMutation({ return useMutation({
mutationFn: () => unlikeCat(catId).then((r) => r.data), mutationFn: () => unlikeCat(catId).then((r) => r.data),
onSuccess: (data) => { onSuccess: () => {
qc.invalidateQueries({ queryKey: ['likeStatus', catId] }); qc.invalidateQueries({ queryKey: ['likeStatus', catId] });
qc.invalidateQueries({ queryKey: ['cat', catId] }); qc.invalidateQueries({ queryKey: ['cat', catId] });
qc.invalidateQueries({ queryKey: ['cats'] }); qc.invalidateQueries({ queryKey: ['cats'] });
if (user && data.ownerPoints !== undefined) {
updateUser({ ...user, points: data.ownerPoints });
}
}, },
}); });
} }

View File

@@ -5,6 +5,7 @@ export interface User {
id: number; id: number;
username: string; username: string;
points: number; points: number;
is_admin?: boolean;
created_at?: string; created_at?: string;
} }

View File

@@ -0,0 +1,99 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '@/hooks/useAuth';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { getAdminUsers, adminDeleteUser, adminDeleteCat } from '@/api/endpoints';
import { Button } from '@/components/ui/button';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { ArrowLeft, Trash2, Shield } from 'lucide-react';
import { useToast } from '@/components/Toast';
export default function AdminPage() {
const { user } = useAuth();
const navigate = useNavigate();
const { toast } = useToast();
const qc = useQueryClient();
const { data, isLoading } = useQuery({
queryKey: ['admin', 'users'],
queryFn: () => getAdminUsers().then(r => r.data),
enabled: !!user?.is_admin,
});
const deleteUserMutation = useMutation({
mutationFn: (id: number) => adminDeleteUser(id),
onSuccess: () => { qc.invalidateQueries({ queryKey: ['admin', 'users'] }); toast('Пользователь удалён'); },
onError: () => toast('Ошибка удаления', 'error'),
});
if (!user?.is_admin) {
return (
<div className="flex flex-col items-center justify-center py-24">
<div className="mb-5 flex h-16 w-16 items-center justify-center rounded-full bg-[var(--secondary)]">
<Shield className="h-8 w-8 text-[var(--fg-tertiary)]" strokeWidth={1.5} />
</div>
<p className="text-[17px] font-semibold mb-1.5">Доступ запрещён</p>
<Button variant="outline" onClick={() => navigate('/feed')} className="rounded-full text-[13px] mt-2">Вернуться в ленту</Button>
</div>
);
}
const users = data?.users ?? [];
return (
<div className="mx-auto max-w-[660px] px-4 py-6 animate-fade-in-up">
<button onClick={() => navigate(-1)}
className="mb-5 flex items-center gap-1.5 text-[14px] text-[var(--fg-secondary)] hover:text-[var(--fg)] transition-colors group">
<ArrowLeft className="h-4 w-4 stroke-[1.5] group-hover:-translate-x-0.5 transition-transform" />
Назад
</button>
<div className="flex items-center gap-3 mb-6">
<Shield className="h-6 w-6 text-[var(--primary)]" strokeWidth={1.5} />
<h1 className="text-[22px] font-bold">Админ-панель</h1>
</div>
{isLoading ? (
<div className="space-y-3">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="card p-4 flex items-center gap-3">
<div className="h-10 w-10 rounded-full bg-[var(--secondary)] animate-skeleton" />
<div className="space-y-2 flex-1">
<div className="h-4 w-32 rounded-full bg-[var(--secondary)] animate-skeleton" />
<div className="h-3 w-20 rounded-full bg-[var(--secondary)] animate-skeleton" />
</div>
</div>
))}
</div>
) : (
<div className="card divide-y">
<div className="px-4 py-2.5 text-[12px] font-bold text-[var(--fg-secondary)] uppercase tracking-widest flex items-center justify-between">
<span>Пользователи ({users.length})</span>
</div>
{users.map((u: any) => (
<div key={u.id} className="flex items-center gap-3 px-4 py-3">
<Avatar className="h-10 w-10 ring-1 ring-[var(--border)]">
<AvatarFallback className="text-sm avatar-initials">{u.username.charAt(0).toUpperCase()}</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-[15px] font-semibold truncate">@{u.username}</span>
{u.is_admin ? (
<span className="text-[10px] px-2 py-0.5 rounded-full bg-[var(--primary-light)] text-[var(--primary)] font-semibold">Админ</span>
) : null}
</div>
<p className="text-[12px] text-[var(--fg-secondary)]">🏆 {u.points} баллов</p>
</div>
{!u.is_admin && (
<button onClick={() => { if (confirm('Удалить пользователя и все его фото?')) deleteUserMutation.mutate(u.id); }}
className="btn-icon h-8 w-8 text-[var(--fg-tertiary)] hover:text-[var(--danger)] hover:bg-[var(--danger-light)]">
<Trash2 className="h-3.5 w-3.5" strokeWidth={1.5} />
</button>
)}
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -13,7 +13,7 @@ export default function CatPage() {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const catId = parseInt(id!); const catId = parseInt(id!);
const navigate = useNavigate(); const navigate = useNavigate();
const { user, updateUser } = useAuth(); const { user } = useAuth();
const { toast } = useToast(); const { toast } = useToast();
const { data, isLoading, isError } = useCat(catId); const { data, isLoading, isError } = useCat(catId);
@@ -34,11 +34,8 @@ export default function CatPage() {
if (isOwner || !user || !cat) return; if (isOwner || !user || !cat) return;
if (isLiked) { if (isLiked) {
setLiked(false); setLiked(false);
try { try { await unlikeMutation.mutateAsync(); refetchLike(); }
const res = await unlikeMutation.mutateAsync(); catch { setLiked(true); }
if (res.ownerPoints !== undefined && cat.user_id === user.id) updateUser({ ...user, points: res.ownerPoints });
refetchLike();
} catch { setLiked(true); }
} else { } else {
setLiked(true); setLiked(true);
toast('Нравится ❤️'); toast('Нравится ❤️');

View File

@@ -6,7 +6,7 @@ import CatModal from '@/components/CatModal';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import { Avatar, AvatarFallback } from '@/components/ui/avatar'; import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { ArrowLeft, Plus, LogOut, Heart } from 'lucide-react'; import { ArrowLeft, Plus, LogOut, Heart, Shield } from 'lucide-react';
export default function ProfilePage() { export default function ProfilePage() {
const { id: paramId } = useParams<{ id?: string }>(); const { id: paramId } = useParams<{ id?: string }>();
@@ -75,11 +75,18 @@ export default function ProfilePage() {
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-3 mb-2"> <div className="flex items-center gap-3 mb-2">
<h1 className="text-[20px] font-bold truncate">@{profileUser?.username}</h1> <h1 className="text-[20px] font-bold truncate">@{profileUser?.username}</h1>
{isMe && ( <div className="flex items-center gap-1 ml-auto">
<button onClick={logout} className="btn-icon h-8 w-8 text-[var(--fg-secondary)] hover:text-[var(--danger)] hover:bg-[var(--danger-light)] ml-auto" title="Выйти"> {isMe && me?.is_admin && (
<LogOut className="h-4 w-4" strokeWidth={1.5} /> <Link to="/admin" className="btn-icon h-8 w-8 text-[var(--primary)] hover:bg-[var(--primary-light)]" title="Админ-панель">
</button> <Shield className="h-4 w-4" strokeWidth={1.5} />
)} </Link>
)}
{isMe && (
<button onClick={logout} className="btn-icon h-8 w-8 text-[var(--fg-secondary)] hover:text-[var(--danger)] hover:bg-[var(--danger-light)]" title="Выйти">
<LogOut className="h-4 w-4" strokeWidth={1.5} />
</button>
)}
</div>
</div> </div>
<div className="flex items-center gap-6 text-[14px] mb-3"> <div className="flex items-center gap-6 text-[14px] mb-3">

View File

@@ -36,7 +36,7 @@ export default function UploadPage() {
try { try {
const result = await uploadMutation.mutateAsync(fd); const result = await uploadMutation.mutateAsync(fd);
if (user) updateUser({ ...user, points: (user.points || 0) + 10 }); if (user) updateUser({ ...user, points: (user.points || 0) + 10 });
toast('+10 баллов 🎉'); toast('+1 балл 🎉');
navigate(`/cat/${result.cat.id}`); navigate(`/cat/${result.cat.id}`);
} catch { toast('Ошибка загрузки', 'error'); } } catch { toast('Ошибка загрузки', 'error'); }
}; };
@@ -98,7 +98,7 @@ export default function UploadPage() {
<div className="flex items-center justify-between pt-3 border-t"> <div className="flex items-center justify-between pt-3 border-t">
<div className="flex items-center gap-2 text-[13px] text-[var(--fg-secondary)]"> <div className="flex items-center gap-2 text-[13px] text-[var(--fg-secondary)]">
<span>🏆</span> <span>🏆</span>
<span className="font-medium">+10 баллов</span> <span className="font-medium">+1 балл</span>
<span className="text-[var(--fg-tertiary)]">за публикацию</span> <span className="text-[var(--fg-tertiary)]">за публикацию</span>
</div> </div>
<Button onClick={handleUpload} disabled={uploadMutation.isPending} <Button onClick={handleUpload} disabled={uploadMutation.isPending}

View File

@@ -1 +1 @@
{"root":["./src/app.tsx","./src/main.tsx","./src/api/client.ts","./src/api/endpoints.ts","./src/components/catcard.tsx","./src/components/catmodal.tsx","./src/components/feedsidebar.tsx","./src/components/navbar.tsx","./src/components/protectedroute.tsx","./src/components/storycircle.tsx","./src/components/toast.tsx","./src/components/ui/avatar.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/skeleton.tsx","./src/hooks/useauth.tsx","./src/hooks/usecats.ts","./src/hooks/uselikes.ts","./src/lib/auth.ts","./src/lib/utils.ts","./src/pages/catpage.tsx","./src/pages/feedpage.tsx","./src/pages/loginpage.tsx","./src/pages/profilepage.tsx","./src/pages/registerpage.tsx","./src/pages/uploadpage.tsx"],"version":"5.9.3"} {"root":["./src/app.tsx","./src/main.tsx","./src/api/client.ts","./src/api/endpoints.ts","./src/components/catcard.tsx","./src/components/catmodal.tsx","./src/components/feedsidebar.tsx","./src/components/navbar.tsx","./src/components/protectedroute.tsx","./src/components/storycircle.tsx","./src/components/toast.tsx","./src/components/ui/avatar.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/skeleton.tsx","./src/hooks/useauth.tsx","./src/hooks/usecats.ts","./src/hooks/uselikes.ts","./src/lib/auth.ts","./src/lib/utils.ts","./src/pages/adminpage.tsx","./src/pages/catpage.tsx","./src/pages/feedpage.tsx","./src/pages/loginpage.tsx","./src/pages/profilepage.tsx","./src/pages/registerpage.tsx","./src/pages/uploadpage.tsx"],"version":"5.9.3"}

View File

@@ -2,6 +2,7 @@ import initSqlJs, { Database as SqlJsDatabase } from 'sql.js';
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import bcrypt from 'bcryptjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url)); const __dirname = path.dirname(fileURLToPath(import.meta.url));
const dbPath = path.join(__dirname, '..', 'data.db'); const dbPath = path.join(__dirname, '..', 'data.db');
@@ -26,10 +27,22 @@ export async function initDb(): Promise<SqlJsDatabase> {
username TEXT UNIQUE NOT NULL, username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL, password_hash TEXT NOT NULL,
points INTEGER DEFAULT 0, points INTEGER DEFAULT 0,
is_admin INTEGER DEFAULT 0,
created_at TEXT DEFAULT (datetime('now')) created_at TEXT DEFAULT (datetime('now'))
) )
`); `);
// Add is_admin column if missing (existing DB migration)
try { db.run('ALTER TABLE users ADD COLUMN is_admin INTEGER DEFAULT 0'); } catch {}
// Auto-create admin if not exists
const admin = queryOne('SELECT id FROM users WHERE username = ?', ['admin']);
if (!admin) {
const hash = bcrypt.hashSync('admin123', 10);
execute('INSERT INTO users (username, password_hash, is_admin) VALUES (?, ?, 1)', ['admin', hash]);
console.log('Admin user created: admin / admin123');
}
db.run(` db.run(`
CREATE TABLE IF NOT EXISTS cats ( CREATE TABLE IF NOT EXISTS cats (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,

View File

@@ -7,6 +7,7 @@ import { initDb } 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';
import adminRoutes from './routes/admin.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url)); const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -24,6 +25,7 @@ async function main() {
app.use('/api/auth', authRoutes); app.use('/api/auth', authRoutes);
app.use('/api/cats', catRoutes); app.use('/api/cats', catRoutes);
app.use('/api/likes', likeRoutes); app.use('/api/likes', likeRoutes);
app.use('/api/admin', adminRoutes);
app.get('/api/health', (_req, res) => { app.get('/api/health', (_req, res) => {
res.json({ status: 'ok' }); res.json({ status: 'ok' });

View File

@@ -1,15 +1,17 @@
import { Request, Response, NextFunction } from 'express'; import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken'; import jwt from 'jsonwebtoken';
import { queryOne } from '../db.js';
const JWT_SECRET = process.env.JWT_SECRET || 'catstagram-dev-secret-key-change-in-production'; const JWT_SECRET = process.env.JWT_SECRET || 'catstagram-dev-secret-key-change-in-production';
export interface AuthRequest extends Request { export interface AuthRequest extends Request {
userId?: number; userId?: number;
username?: string; username?: string;
isAdmin?: boolean;
} }
export function generateToken(userId: number, username: string): string { export function generateToken(userId: number, username: string, isAdmin: boolean): string {
return jwt.sign({ userId, username }, JWT_SECRET, { expiresIn: '7d' }); return jwt.sign({ userId, username, isAdmin }, JWT_SECRET, { expiresIn: '7d' });
} }
export function authMiddleware(req: AuthRequest, res: Response, next: NextFunction): void { export function authMiddleware(req: AuthRequest, res: Response, next: NextFunction): void {
@@ -21,11 +23,20 @@ export function authMiddleware(req: AuthRequest, res: Response, next: NextFuncti
const token = header.split(' ')[1]; const token = header.split(' ')[1];
try { try {
const decoded = jwt.verify(token, JWT_SECRET) as { userId: number; username: string }; const decoded = jwt.verify(token, JWT_SECRET) as { userId: number; username: string; isAdmin: boolean };
req.userId = decoded.userId; req.userId = decoded.userId;
req.username = decoded.username; req.username = decoded.username;
req.isAdmin = decoded.isAdmin;
next(); next();
} catch { } catch {
res.status(401).json({ error: 'Invalid token' }); res.status(401).json({ error: 'Invalid token' });
} }
} }
export function adminOnly(req: AuthRequest, res: Response, next: NextFunction): void {
if (!req.isAdmin) {
res.status(403).json({ error: 'Доступ только администратору' });
return;
}
next();
}

View File

@@ -0,0 +1,48 @@
import { Router, Response } from 'express';
import { queryAll, queryOne, execute } from '../db';
import { authMiddleware, adminOnly, AuthRequest } from '../middleware/auth.js';
const router = Router();
// All routes require admin
router.use(authMiddleware, adminOnly);
// Get all users
router.get('/users', (req: AuthRequest, res: Response) => {
const users = queryAll(
'SELECT id, username, points, is_admin, created_at FROM users ORDER BY points DESC'
);
res.json({ users });
});
// Delete any cat
router.delete('/cats/:id', (req: AuthRequest, res: Response) => {
const cat = queryOne('SELECT id FROM cats WHERE id = ?', [req.params.id]);
if (!cat) {
res.status(404).json({ error: 'Cat not found' });
return;
}
execute('DELETE FROM likes WHERE cat_id = ?', [req.params.id]);
execute('DELETE FROM cats WHERE id = ?', [req.params.id]);
res.json({ ok: true });
});
// Ban user (delete their cats and likes)
router.delete('/users/:id', (req: AuthRequest, res: Response) => {
const userId = parseInt(req.params.id);
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;
}
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]);
res.json({ ok: true });
});
export default router;

View File

@@ -27,32 +27,32 @@ router.post('/register', (req: Request, res: Response) => {
const password_hash = bcrypt.hashSync(password, 10); const password_hash = bcrypt.hashSync(password, 10);
execute('INSERT INTO users (username, password_hash) VALUES (?, ?)', [username, password_hash]); execute('INSERT INTO users (username, password_hash) VALUES (?, ?)', [username, password_hash]);
const user = queryOne('SELECT id, username, points FROM users WHERE username = ?', [username]); const user = queryOne('SELECT id, username, points, is_admin FROM users WHERE username = ?', [username]);
const token = generateToken(user.id, user.username); const token = generateToken(user.id, user.username, !!user.is_admin);
res.status(201).json({ token, user }); res.status(201).json({ token, user: { id: user.id, username: user.username, points: user.points, is_admin: !!user.is_admin } });
}); });
router.post('/login', (req: Request, res: Response) => { router.post('/login', (req: Request, res: Response) => {
const { username, password } = req.body; const { username, password } = req.body;
const user = queryOne('SELECT id, username, password_hash, points FROM users WHERE username = ?', [username]); const user = queryOne('SELECT id, username, password_hash, points, is_admin FROM users WHERE username = ?', [username]);
if (!user || !bcrypt.compareSync(password, user.password_hash)) { if (!user || !bcrypt.compareSync(password, user.password_hash)) {
res.status(401).json({ error: 'Неверное имя пользователя или пароль' }); res.status(401).json({ error: 'Неверное имя пользователя или пароль' });
return; return;
} }
const token = generateToken(user.id, user.username); const token = generateToken(user.id, user.username, !!user.is_admin);
res.json({ token, user: { id: user.id, username: user.username, points: user.points } }); res.json({ token, user: { id: user.id, username: user.username, points: user.points, is_admin: !!user.is_admin } });
}); });
router.get('/me', authMiddleware, (req: AuthRequest, res: Response) => { router.get('/me', authMiddleware, (req: AuthRequest, res: Response) => {
const user = queryOne('SELECT id, username, points, created_at FROM users WHERE id = ?', [req.userId]); const user = queryOne('SELECT id, username, points, is_admin, created_at FROM users WHERE id = ?', [req.userId]);
if (!user) { if (!user) {
res.status(404).json({ error: 'User not found' }); res.status(404).json({ error: 'User not found' });
return; return;
} }
res.json({ user }); res.json({ user: { id: user.id, username: user.username, points: user.points, is_admin: !!user.is_admin, created_at: user.created_at } });
}); });
export default router; export default router;

View File

@@ -99,7 +99,7 @@ router.post('/', authMiddleware, upload.single('image'), (req: AuthRequest, res:
[req.userId, image_url, caption] [req.userId, image_url, caption]
); );
execute('UPDATE users SET points = points + 10 WHERE id = ?', [req.userId]); execute('UPDATE users SET points = points + 1 WHERE id = ?', [req.userId]);
const cat = queryOne( const cat = queryOne(
`SELECT c.id, c.image_url, c.caption, c.created_at, c.user_id, `SELECT c.id, c.image_url, c.caption, c.created_at, c.user_id,
@@ -120,7 +120,7 @@ router.delete('/:id', authMiddleware, (req: AuthRequest, res: Response) => {
res.status(404).json({ error: 'Cat not found' }); res.status(404).json({ error: 'Cat not found' });
return; return;
} }
if (cat.user_id !== req.userId) { if (cat.user_id !== req.userId && !req.isAdmin) {
res.status(403).json({ error: 'Not your cat' }); res.status(403).json({ error: 'Not your cat' });
return; return;
} }

View File

@@ -26,12 +26,10 @@ router.post('/:catId', authMiddleware, (req: AuthRequest, res: Response) => {
} }
execute('INSERT INTO likes (cat_id, user_id) VALUES (?, ?)', [catId, userId]); execute('INSERT INTO likes (cat_id, user_id) VALUES (?, ?)', [catId, userId]);
execute('UPDATE users SET points = points + 1 WHERE id = ?', [cat.user_id]);
const countResult = queryOne('SELECT COUNT(*) as count FROM likes WHERE cat_id = ?', [catId]); const countResult = queryOne('SELECT COUNT(*) as count FROM likes WHERE cat_id = ?', [catId]);
const owner = queryOne('SELECT points FROM users WHERE id = ?', [cat.user_id]);
res.json({ liked: true, likesCount: countResult?.count || 0, ownerPoints: owner?.points || 0 }); res.json({ liked: true, likesCount: countResult?.count || 0 });
}); });
router.delete('/:catId', authMiddleware, (req: AuthRequest, res: Response) => { router.delete('/:catId', authMiddleware, (req: AuthRequest, res: Response) => {
@@ -51,12 +49,10 @@ router.delete('/:catId', authMiddleware, (req: AuthRequest, res: Response) => {
} }
execute('DELETE FROM likes WHERE id = ?', [existing.id]); execute('DELETE FROM likes WHERE id = ?', [existing.id]);
execute('UPDATE users SET points = MAX(0, points - 1) WHERE id = ?', [cat.user_id]);
const countResult = queryOne('SELECT COUNT(*) as count FROM likes WHERE cat_id = ?', [catId]); const countResult = queryOne('SELECT COUNT(*) as count FROM likes WHERE cat_id = ?', [catId]);
const owner = queryOne('SELECT points FROM users WHERE id = ?', [cat.user_id]);
res.json({ liked: false, likesCount: countResult?.count || 0, ownerPoints: owner?.points || 0 }); res.json({ liked: false, likesCount: countResult?.count || 0 });
}); });
router.get('/:catId/status', authMiddleware, (req: AuthRequest, res: Response) => { router.get('/:catId/status', authMiddleware, (req: AuthRequest, res: Response) => {
@@ -67,4 +63,4 @@ router.get('/:catId/status', authMiddleware, (req: AuthRequest, res: Response) =
res.json({ liked: !!liked }); res.json({ liked: !!liked });
}); });
export default router; export default router;